@switchbot/openapi-cli 3.4.1 → 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 +2444 -1597
- 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
|
+
],
|
|
7987
|
+
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
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],
|
|
7964
7995
|
statusFields: ["power", "brightness", "color", "colorTemperature", "version"]
|
|
7965
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
|
},
|
|
@@ -8380,9 +8462,8 @@ function updateCacheFromDeviceList(body) {
|
|
|
8380
8462
|
for (const d of body.deviceList) {
|
|
8381
8463
|
if (!d.deviceId) continue;
|
|
8382
8464
|
devices[d.deviceId] = {
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
type: d.deviceType ?? "",
|
|
8465
|
+
type: d.deviceType || d.controlType || "Unknown Device",
|
|
8466
|
+
typeSource: d.deviceType ? "deviceType" : d.controlType ? "controlType" : "deviceType",
|
|
8386
8467
|
name: d.deviceName,
|
|
8387
8468
|
category: "physical",
|
|
8388
8469
|
hubDeviceId: d.hubDeviceId,
|
|
@@ -8397,6 +8478,7 @@ function updateCacheFromDeviceList(body) {
|
|
|
8397
8478
|
if (!d.deviceId) continue;
|
|
8398
8479
|
devices[d.deviceId] = {
|
|
8399
8480
|
type: d.remoteType,
|
|
8481
|
+
typeSource: "remoteType",
|
|
8400
8482
|
name: d.deviceName,
|
|
8401
8483
|
category: "ir",
|
|
8402
8484
|
hubDeviceId: d.hubDeviceId,
|
|
@@ -8793,10 +8875,11 @@ async function fetchDeviceList(client, options = {}) {
|
|
|
8793
8875
|
const infraredRemoteList = [];
|
|
8794
8876
|
for (const [deviceId, entry] of Object.entries(cached2.devices)) {
|
|
8795
8877
|
if (entry.category === "physical") {
|
|
8878
|
+
const cachedDeviceType = entry.typeSource === "deviceType" ? entry.type : entry.typeSource === void 0 && entry.type !== entry.controlType ? entry.type : void 0;
|
|
8796
8879
|
deviceList.push({
|
|
8797
8880
|
deviceId,
|
|
8798
8881
|
deviceName: entry.name,
|
|
8799
|
-
...
|
|
8882
|
+
...cachedDeviceType && cachedDeviceType !== "Unknown Device" ? { deviceType: cachedDeviceType } : {},
|
|
8800
8883
|
enableCloudService: entry.enableCloudService ?? true,
|
|
8801
8884
|
hubDeviceId: entry.hubDeviceId ?? "",
|
|
8802
8885
|
roomID: entry.roomID,
|
|
@@ -8867,10 +8950,12 @@ async function executeCommand(deviceId, cmd, parameter, commandType, client, opt
|
|
|
8867
8950
|
if (err instanceof Error && err.name === "DryRunSignal") {
|
|
8868
8951
|
writeAudit({ ...baseAudit, result: "dry-run" });
|
|
8869
8952
|
} else {
|
|
8953
|
+
const statusCode = err instanceof ApiError ? err.code : void 0;
|
|
8870
8954
|
writeAudit({
|
|
8871
8955
|
...baseAudit,
|
|
8872
8956
|
result: "error",
|
|
8873
|
-
error: err instanceof Error ? err.message : String(err)
|
|
8957
|
+
error: err instanceof Error ? err.message : String(err),
|
|
8958
|
+
...statusCode !== void 0 ? { statusCode } : {}
|
|
8874
8959
|
});
|
|
8875
8960
|
}
|
|
8876
8961
|
throw err;
|
|
@@ -8991,7 +9076,23 @@ async function describeDevice(deviceId, options = {}, client) {
|
|
|
8991
9076
|
ir = infraredRemoteList.find((d) => d.deviceId === deviceId);
|
|
8992
9077
|
}
|
|
8993
9078
|
if (!physical && !ir) throw new DeviceNotFoundError(deviceId);
|
|
8994
|
-
|
|
9079
|
+
let typeName;
|
|
9080
|
+
let typeSource;
|
|
9081
|
+
if (physical) {
|
|
9082
|
+
if (physical.deviceType) {
|
|
9083
|
+
typeName = physical.deviceType;
|
|
9084
|
+
typeSource = "deviceType";
|
|
9085
|
+
} else if (physical.controlType) {
|
|
9086
|
+
typeName = physical.controlType;
|
|
9087
|
+
typeSource = "controlType";
|
|
9088
|
+
} else {
|
|
9089
|
+
typeName = "Unknown Device";
|
|
9090
|
+
typeSource = "deviceType";
|
|
9091
|
+
}
|
|
9092
|
+
} else {
|
|
9093
|
+
typeName = ir.remoteType;
|
|
9094
|
+
typeSource = "remoteType";
|
|
9095
|
+
}
|
|
8995
9096
|
const match = typeName ? findCatalogEntry(typeName) : null;
|
|
8996
9097
|
const catalogEntry = !match || Array.isArray(match) ? null : match;
|
|
8997
9098
|
let liveStatus;
|
|
@@ -9027,6 +9128,7 @@ async function describeDevice(deviceId, options = {}, client) {
|
|
|
9027
9128
|
device: selectedDevice,
|
|
9028
9129
|
isPhysical: Boolean(physical),
|
|
9029
9130
|
typeName,
|
|
9131
|
+
typeSource,
|
|
9030
9132
|
controlType: physical?.controlType ?? ir?.controlType ?? null,
|
|
9031
9133
|
catalog: catalogEntry,
|
|
9032
9134
|
capabilities,
|
|
@@ -31710,6 +31812,162 @@ function applyFilter(clauses, deviceList, infraredRemoteList, hubLocation) {
|
|
|
31710
31812
|
// src/devices/param-validator.ts
|
|
31711
31813
|
init_cjs_shim();
|
|
31712
31814
|
init_output();
|
|
31815
|
+
|
|
31816
|
+
// src/devices/css-colors.ts
|
|
31817
|
+
init_cjs_shim();
|
|
31818
|
+
var CSS_COLORS = {
|
|
31819
|
+
aliceblue: [240, 248, 255],
|
|
31820
|
+
antiquewhite: [250, 235, 215],
|
|
31821
|
+
aqua: [0, 255, 255],
|
|
31822
|
+
aquamarine: [127, 255, 212],
|
|
31823
|
+
azure: [240, 255, 255],
|
|
31824
|
+
beige: [245, 245, 220],
|
|
31825
|
+
bisque: [255, 228, 196],
|
|
31826
|
+
black: [0, 0, 0],
|
|
31827
|
+
blanchedalmond: [255, 235, 205],
|
|
31828
|
+
blue: [0, 0, 255],
|
|
31829
|
+
blueviolet: [138, 43, 226],
|
|
31830
|
+
brown: [165, 42, 42],
|
|
31831
|
+
burlywood: [222, 184, 135],
|
|
31832
|
+
cadetblue: [95, 158, 160],
|
|
31833
|
+
chartreuse: [127, 255, 0],
|
|
31834
|
+
chocolate: [210, 105, 30],
|
|
31835
|
+
coral: [255, 127, 80],
|
|
31836
|
+
cornflowerblue: [100, 149, 237],
|
|
31837
|
+
cornsilk: [255, 248, 220],
|
|
31838
|
+
crimson: [220, 20, 60],
|
|
31839
|
+
cyan: [0, 255, 255],
|
|
31840
|
+
darkblue: [0, 0, 139],
|
|
31841
|
+
darkcyan: [0, 139, 139],
|
|
31842
|
+
darkgoldenrod: [184, 134, 11],
|
|
31843
|
+
darkgray: [169, 169, 169],
|
|
31844
|
+
darkgreen: [0, 100, 0],
|
|
31845
|
+
darkgrey: [169, 169, 169],
|
|
31846
|
+
darkkhaki: [189, 183, 107],
|
|
31847
|
+
darkmagenta: [139, 0, 139],
|
|
31848
|
+
darkolivegreen: [85, 107, 47],
|
|
31849
|
+
darkorange: [255, 140, 0],
|
|
31850
|
+
darkorchid: [153, 50, 204],
|
|
31851
|
+
darkred: [139, 0, 0],
|
|
31852
|
+
darksalmon: [233, 150, 122],
|
|
31853
|
+
darkseagreen: [143, 188, 143],
|
|
31854
|
+
darkslateblue: [72, 61, 139],
|
|
31855
|
+
darkslategray: [47, 79, 79],
|
|
31856
|
+
darkslategrey: [47, 79, 79],
|
|
31857
|
+
darkturquoise: [0, 206, 209],
|
|
31858
|
+
darkviolet: [148, 0, 211],
|
|
31859
|
+
deeppink: [255, 20, 147],
|
|
31860
|
+
deepskyblue: [0, 191, 255],
|
|
31861
|
+
dimgray: [105, 105, 105],
|
|
31862
|
+
dimgrey: [105, 105, 105],
|
|
31863
|
+
dodgerblue: [30, 144, 255],
|
|
31864
|
+
firebrick: [178, 34, 34],
|
|
31865
|
+
floralwhite: [255, 250, 240],
|
|
31866
|
+
forestgreen: [34, 139, 34],
|
|
31867
|
+
fuchsia: [255, 0, 255],
|
|
31868
|
+
gainsboro: [220, 220, 220],
|
|
31869
|
+
ghostwhite: [248, 248, 255],
|
|
31870
|
+
gold: [255, 215, 0],
|
|
31871
|
+
goldenrod: [218, 165, 32],
|
|
31872
|
+
gray: [128, 128, 128],
|
|
31873
|
+
green: [0, 128, 0],
|
|
31874
|
+
greenyellow: [173, 255, 47],
|
|
31875
|
+
grey: [128, 128, 128],
|
|
31876
|
+
honeydew: [240, 255, 240],
|
|
31877
|
+
hotpink: [255, 105, 180],
|
|
31878
|
+
indianred: [205, 92, 92],
|
|
31879
|
+
indigo: [75, 0, 130],
|
|
31880
|
+
ivory: [255, 255, 240],
|
|
31881
|
+
khaki: [240, 230, 140],
|
|
31882
|
+
lavender: [230, 230, 250],
|
|
31883
|
+
lavenderblush: [255, 240, 245],
|
|
31884
|
+
lawngreen: [124, 252, 0],
|
|
31885
|
+
lemonchiffon: [255, 250, 205],
|
|
31886
|
+
lightblue: [173, 216, 230],
|
|
31887
|
+
lightcoral: [240, 128, 128],
|
|
31888
|
+
lightcyan: [224, 255, 255],
|
|
31889
|
+
lightgoldenrodyellow: [250, 250, 210],
|
|
31890
|
+
lightgray: [211, 211, 211],
|
|
31891
|
+
lightgreen: [144, 238, 144],
|
|
31892
|
+
lightgrey: [211, 211, 211],
|
|
31893
|
+
lightpink: [255, 182, 193],
|
|
31894
|
+
lightsalmon: [255, 160, 122],
|
|
31895
|
+
lightseagreen: [32, 178, 170],
|
|
31896
|
+
lightskyblue: [135, 206, 250],
|
|
31897
|
+
lightslategray: [119, 136, 153],
|
|
31898
|
+
lightslategrey: [119, 136, 153],
|
|
31899
|
+
lightsteelblue: [176, 196, 222],
|
|
31900
|
+
lightyellow: [255, 255, 224],
|
|
31901
|
+
lime: [0, 255, 0],
|
|
31902
|
+
limegreen: [50, 205, 50],
|
|
31903
|
+
linen: [250, 240, 230],
|
|
31904
|
+
magenta: [255, 0, 255],
|
|
31905
|
+
maroon: [128, 0, 0],
|
|
31906
|
+
mediumaquamarine: [102, 205, 170],
|
|
31907
|
+
mediumblue: [0, 0, 205],
|
|
31908
|
+
mediumorchid: [186, 85, 211],
|
|
31909
|
+
mediumpurple: [147, 111, 219],
|
|
31910
|
+
mediumseagreen: [60, 179, 113],
|
|
31911
|
+
mediumslateblue: [123, 104, 238],
|
|
31912
|
+
mediumspringgreen: [0, 250, 154],
|
|
31913
|
+
mediumturquoise: [72, 209, 204],
|
|
31914
|
+
mediumvioletred: [199, 21, 133],
|
|
31915
|
+
midnightblue: [25, 25, 112],
|
|
31916
|
+
mintcream: [245, 255, 250],
|
|
31917
|
+
mistyrose: [255, 228, 225],
|
|
31918
|
+
moccasin: [255, 228, 181],
|
|
31919
|
+
navajowhite: [255, 222, 173],
|
|
31920
|
+
navy: [0, 0, 128],
|
|
31921
|
+
oldlace: [253, 245, 230],
|
|
31922
|
+
olive: [128, 128, 0],
|
|
31923
|
+
olivedrab: [107, 142, 35],
|
|
31924
|
+
orange: [255, 165, 0],
|
|
31925
|
+
orangered: [255, 69, 0],
|
|
31926
|
+
orchid: [218, 112, 214],
|
|
31927
|
+
palegoldenrod: [238, 232, 170],
|
|
31928
|
+
palegreen: [152, 251, 152],
|
|
31929
|
+
paleturquoise: [175, 238, 238],
|
|
31930
|
+
palevioletred: [219, 112, 147],
|
|
31931
|
+
papayawhip: [255, 239, 213],
|
|
31932
|
+
peachpuff: [255, 218, 185],
|
|
31933
|
+
peru: [205, 133, 63],
|
|
31934
|
+
pink: [255, 192, 203],
|
|
31935
|
+
plum: [221, 160, 221],
|
|
31936
|
+
powderblue: [176, 224, 230],
|
|
31937
|
+
purple: [128, 0, 128],
|
|
31938
|
+
rebeccapurple: [102, 51, 153],
|
|
31939
|
+
red: [255, 0, 0],
|
|
31940
|
+
rosybrown: [188, 143, 143],
|
|
31941
|
+
royalblue: [65, 105, 225],
|
|
31942
|
+
saddlebrown: [139, 69, 19],
|
|
31943
|
+
salmon: [250, 128, 114],
|
|
31944
|
+
sandybrown: [244, 164, 96],
|
|
31945
|
+
seagreen: [46, 139, 87],
|
|
31946
|
+
seashell: [255, 245, 238],
|
|
31947
|
+
sienna: [160, 82, 45],
|
|
31948
|
+
silver: [192, 192, 192],
|
|
31949
|
+
skyblue: [135, 206, 235],
|
|
31950
|
+
slateblue: [106, 90, 205],
|
|
31951
|
+
slategray: [112, 128, 144],
|
|
31952
|
+
slategrey: [112, 128, 144],
|
|
31953
|
+
snow: [255, 250, 250],
|
|
31954
|
+
springgreen: [0, 255, 127],
|
|
31955
|
+
steelblue: [70, 130, 180],
|
|
31956
|
+
tan: [210, 180, 140],
|
|
31957
|
+
teal: [0, 128, 128],
|
|
31958
|
+
thistle: [216, 191, 216],
|
|
31959
|
+
tomato: [255, 99, 71],
|
|
31960
|
+
turquoise: [64, 224, 208],
|
|
31961
|
+
violet: [238, 130, 238],
|
|
31962
|
+
wheat: [245, 222, 179],
|
|
31963
|
+
white: [255, 255, 255],
|
|
31964
|
+
whitesmoke: [245, 245, 245],
|
|
31965
|
+
yellow: [255, 255, 0],
|
|
31966
|
+
yellowgreen: [154, 205, 50]
|
|
31967
|
+
};
|
|
31968
|
+
|
|
31969
|
+
// src/devices/param-validator.ts
|
|
31970
|
+
init_catalog();
|
|
31713
31971
|
var AC_MODE_MAP = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
|
|
31714
31972
|
var AC_FAN_MAP = { auto: 1, low: 2, mid: 3, high: 4 };
|
|
31715
31973
|
var CURTAIN_MODE_MAP = { default: "ff", performance: "0", silent: "1" };
|
|
@@ -31761,6 +32019,9 @@ function buildBlindTiltSetPosition(opts) {
|
|
|
31761
32019
|
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
|
|
31762
32020
|
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
|
|
31763
32021
|
}
|
|
32022
|
+
if (angle % 2 !== 0) {
|
|
32023
|
+
throw new UsageError(`--angle must be a multiple of 2 (got "${opts.angle}"). Example: --angle 50`);
|
|
32024
|
+
}
|
|
31764
32025
|
return `${dir};${angle}`;
|
|
31765
32026
|
}
|
|
31766
32027
|
function buildRelaySetMode(opts) {
|
|
@@ -31776,11 +32037,12 @@ function buildRelaySetMode(opts) {
|
|
|
31776
32037
|
}
|
|
31777
32038
|
return `${ch};${modeInt}`;
|
|
31778
32039
|
}
|
|
31779
|
-
function buildBrightnessSet(opts) {
|
|
31780
|
-
|
|
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})`);
|
|
31781
32043
|
const b2 = parseInt(opts.brightness, 10);
|
|
31782
|
-
if (!Number.isFinite(b2) || b2 <
|
|
31783
|
-
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}")`);
|
|
31784
32046
|
}
|
|
31785
32047
|
return String(b2);
|
|
31786
32048
|
}
|
|
@@ -31796,94 +32058,203 @@ function buildColorTemperatureSet(opts) {
|
|
|
31796
32058
|
if (!result.ok) throw new UsageError(result.error);
|
|
31797
32059
|
return result.normalized ?? opts.colorTemp;
|
|
31798
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
|
+
}
|
|
31799
32073
|
function validateParameter(deviceType, command, raw) {
|
|
31800
32074
|
if (!deviceType) return { ok: true };
|
|
31801
|
-
|
|
32075
|
+
const dt = canonicalizeDeviceType(deviceType);
|
|
32076
|
+
if (dt === "Air Conditioner" && command === "setAll") {
|
|
31802
32077
|
return validateAcSetAll(raw);
|
|
31803
32078
|
}
|
|
31804
|
-
if (
|
|
32079
|
+
if (dt.startsWith("Curtain") && command === "setPosition") {
|
|
31805
32080
|
return validateCurtainSetPosition(raw);
|
|
31806
32081
|
}
|
|
31807
|
-
if (
|
|
32082
|
+
if (dt.startsWith("Blind Tilt") && command === "setPosition") {
|
|
31808
32083
|
return validateBlindTiltSetPosition(raw);
|
|
31809
32084
|
}
|
|
31810
|
-
if (
|
|
31811
|
-
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");
|
|
31812
32096
|
}
|
|
31813
|
-
if (command === "setBrightness" && isBrightnessDevice(
|
|
31814
|
-
return validateSetBrightness(raw);
|
|
32097
|
+
if (command === "setBrightness" && isBrightnessDevice(dt)) {
|
|
32098
|
+
return validateSetBrightness(raw, dt);
|
|
31815
32099
|
}
|
|
31816
|
-
if (command === "setColor" && isColorDevice(
|
|
32100
|
+
if (command === "setColor" && isColorDevice(dt)) {
|
|
31817
32101
|
return validateSetColor(raw);
|
|
31818
32102
|
}
|
|
31819
|
-
if (command === "setColorTemperature" &&
|
|
32103
|
+
if (command === "setColorTemperature" && isColorTemperatureDevice(dt)) {
|
|
31820
32104
|
return validateSetColorTemperature(raw);
|
|
31821
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
|
+
}
|
|
31822
32166
|
return { ok: true };
|
|
31823
32167
|
}
|
|
31824
32168
|
function isBrightnessDevice(deviceType) {
|
|
31825
|
-
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;
|
|
31826
32182
|
}
|
|
31827
32183
|
function isColorDevice(deviceType) {
|
|
31828
|
-
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";
|
|
31829
32203
|
}
|
|
31830
32204
|
function isLightingCommandSupported(deviceType, command) {
|
|
31831
|
-
|
|
31832
|
-
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;
|
|
31833
32214
|
return false;
|
|
31834
32215
|
}
|
|
31835
|
-
function validateSetBrightness(raw) {
|
|
32216
|
+
function validateSetBrightness(raw, deviceType) {
|
|
32217
|
+
const [min, max] = brightnessRange(deviceType) ?? [1, 100];
|
|
31836
32218
|
if (raw === void 0 || raw === "" || raw === "default") {
|
|
31837
32219
|
return {
|
|
31838
32220
|
ok: false,
|
|
31839
|
-
error: `setBrightness requires an integer
|
|
32221
|
+
error: `setBrightness requires an integer ${min}-${max} (percent). Example: "50".`
|
|
31840
32222
|
};
|
|
31841
32223
|
}
|
|
31842
|
-
const trimmed = raw.trim();
|
|
32224
|
+
const trimmed = stripQuotes(raw.trim());
|
|
31843
32225
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
31844
32226
|
return {
|
|
31845
32227
|
ok: false,
|
|
31846
|
-
error: `setBrightness must be an integer
|
|
32228
|
+
error: `setBrightness must be an integer ${min}-${max}, got ${JSON.stringify(raw)}. ${hintBrightnessRetry(min, max)}`
|
|
31847
32229
|
};
|
|
31848
32230
|
}
|
|
31849
32231
|
const n = Number(trimmed);
|
|
31850
|
-
if (!Number.isInteger(n) || n <
|
|
32232
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
31851
32233
|
return {
|
|
31852
32234
|
ok: false,
|
|
31853
|
-
error: `setBrightness must be an integer
|
|
32235
|
+
error: `setBrightness must be an integer ${min}-${max}, got "${raw}". ${hintBrightnessRetry(min, max)}`
|
|
31854
32236
|
};
|
|
31855
32237
|
}
|
|
31856
32238
|
return { ok: true, normalized: String(n) };
|
|
31857
32239
|
}
|
|
31858
|
-
function hintBrightnessRetry() {
|
|
31859
|
-
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".`;
|
|
31860
32242
|
}
|
|
31861
|
-
var
|
|
31862
|
-
red: [255, 0, 0],
|
|
31863
|
-
green: [0, 128, 0],
|
|
31864
|
-
lime: [0, 255, 0],
|
|
31865
|
-
blue: [0, 0, 255],
|
|
31866
|
-
yellow: [255, 255, 0],
|
|
31867
|
-
cyan: [0, 255, 255],
|
|
31868
|
-
magenta: [255, 0, 255],
|
|
31869
|
-
white: [255, 255, 255],
|
|
31870
|
-
black: [0, 0, 0],
|
|
31871
|
-
orange: [255, 165, 0],
|
|
31872
|
-
purple: [128, 0, 128],
|
|
31873
|
-
pink: [255, 192, 203],
|
|
31874
|
-
brown: [165, 42, 42],
|
|
31875
|
-
grey: [128, 128, 128],
|
|
31876
|
-
gray: [128, 128, 128],
|
|
32243
|
+
var CUSTOM_COLORS = {
|
|
31877
32244
|
warm: [255, 180, 100]
|
|
31878
32245
|
};
|
|
32246
|
+
var NAMED_COLORS = {
|
|
32247
|
+
...CSS_COLORS,
|
|
32248
|
+
...CUSTOM_COLORS
|
|
32249
|
+
};
|
|
31879
32250
|
function validateSetColor(raw) {
|
|
31880
32251
|
if (raw === void 0 || raw === "" || raw === "default") {
|
|
31881
32252
|
return {
|
|
31882
32253
|
ok: false,
|
|
31883
|
-
error: `setColor requires a color.
|
|
32254
|
+
error: `setColor requires a color. Use a CSS color name (e.g. coral, teal, salmon), hex (#RRGGBB / #RGB), or R:G:B format.`
|
|
31884
32255
|
};
|
|
31885
32256
|
}
|
|
31886
|
-
const trimmed = raw.trim();
|
|
32257
|
+
const trimmed = stripQuotes(raw.trim());
|
|
31887
32258
|
const named = NAMED_COLORS[trimmed.toLowerCase()];
|
|
31888
32259
|
if (named) {
|
|
31889
32260
|
return { ok: true, normalized: `${named[0]}:${named[1]}:${named[2]}` };
|
|
@@ -31950,7 +32321,7 @@ function validateSetColorTemperature(raw) {
|
|
|
31950
32321
|
error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`
|
|
31951
32322
|
};
|
|
31952
32323
|
}
|
|
31953
|
-
const trimmed = raw.trim();
|
|
32324
|
+
const trimmed = stripQuotes(raw.trim());
|
|
31954
32325
|
if (!/^-?\d+$/.test(trimmed)) {
|
|
31955
32326
|
return {
|
|
31956
32327
|
ok: false,
|
|
@@ -31973,13 +32344,14 @@ function validateAcSetAll(raw) {
|
|
|
31973
32344
|
error: `setAll requires a parameter "<temp>,<mode>,<fan>,<on|off>". Example: "26,2,2,on".`
|
|
31974
32345
|
};
|
|
31975
32346
|
}
|
|
31976
|
-
|
|
32347
|
+
const stripped = stripQuotes(raw.trim());
|
|
32348
|
+
if (stripped.startsWith("{") || stripped.startsWith("[")) {
|
|
31977
32349
|
return {
|
|
31978
32350
|
ok: false,
|
|
31979
32351
|
error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`
|
|
31980
32352
|
};
|
|
31981
32353
|
}
|
|
31982
|
-
const parts =
|
|
32354
|
+
const parts = stripped.split(",");
|
|
31983
32355
|
if (parts.length !== 4) {
|
|
31984
32356
|
return {
|
|
31985
32357
|
ok: false,
|
|
@@ -32024,8 +32396,9 @@ function validateCurtainSetPosition(raw) {
|
|
|
32024
32396
|
error: `setPosition requires a parameter. Expected: "<0-100>" or "<index>,<ff|0|1>,<0-100>". Example: "50" or "0,ff,50".`
|
|
32025
32397
|
};
|
|
32026
32398
|
}
|
|
32027
|
-
|
|
32028
|
-
|
|
32399
|
+
const stripped = stripQuotes(raw.trim());
|
|
32400
|
+
if (!stripped.includes(",")) {
|
|
32401
|
+
const pos2 = Number(stripped);
|
|
32029
32402
|
if (!Number.isInteger(pos2) || pos2 < 0 || pos2 > 100) {
|
|
32030
32403
|
return {
|
|
32031
32404
|
ok: false,
|
|
@@ -32034,7 +32407,7 @@ function validateCurtainSetPosition(raw) {
|
|
|
32034
32407
|
}
|
|
32035
32408
|
return { ok: true, normalized: String(pos2) };
|
|
32036
32409
|
}
|
|
32037
|
-
const parts =
|
|
32410
|
+
const parts = stripped.split(",").map((s2) => s2.trim());
|
|
32038
32411
|
if (parts.length !== 3) {
|
|
32039
32412
|
return {
|
|
32040
32413
|
ok: false,
|
|
@@ -32072,7 +32445,8 @@ function validateBlindTiltSetPosition(raw) {
|
|
|
32072
32445
|
error: `Blind Tilt setPosition requires a parameter. Expected: "<up|down>;<0-100>". Example: "up;50".`
|
|
32073
32446
|
};
|
|
32074
32447
|
}
|
|
32075
|
-
const
|
|
32448
|
+
const stripped = stripQuotes(raw.trim());
|
|
32449
|
+
const parts = stripped.split(";");
|
|
32076
32450
|
if (parts.length !== 2) {
|
|
32077
32451
|
return {
|
|
32078
32452
|
ok: false,
|
|
@@ -32093,16 +32467,23 @@ function validateBlindTiltSetPosition(raw) {
|
|
|
32093
32467
|
error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`
|
|
32094
32468
|
};
|
|
32095
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
|
+
}
|
|
32096
32476
|
return { ok: true, normalized: `${dir};${angle}` };
|
|
32097
32477
|
}
|
|
32098
|
-
function
|
|
32478
|
+
function validateRelay2PmSetMode(raw) {
|
|
32099
32479
|
if (raw === void 0 || raw === "" || raw === "default") {
|
|
32100
32480
|
return {
|
|
32101
32481
|
ok: false,
|
|
32102
32482
|
error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`
|
|
32103
32483
|
};
|
|
32104
32484
|
}
|
|
32105
|
-
const
|
|
32485
|
+
const stripped = stripQuotes(raw.trim());
|
|
32486
|
+
const parts = stripped.split(";");
|
|
32106
32487
|
if (parts.length !== 2) {
|
|
32107
32488
|
return {
|
|
32108
32489
|
ok: false,
|
|
@@ -32125,6 +32506,342 @@ function validateRelaySetMode(raw) {
|
|
|
32125
32506
|
}
|
|
32126
32507
|
return { ok: true, normalized: `${ch};${mode}` };
|
|
32127
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
|
+
}
|
|
32128
32845
|
|
|
32129
32846
|
// src/commands/batch.ts
|
|
32130
32847
|
init_cjs_shim();
|
|
@@ -32354,13 +33071,7 @@ Examples:
|
|
|
32354
33071
|
}
|
|
32355
33072
|
});
|
|
32356
33073
|
}
|
|
32357
|
-
|
|
32358
|
-
if (parameter) {
|
|
32359
|
-
try {
|
|
32360
|
-
parsedParam = JSON.parse(parameter);
|
|
32361
|
-
} catch {
|
|
32362
|
-
}
|
|
32363
|
-
}
|
|
33074
|
+
const parsedParam = parseParameterForWire(parameter);
|
|
32364
33075
|
const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency;
|
|
32365
33076
|
const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
|
|
32366
33077
|
const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
|
|
@@ -32395,8 +33106,11 @@ Examples:
|
|
|
32395
33106
|
}
|
|
32396
33107
|
return;
|
|
32397
33108
|
}
|
|
33109
|
+
const totalDevices = resolved.ids.length;
|
|
33110
|
+
const deviceIndices = new Map(resolved.ids.map((id, i) => [id, i + 1]));
|
|
32398
33111
|
const startedAt = Date.now();
|
|
32399
33112
|
const outcomes = await runPool(resolved.ids, concurrency, staggerMs, async (id) => {
|
|
33113
|
+
const stepIdx = deviceIndices.get(id);
|
|
32400
33114
|
const stepStart = Date.now();
|
|
32401
33115
|
const startedIso = new Date(stepStart).toISOString();
|
|
32402
33116
|
try {
|
|
@@ -32408,7 +33122,7 @@ Examples:
|
|
|
32408
33122
|
const durationMs = Date.now() - stepStart;
|
|
32409
33123
|
const replayed = typeof result2 === "object" && result2 !== null && result2.replayed === true;
|
|
32410
33124
|
if (!isJsonMode()) {
|
|
32411
|
-
console.log(
|
|
33125
|
+
console.log(`[${stepIdx}/${totalDevices}] \u2713 ${id}: ${cmd}${replayed ? " (replayed)" : ""}`);
|
|
32412
33126
|
}
|
|
32413
33127
|
return {
|
|
32414
33128
|
ok: true,
|
|
@@ -32431,7 +33145,7 @@ Examples:
|
|
|
32431
33145
|
}
|
|
32432
33146
|
const errorPayload = buildErrorPayload(err);
|
|
32433
33147
|
if (!isJsonMode()) {
|
|
32434
|
-
console.error(
|
|
33148
|
+
console.error(`[${stepIdx}/${totalDevices}] \u2717 ${id}: ${errorPayload.message}`);
|
|
32435
33149
|
}
|
|
32436
33150
|
return {
|
|
32437
33151
|
ok: false,
|
|
@@ -32681,7 +33395,7 @@ function registerWatchCommand(devices) {
|
|
|
32681
33395
|
`Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1e3}s)`,
|
|
32682
33396
|
durationArg("--interval"),
|
|
32683
33397
|
"30s"
|
|
32684
|
-
).option("--max <n>", "Stop after N ticks (default: run until Ctrl-C)", intArg("--max", { min: 1 })).option("--for <dur>", 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg("--for")).option("--include-unchanged", "Emit a tick even when no field changed").option("--initial <mode>", "How to handle the first poll: snapshot | emit | skip (default: snapshot)", enumArg("--initial", INITIAL_MODES), "snapshot").addHelpText(
|
|
33398
|
+
).option("--max <n>", "Stop after N ticks (default: run until Ctrl-C)", intArg("--max", { min: 1 })).option("--once", "Stop after one tick (shorthand for --max 1)").option("--for <dur>", 'Stop after elapsed time (e.g. "5m", "30s"). Combines with --max: first limit wins.', durationArg("--for")).option("--include-unchanged", "Emit a tick even when no field changed").option("--initial <mode>", "How to handle the first poll: snapshot | emit | skip (default: snapshot)", enumArg("--initial", INITIAL_MODES), "snapshot").addHelpText(
|
|
32685
33399
|
"after",
|
|
32686
33400
|
`
|
|
32687
33401
|
Default output is a human-readable table of field changes per tick; add --json
|
|
@@ -32710,6 +33424,12 @@ Examples:
|
|
|
32710
33424
|
).action(
|
|
32711
33425
|
async (deviceIds, options) => {
|
|
32712
33426
|
try {
|
|
33427
|
+
if (options.once && options.max !== void 0) {
|
|
33428
|
+
throw new UsageError("--once and --max are mutually exclusive.");
|
|
33429
|
+
}
|
|
33430
|
+
if (options.once) {
|
|
33431
|
+
options.max = "1";
|
|
33432
|
+
}
|
|
32713
33433
|
const allIds = [...deviceIds];
|
|
32714
33434
|
if (options.name) {
|
|
32715
33435
|
const resolved = resolveDeviceId(void 0, options.name);
|
|
@@ -32965,7 +33685,7 @@ init_catalog();
|
|
|
32965
33685
|
init_flags();
|
|
32966
33686
|
init_client();
|
|
32967
33687
|
function registerExpandCommand(devices) {
|
|
32968
|
-
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", `
|
|
32969
33689
|
Translates semantic flags into the wire parameter format, then sends the command.
|
|
32970
33690
|
|
|
32971
33691
|
Supported expansions:
|
|
@@ -33086,7 +33806,7 @@ Examples:
|
|
|
33086
33806
|
}
|
|
33087
33807
|
}
|
|
33088
33808
|
if (command === "setBrightness") {
|
|
33089
|
-
parameter = buildBrightnessSet(options);
|
|
33809
|
+
parameter = buildBrightnessSet(options, cached2?.type);
|
|
33090
33810
|
} else if (command === "setColor") {
|
|
33091
33811
|
parameter = buildColorSet(options);
|
|
33092
33812
|
} else {
|
|
@@ -33258,13 +33978,16 @@ var EXPAND_HINTS = {
|
|
|
33258
33978
|
"Relay Switch 2PM": { command: "setMode", flags: "--channel 1 --mode edge" }
|
|
33259
33979
|
};
|
|
33260
33980
|
function annotateStatusPayload(deviceId, body) {
|
|
33261
|
-
const
|
|
33981
|
+
const cached2 = getCachedDevice(deviceId);
|
|
33982
|
+
const deviceType = cached2?.type ?? "";
|
|
33983
|
+
const annotated = { deviceId, deviceType, ...body };
|
|
33984
|
+
annotated.deviceId = deviceId;
|
|
33985
|
+
annotated.deviceType = deviceType;
|
|
33262
33986
|
if (Object.keys(body).length === 0) {
|
|
33263
33987
|
annotated.supported = false;
|
|
33264
33988
|
annotated.note = "this device does not expose cloud status";
|
|
33265
33989
|
return annotated;
|
|
33266
33990
|
}
|
|
33267
|
-
const cached2 = getCachedDevice(deviceId);
|
|
33268
33991
|
const looksLikeMeter = cached2?.type?.toLowerCase().includes("meter") ?? false;
|
|
33269
33992
|
const staleZeroReading = looksLikeMeter && !Object.prototype.hasOwnProperty.call(body, "onlineStatus") && body.battery === 0 && body.temperature === 0 && body.humidity === 0;
|
|
33270
33993
|
if (staleZeroReading) {
|
|
@@ -33421,7 +34144,7 @@ Examples:
|
|
|
33421
34144
|
rows.push([
|
|
33422
34145
|
d.deviceId,
|
|
33423
34146
|
d.deviceName,
|
|
33424
|
-
d.deviceType || "
|
|
34147
|
+
d.deviceType || d.controlType || "Unknown Device",
|
|
33425
34148
|
"physical",
|
|
33426
34149
|
d.controlType || "\u2014",
|
|
33427
34150
|
d.familyName || "\u2014",
|
|
@@ -33767,13 +34490,7 @@ ${extra}` : extra;
|
|
|
33767
34490
|
if (options.yes && !destructive && !isDryRun()) {
|
|
33768
34491
|
console.error(`Note: --yes has no effect; "${cmd}" is not a destructive command.`);
|
|
33769
34492
|
}
|
|
33770
|
-
|
|
33771
|
-
if (parameter) {
|
|
33772
|
-
try {
|
|
33773
|
-
parsedParam = JSON.parse(parameter);
|
|
33774
|
-
} catch {
|
|
33775
|
-
}
|
|
33776
|
-
}
|
|
34493
|
+
const parsedParam = parseParameterForWire(parameter);
|
|
33777
34494
|
_cmd = cmd;
|
|
33778
34495
|
_parsedParam = parsedParam;
|
|
33779
34496
|
const body = await executeCommand(
|
|
@@ -33970,12 +34687,19 @@ Examples:
|
|
|
33970
34687
|
});
|
|
33971
34688
|
return;
|
|
33972
34689
|
}
|
|
34690
|
+
if (result.typeSource === "controlType") {
|
|
34691
|
+
const deviceName2 = device.deviceName ?? deviceId;
|
|
34692
|
+
console.error(`warning: ${deviceName2} (${deviceId}): deviceType not reported by API, using controlType "${result.controlType}". Capabilities may be limited.`);
|
|
34693
|
+
} else if (typeName === "Unknown Device") {
|
|
34694
|
+
const deviceName2 = device.deviceName ?? deviceId;
|
|
34695
|
+
console.error(`warning: ${deviceName2} (${deviceId}): neither deviceType nor controlType reported by API. Capabilities may be limited.`);
|
|
34696
|
+
}
|
|
33973
34697
|
if (isPhysical) {
|
|
33974
34698
|
const physical = device;
|
|
33975
34699
|
printKeyValue({
|
|
33976
34700
|
deviceId: physical.deviceId,
|
|
33977
34701
|
deviceName: physical.deviceName,
|
|
33978
|
-
deviceType: physical.deviceType || "
|
|
34702
|
+
deviceType: physical.deviceType || physical.controlType || "Unknown Device",
|
|
33979
34703
|
controlType: physical.controlType || "\u2014",
|
|
33980
34704
|
family: physical.familyName || "\u2014",
|
|
33981
34705
|
roomID: physical.roomID || "\u2014",
|
|
@@ -34154,7 +34878,7 @@ Examples:
|
|
|
34154
34878
|
handleError(error48);
|
|
34155
34879
|
}
|
|
34156
34880
|
});
|
|
34157
|
-
scenes.command("execute").description("Execute a manual scene by its ID").argument("<sceneId>", 'Scene ID from "scenes list"').addHelpText("after", `
|
|
34881
|
+
scenes.command("execute").alias("run").description("Execute a manual scene by its ID").argument("<sceneId>", 'Scene ID from "scenes list"').addHelpText("after", `
|
|
34158
34882
|
Example:
|
|
34159
34883
|
$ switchbot scenes execute T12345678
|
|
34160
34884
|
`).action(async (sceneId) => {
|
|
@@ -49086,6 +49810,47 @@ var EventSubscriptionManager = class {
|
|
|
49086
49810
|
}
|
|
49087
49811
|
};
|
|
49088
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
|
+
|
|
49089
49854
|
// src/devices/history-query.ts
|
|
49090
49855
|
init_cjs_shim();
|
|
49091
49856
|
import fs10 from "node:fs";
|
|
@@ -49887,15 +50652,27 @@ against the live API without executing any mutations.
|
|
|
49887
50652
|
handleError(err);
|
|
49888
50653
|
}
|
|
49889
50654
|
});
|
|
49890
|
-
plan.command("run").description("Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default").argument("[file]", 'Path to plan.json, or "-" / omit to read stdin').option("--yes", "Authorize destructive commands (e.g. Smart Lock unlock, Garage open)").option("--require-approval", "Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)").option("--continue-on-error", "Keep running after a failed step (default: stop at first error)").action(
|
|
50655
|
+
plan.command("run").description("Validate + preview/execute a plan. Respects --dry-run; destructive steps require the reviewed plan flow by default").argument("[file]", 'Path to plan.json, or "-" / omit to read stdin').option("--yes", "Authorize destructive commands (e.g. Smart Lock unlock, Garage open)").option("--require-approval", "Prompt for confirmation before each destructive step (TTY only; mutually exclusive with --json)").option("--continue-on-error", "Keep running after a failed step (default: stop at first error)").option("--plan <json>", "Inline plan JSON (alternative to file argument or stdin)").action(
|
|
49891
50656
|
async (file2, options) => {
|
|
49892
50657
|
if (options.requireApproval && isJsonMode()) {
|
|
49893
50658
|
console.error("error: --require-approval cannot be used with --json (no TTY available for prompts)");
|
|
49894
50659
|
process.exit(1);
|
|
49895
50660
|
}
|
|
50661
|
+
if (options.plan !== void 0 && file2 !== void 0) {
|
|
50662
|
+
console.error("error: --plan and a file argument are mutually exclusive.");
|
|
50663
|
+
process.exit(2);
|
|
50664
|
+
}
|
|
49896
50665
|
let raw;
|
|
49897
50666
|
try {
|
|
49898
|
-
|
|
50667
|
+
if (options.plan !== void 0) {
|
|
50668
|
+
try {
|
|
50669
|
+
raw = JSON.parse(options.plan);
|
|
50670
|
+
} catch (err) {
|
|
50671
|
+
throw new UsageError(`--plan is not valid JSON: ${err.message}`);
|
|
50672
|
+
}
|
|
50673
|
+
} else {
|
|
50674
|
+
raw = await readPlanSource(file2);
|
|
50675
|
+
}
|
|
49899
50676
|
} catch (err) {
|
|
49900
50677
|
handleError(err);
|
|
49901
50678
|
}
|
|
@@ -50944,6 +51721,8 @@ function buildRiskProfile(typeName, command, commandType, isDestructive) {
|
|
|
50944
51721
|
}
|
|
50945
51722
|
function createSwitchBotMcpServer(options) {
|
|
50946
51723
|
const eventManager = options?.eventManager;
|
|
51724
|
+
const allowedTools = TOOL_PROFILES[options?.toolProfile ?? "default"];
|
|
51725
|
+
const profileName = options?.toolProfile ?? "default";
|
|
50947
51726
|
const server = new McpServer(
|
|
50948
51727
|
{
|
|
50949
51728
|
name: "switchbot",
|
|
@@ -50967,1075 +51746,1093 @@ Recommended bootstrap sequence:
|
|
|
50967
51746
|
2. search_catalog or describe_device \u2192 confirm supported commands offline/online
|
|
50968
51747
|
3. send_command (with confirm:true for destructive commands)
|
|
50969
51748
|
|
|
50970
|
-
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)." : ""}`
|
|
50971
51752
|
}
|
|
50972
51753
|
);
|
|
50973
|
-
|
|
50974
|
-
|
|
50975
|
-
|
|
50976
|
-
|
|
50977
|
-
|
|
50978
|
-
|
|
50979
|
-
|
|
50980
|
-
|
|
50981
|
-
|
|
50982
|
-
|
|
50983
|
-
|
|
50984
|
-
|
|
50985
|
-
|
|
50986
|
-
|
|
50987
|
-
|
|
50988
|
-
|
|
50989
|
-
|
|
50990
|
-
|
|
50991
|
-
|
|
50992
|
-
|
|
50993
|
-
|
|
50994
|
-
|
|
50995
|
-
|
|
50996
|
-
|
|
50997
|
-
|
|
50998
|
-
|
|
50999
|
-
|
|
51000
|
-
|
|
51001
|
-
async () => {
|
|
51002
|
-
const body = await fetchDeviceList();
|
|
51003
|
-
return {
|
|
51004
|
-
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
51005
|
-
structuredContent: {
|
|
51006
|
-
deviceList: body.deviceList.map(toMcpDeviceListShape),
|
|
51007
|
-
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")
|
|
51008
51782
|
}
|
|
51009
|
-
}
|
|
51010
|
-
|
|
51011
|
-
|
|
51012
|
-
server.registerTool(
|
|
51013
|
-
"get_device_status",
|
|
51014
|
-
{
|
|
51015
|
-
title: "Get live status for a device",
|
|
51016
|
-
description: "Query the real-time status payload for a physical device. IR remotes have no status channel and will error.",
|
|
51017
|
-
_meta: { agentSafetyTier: "read" },
|
|
51018
|
-
inputSchema: external_exports.object({
|
|
51019
|
-
deviceId: external_exports.string().describe("Device ID from list_devices")
|
|
51020
|
-
}).strict(),
|
|
51021
|
-
outputSchema: {
|
|
51022
|
-
status: external_exports.object({
|
|
51023
|
-
deviceId: external_exports.string().optional(),
|
|
51024
|
-
deviceType: external_exports.string().optional(),
|
|
51025
|
-
hubDeviceId: external_exports.string().optional(),
|
|
51026
|
-
connectionStatus: external_exports.string().optional()
|
|
51027
|
-
}).passthrough().describe("Live device status (deviceId + deviceType + device-specific fields)")
|
|
51028
|
-
}
|
|
51029
|
-
},
|
|
51030
|
-
async ({ deviceId }) => {
|
|
51031
|
-
const body = await fetchDeviceStatus(deviceId);
|
|
51032
|
-
return {
|
|
51033
|
-
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
51034
|
-
structuredContent: { status: body }
|
|
51035
|
-
};
|
|
51036
|
-
}
|
|
51037
|
-
);
|
|
51038
|
-
server.registerTool(
|
|
51039
|
-
"get_device_history",
|
|
51040
|
-
{
|
|
51041
|
-
title: "Get locally-persisted device state history",
|
|
51042
|
-
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.",
|
|
51043
|
-
_meta: { agentSafetyTier: "read" },
|
|
51044
|
-
inputSchema: external_exports.object({
|
|
51045
|
-
deviceId: external_exports.string().optional().describe("Device MAC address (deviceId). Omit to list all devices with history."),
|
|
51046
|
-
limit: external_exports.number().int().min(1).max(100).optional().describe("Max history entries to return (default 20, max 100)")
|
|
51047
|
-
}).strict(),
|
|
51048
|
-
outputSchema: {
|
|
51049
|
-
deviceId: external_exports.string().optional(),
|
|
51050
|
-
latest: external_exports.unknown().optional(),
|
|
51051
|
-
history: external_exports.array(external_exports.unknown()).optional(),
|
|
51052
|
-
devices: external_exports.array(external_exports.object({ deviceId: external_exports.string(), latest: external_exports.unknown() })).optional()
|
|
51053
|
-
}
|
|
51054
|
-
},
|
|
51055
|
-
async ({ deviceId, limit }) => {
|
|
51056
|
-
if (deviceId) {
|
|
51057
|
-
const latest = deviceHistoryStore.getLatest(deviceId);
|
|
51058
|
-
const history = deviceHistoryStore.getHistory(deviceId, limit ?? 20);
|
|
51059
|
-
const result2 = { deviceId, latest, history };
|
|
51783
|
+
},
|
|
51784
|
+
async () => {
|
|
51785
|
+
const body = await fetchDeviceList();
|
|
51060
51786
|
return {
|
|
51061
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51062
|
-
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
|
+
}
|
|
51063
51792
|
};
|
|
51064
51793
|
}
|
|
51065
|
-
|
|
51066
|
-
|
|
51067
|
-
|
|
51068
|
-
|
|
51069
|
-
|
|
51070
|
-
|
|
51071
|
-
|
|
51072
|
-
|
|
51073
|
-
|
|
51074
|
-
|
|
51075
|
-
|
|
51076
|
-
|
|
51077
|
-
|
|
51078
|
-
|
|
51079
|
-
|
|
51080
|
-
|
|
51081
|
-
|
|
51082
|
-
|
|
51083
|
-
|
|
51084
|
-
|
|
51085
|
-
|
|
51086
|
-
|
|
51087
|
-
|
|
51088
|
-
|
|
51089
|
-
|
|
51090
|
-
|
|
51091
|
-
records: external_exports.array(external_exports.object({
|
|
51092
|
-
t: external_exports.string(),
|
|
51093
|
-
topic: external_exports.string(),
|
|
51094
|
-
deviceType: external_exports.string().optional(),
|
|
51095
|
-
payload: external_exports.unknown()
|
|
51096
|
-
}))
|
|
51097
|
-
}
|
|
51098
|
-
},
|
|
51099
|
-
async ({ deviceId, since, from, to, fields, limit }) => {
|
|
51100
|
-
if (since && (from || to)) {
|
|
51101
|
-
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
|
+
};
|
|
51102
51820
|
}
|
|
51103
|
-
|
|
51104
|
-
|
|
51105
|
-
|
|
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 };
|
|
51106
51853
|
return {
|
|
51107
51854
|
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
51108
51855
|
structuredContent: result
|
|
51109
51856
|
};
|
|
51110
|
-
} catch (err) {
|
|
51111
|
-
const msg = err instanceof Error ? err.message : "history query failed";
|
|
51112
|
-
return mcpError("usage", 2, msg);
|
|
51113
51857
|
}
|
|
51114
|
-
|
|
51115
|
-
)
|
|
51116
|
-
|
|
51117
|
-
|
|
51118
|
-
|
|
51119
|
-
|
|
51120
|
-
|
|
51121
|
-
|
|
51122
|
-
|
|
51123
|
-
|
|
51124
|
-
|
|
51125
|
-
|
|
51126
|
-
|
|
51127
|
-
|
|
51128
|
-
|
|
51129
|
-
|
|
51130
|
-
|
|
51131
|
-
dryRun: external_exports.boolean().optional().describe("When true, do not call the API \u2014 return { ok:true, dryRun:true, wouldSend:{...} } instead.")
|
|
51132
|
-
}).strict(),
|
|
51133
|
-
outputSchema: {
|
|
51134
|
-
ok: external_exports.literal(true),
|
|
51135
|
-
command: external_exports.string().optional(),
|
|
51136
|
-
deviceId: external_exports.string().optional(),
|
|
51137
|
-
result: external_exports.unknown().optional().describe("API response body from SwitchBot (absent on dryRun)"),
|
|
51138
|
-
riskProfile: external_exports.object({
|
|
51139
|
-
riskLevel: external_exports.enum(["high", "medium", "low"]),
|
|
51140
|
-
requiresConfirmation: external_exports.boolean(),
|
|
51141
|
-
supportsDryRun: external_exports.literal(true),
|
|
51142
|
-
idempotencyHint: external_exports.enum(["safe", "non-idempotent"]),
|
|
51143
|
-
recommendedMode: external_exports.enum(["review-before-execute", "plan", "direct"])
|
|
51144
|
-
}).optional().describe(
|
|
51145
|
-
'Device+command-specific risk metadata. riskLevel:"high" means confirm:true was required. Always present on dryRun responses so agents can preview risk before committing.'
|
|
51146
|
-
),
|
|
51147
|
-
verification: external_exports.object({
|
|
51148
|
-
verifiable: external_exports.boolean(),
|
|
51149
|
-
reason: external_exports.string(),
|
|
51150
|
-
suggestedFollowup: external_exports.string()
|
|
51151
|
-
}).optional().describe(
|
|
51152
|
-
'Present when the target is an IR device. IR is unidirectional \u2014 agents should treat the success as "signal sent" not "state changed".'
|
|
51153
|
-
),
|
|
51154
|
-
dryRun: external_exports.literal(true).optional().describe("Present when dryRun:true was requested"),
|
|
51155
|
-
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: {
|
|
51156
51875
|
deviceId: external_exports.string(),
|
|
51157
|
-
|
|
51158
|
-
|
|
51159
|
-
|
|
51160
|
-
|
|
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
|
+
}
|
|
51161
51900
|
}
|
|
51162
|
-
|
|
51163
|
-
|
|
51164
|
-
|
|
51165
|
-
|
|
51166
|
-
|
|
51167
|
-
|
|
51168
|
-
|
|
51169
|
-
|
|
51170
|
-
|
|
51171
|
-
|
|
51172
|
-
|
|
51173
|
-
|
|
51174
|
-
|
|
51175
|
-
|
|
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)")
|
|
51176
51948
|
}
|
|
51177
|
-
|
|
51178
|
-
|
|
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);
|
|
51179
52024
|
return mcpError(
|
|
51180
|
-
"
|
|
51181
|
-
|
|
51182
|
-
|
|
52025
|
+
"guard",
|
|
52026
|
+
3,
|
|
52027
|
+
`Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
|
|
51183
52028
|
{
|
|
51184
|
-
hint:
|
|
52029
|
+
hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
|
|
51185
52030
|
context: {
|
|
51186
|
-
|
|
51187
|
-
deviceType:
|
|
51188
|
-
|
|
52031
|
+
command: effectiveCommand,
|
|
52032
|
+
deviceType: typeName,
|
|
52033
|
+
directExecutionAllowed: false,
|
|
52034
|
+
requiredWorkflow: "plan-approval",
|
|
52035
|
+
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
51189
52036
|
}
|
|
51190
52037
|
}
|
|
51191
52038
|
);
|
|
51192
52039
|
}
|
|
51193
|
-
if (
|
|
51194
|
-
|
|
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;
|
|
51195
52074
|
}
|
|
51196
52075
|
if (effectiveType !== "customize") {
|
|
51197
|
-
const pv = validateParameter(
|
|
52076
|
+
const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
|
|
51198
52077
|
if (!pv.ok) {
|
|
51199
52078
|
return mcpError("usage", 2, pv.error, {
|
|
51200
|
-
|
|
51201
|
-
context: { deviceType: cached2.type, command: effectiveCommand, parameter: stringifiedParam }
|
|
52079
|
+
context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
|
|
51202
52080
|
});
|
|
51203
52081
|
}
|
|
51204
52082
|
if (pv.normalized !== void 0) {
|
|
51205
|
-
effectiveParameter = pv.normalized;
|
|
52083
|
+
effectiveParameter = parseParameterForWire(pv.normalized);
|
|
51206
52084
|
}
|
|
51207
52085
|
}
|
|
51208
|
-
|
|
51209
|
-
|
|
51210
|
-
|
|
51211
|
-
|
|
51212
|
-
|
|
51213
|
-
}
|
|
51214
|
-
|
|
51215
|
-
|
|
51216
|
-
|
|
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
|
+
}
|
|
51217
52114
|
return {
|
|
51218
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51219
|
-
structuredContent:
|
|
52115
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52116
|
+
structuredContent: structured
|
|
51220
52117
|
};
|
|
51221
52118
|
}
|
|
51222
|
-
|
|
51223
|
-
|
|
51224
|
-
|
|
51225
|
-
|
|
51226
|
-
|
|
51227
|
-
|
|
51228
|
-
|
|
51229
|
-
|
|
51230
|
-
|
|
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)")
|
|
51231
52138
|
}
|
|
51232
|
-
|
|
51233
|
-
}
|
|
51234
|
-
|
|
51235
|
-
|
|
51236
|
-
|
|
51237
|
-
|
|
51238
|
-
|
|
51239
|
-
3,
|
|
51240
|
-
`Direct destructive execution is disabled for command "${effectiveCommand}" on device type "${typeName}".`,
|
|
51241
|
-
{
|
|
51242
|
-
hint: reason ? `${destructiveExecutionHint()} Reason: ${reason}` : destructiveExecutionHint(),
|
|
51243
|
-
context: {
|
|
51244
|
-
command: effectiveCommand,
|
|
51245
|
-
deviceType: typeName,
|
|
51246
|
-
directExecutionAllowed: false,
|
|
51247
|
-
requiredWorkflow: "plan-approval",
|
|
51248
|
-
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
51249
|
-
}
|
|
51250
|
-
}
|
|
51251
|
-
);
|
|
51252
|
-
}
|
|
51253
|
-
if (destructive && !confirm) {
|
|
51254
|
-
const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
|
|
51255
|
-
const entry = typeName ? findCatalogEntry(typeName) : null;
|
|
51256
|
-
const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === effectiveCommand) : void 0;
|
|
51257
|
-
const hint = reason ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` : "Re-issue the call with confirm:true to proceed.";
|
|
51258
|
-
return mcpError(
|
|
51259
|
-
"guard",
|
|
51260
|
-
3,
|
|
51261
|
-
`Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`,
|
|
51262
|
-
{
|
|
51263
|
-
hint,
|
|
51264
|
-
context: {
|
|
51265
|
-
command: effectiveCommand,
|
|
51266
|
-
deviceType: typeName,
|
|
51267
|
-
description: spec?.description ?? null,
|
|
51268
|
-
...reason ? { safetyReason: reason, destructiveReason: reason } : {}
|
|
51269
|
-
}
|
|
52139
|
+
},
|
|
52140
|
+
async ({ sceneId, dryRun }) => {
|
|
52141
|
+
if (dryRun) {
|
|
52142
|
+
let scenes = [];
|
|
52143
|
+
try {
|
|
52144
|
+
scenes = await fetchScenes();
|
|
52145
|
+
} catch {
|
|
51270
52146
|
}
|
|
51271
|
-
|
|
51272
|
-
|
|
51273
|
-
|
|
51274
|
-
|
|
51275
|
-
|
|
51276
|
-
|
|
51277
|
-
|
|
51278
|
-
validation.error.message,
|
|
51279
|
-
{
|
|
51280
|
-
hint: validation.error.hint,
|
|
51281
|
-
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
|
+
});
|
|
51282
52154
|
}
|
|
51283
|
-
|
|
51284
|
-
|
|
51285
|
-
|
|
51286
|
-
|
|
51287
|
-
|
|
51288
|
-
|
|
51289
|
-
const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
|
|
51290
|
-
if (!pv.ok) {
|
|
51291
|
-
return mcpError("usage", 2, pv.error, {
|
|
51292
|
-
context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: "param-out-of-range" }
|
|
51293
|
-
});
|
|
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
|
+
};
|
|
51294
52161
|
}
|
|
51295
|
-
|
|
51296
|
-
|
|
52162
|
+
try {
|
|
52163
|
+
await executeScene(sceneId);
|
|
52164
|
+
} catch (err) {
|
|
52165
|
+
return apiErrorToMcpError(err);
|
|
51297
52166
|
}
|
|
52167
|
+
const structured = { ok: true, sceneId };
|
|
52168
|
+
return {
|
|
52169
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52170
|
+
structuredContent: structured
|
|
52171
|
+
};
|
|
51298
52172
|
}
|
|
51299
|
-
|
|
51300
|
-
|
|
51301
|
-
|
|
51302
|
-
|
|
51303
|
-
|
|
51304
|
-
|
|
51305
|
-
|
|
51306
|
-
|
|
51307
|
-
|
|
51308
|
-
|
|
51309
|
-
|
|
51310
|
-
newShape: err.newShape
|
|
51311
|
-
}
|
|
51312
|
-
});
|
|
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() }))
|
|
51313
52184
|
}
|
|
51314
|
-
|
|
51315
|
-
|
|
51316
|
-
|
|
51317
|
-
|
|
51318
|
-
|
|
51319
|
-
|
|
51320
|
-
if (isIr) {
|
|
51321
|
-
structured.verification = {
|
|
51322
|
-
verifiable: false,
|
|
51323
|
-
reason: "IR transmission is unidirectional; no receipt acknowledgment is possible.",
|
|
51324
|
-
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 }
|
|
51325
52191
|
};
|
|
51326
52192
|
}
|
|
51327
|
-
|
|
51328
|
-
|
|
51329
|
-
|
|
51330
|
-
|
|
51331
|
-
|
|
51332
|
-
|
|
51333
|
-
|
|
51334
|
-
|
|
51335
|
-
|
|
51336
|
-
|
|
51337
|
-
|
|
51338
|
-
|
|
51339
|
-
|
|
51340
|
-
|
|
51341
|
-
|
|
51342
|
-
|
|
51343
|
-
|
|
51344
|
-
|
|
51345
|
-
|
|
51346
|
-
|
|
51347
|
-
|
|
51348
|
-
|
|
51349
|
-
|
|
51350
|
-
|
|
51351
|
-
|
|
51352
|
-
|
|
51353
|
-
|
|
51354
|
-
|
|
51355
|
-
|
|
51356
|
-
|
|
51357
|
-
|
|
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")
|
|
51358
52224
|
}
|
|
51359
|
-
|
|
51360
|
-
|
|
51361
|
-
|
|
51362
|
-
|
|
51363
|
-
|
|
51364
|
-
|
|
51365
|
-
|
|
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
|
+
);
|
|
51366
52236
|
}
|
|
51367
|
-
const
|
|
51368
|
-
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 };
|
|
51369
52251
|
return {
|
|
51370
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51371
|
-
structuredContent:
|
|
52252
|
+
content: [{ type: "text", text: JSON.stringify(normalised, null, 2) }],
|
|
52253
|
+
structuredContent: structured
|
|
51372
52254
|
};
|
|
51373
52255
|
}
|
|
51374
|
-
|
|
51375
|
-
|
|
51376
|
-
|
|
51377
|
-
|
|
51378
|
-
|
|
51379
|
-
|
|
51380
|
-
|
|
51381
|
-
|
|
51382
|
-
|
|
51383
|
-
|
|
51384
|
-
|
|
51385
|
-
|
|
51386
|
-
|
|
51387
|
-
|
|
51388
|
-
|
|
51389
|
-
|
|
51390
|
-
|
|
51391
|
-
|
|
51392
|
-
|
|
51393
|
-
|
|
51394
|
-
|
|
51395
|
-
|
|
51396
|
-
|
|
51397
|
-
|
|
51398
|
-
|
|
51399
|
-
|
|
51400
|
-
|
|
51401
|
-
|
|
51402
|
-
|
|
51403
|
-
|
|
51404
|
-
|
|
51405
|
-
|
|
51406
|
-
|
|
51407
|
-
|
|
51408
|
-
|
|
51409
|
-
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.",
|
|
51410
|
-
_meta: { agentSafetyTier: "read" },
|
|
51411
|
-
inputSchema: external_exports.object({
|
|
51412
|
-
query: external_exports.string().describe("Search query (matches type and aliases, case-insensitive). Must be non-empty; use list_catalog_types to enumerate instead."),
|
|
51413
|
-
limit: external_exports.number().int().min(1).max(100).optional().default(20).describe("Max entries returned (default 20)")
|
|
51414
|
-
}).strict(),
|
|
51415
|
-
outputSchema: {
|
|
51416
|
-
results: external_exports.array(external_exports.object({
|
|
51417
|
-
type: external_exports.string(),
|
|
51418
|
-
category: external_exports.enum(["physical", "ir"]),
|
|
51419
|
-
commands: external_exports.array(external_exports.object({
|
|
51420
|
-
command: external_exports.string(),
|
|
51421
|
-
parameter: external_exports.string(),
|
|
51422
|
-
description: external_exports.string(),
|
|
51423
|
-
commandType: external_exports.enum(["command", "customize"]).optional(),
|
|
51424
|
-
idempotent: external_exports.boolean().optional(),
|
|
51425
|
-
safetyTier: external_exports.enum(["read", "mutation", "ir-fire-forget", "destructive", "maintenance"]).optional(),
|
|
51426
|
-
safetyReason: external_exports.string().optional()
|
|
51427
|
-
}).passthrough()),
|
|
51428
|
-
aliases: external_exports.array(external_exports.string()).optional(),
|
|
51429
|
-
statusFields: external_exports.array(external_exports.string()).optional(),
|
|
51430
|
-
role: external_exports.string().optional(),
|
|
51431
|
-
readOnly: external_exports.boolean().optional()
|
|
51432
|
-
}).passthrough()).describe("Matching catalog entries"),
|
|
51433
|
-
total: external_exports.number().int().describe("Number of entries returned")
|
|
51434
|
-
}
|
|
51435
|
-
},
|
|
51436
|
-
async ({ query, limit }) => {
|
|
51437
|
-
if (query.trim() === "") {
|
|
51438
|
-
return mcpError(
|
|
51439
|
-
"usage",
|
|
51440
|
-
2,
|
|
51441
|
-
"search_catalog requires a non-empty query.",
|
|
51442
|
-
{
|
|
51443
|
-
hint: "Pass a search term like 'Bot' or 'Hub', or call list_catalog_types to enumerate all types without a query."
|
|
51444
|
-
}
|
|
51445
|
-
);
|
|
51446
|
-
}
|
|
51447
|
-
const hits = searchCatalog(query, limit);
|
|
51448
|
-
const normalised = hits.map((e) => ({
|
|
51449
|
-
...e,
|
|
51450
|
-
commands: e.commands.map((c) => {
|
|
51451
|
-
const tier = deriveSafetyTier(c, e);
|
|
51452
|
-
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 });
|
|
51453
52291
|
return {
|
|
51454
|
-
|
|
51455
|
-
|
|
51456
|
-
...reason ? { safetyReason: reason } : {}
|
|
52292
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
52293
|
+
structuredContent: { device: toMcpDescribeShape(result) }
|
|
51457
52294
|
};
|
|
51458
|
-
})
|
|
51459
|
-
|
|
51460
|
-
|
|
51461
|
-
|
|
51462
|
-
|
|
51463
|
-
|
|
51464
|
-
|
|
51465
|
-
|
|
51466
|
-
|
|
51467
|
-
server.registerTool(
|
|
51468
|
-
"describe_device",
|
|
51469
|
-
{
|
|
51470
|
-
title: "Describe a specific device",
|
|
51471
|
-
description: "Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.",
|
|
51472
|
-
_meta: { agentSafetyTier: "read" },
|
|
51473
|
-
inputSchema: external_exports.object({
|
|
51474
|
-
deviceId: external_exports.string().describe("Device ID from list_devices"),
|
|
51475
|
-
live: external_exports.boolean().optional().default(false).describe("Also fetch live /status values (costs 1 extra API call)")
|
|
51476
|
-
}).strict(),
|
|
51477
|
-
outputSchema: {
|
|
51478
|
-
device: external_exports.object({
|
|
51479
|
-
device: external_exports.object({ deviceId: external_exports.string(), deviceName: external_exports.string() }).passthrough(),
|
|
51480
|
-
isPhysical: external_exports.boolean(),
|
|
51481
|
-
typeName: external_exports.string(),
|
|
51482
|
-
controlType: external_exports.string().nullable(),
|
|
51483
|
-
source: external_exports.enum(["catalog", "live", "catalog+live", "none"]),
|
|
51484
|
-
capabilities: external_exports.unknown().nullable(),
|
|
51485
|
-
suggestedActions: external_exports.array(external_exports.object({
|
|
51486
|
-
command: external_exports.string(),
|
|
51487
|
-
parameter: external_exports.string().optional(),
|
|
51488
|
-
description: external_exports.string()
|
|
51489
|
-
})).optional(),
|
|
51490
|
-
inheritedLocation: external_exports.object({
|
|
51491
|
-
family: external_exports.string().optional(),
|
|
51492
|
-
room: external_exports.string().optional()
|
|
51493
|
-
}).optional()
|
|
51494
|
-
}).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
|
+
}
|
|
51495
52304
|
}
|
|
51496
|
-
|
|
51497
|
-
|
|
51498
|
-
|
|
51499
|
-
|
|
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;
|
|
51500
52373
|
return {
|
|
51501
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51502
|
-
structuredContent:
|
|
52374
|
+
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
52375
|
+
structuredContent: structured
|
|
51503
52376
|
};
|
|
51504
|
-
} catch (err) {
|
|
51505
|
-
if (err instanceof DeviceNotFoundError) {
|
|
51506
|
-
return mcpError("runtime", 152, err.message, {
|
|
51507
|
-
hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).",
|
|
51508
|
-
context: { deviceId }
|
|
51509
|
-
});
|
|
51510
|
-
}
|
|
51511
|
-
return apiErrorToMcpError(err);
|
|
51512
52377
|
}
|
|
51513
|
-
|
|
51514
|
-
)
|
|
51515
|
-
|
|
51516
|
-
|
|
51517
|
-
|
|
51518
|
-
|
|
51519
|
-
|
|
51520
|
-
|
|
51521
|
-
|
|
51522
|
-
|
|
51523
|
-
|
|
51524
|
-
|
|
51525
|
-
|
|
51526
|
-
|
|
51527
|
-
|
|
51528
|
-
|
|
51529
|
-
|
|
51530
|
-
|
|
51531
|
-
|
|
51532
|
-
|
|
51533
|
-
|
|
51534
|
-
|
|
51535
|
-
|
|
51536
|
-
|
|
51537
|
-
|
|
51538
|
-
|
|
51539
|
-
external_exports.object({
|
|
51540
|
-
|
|
51541
|
-
|
|
51542
|
-
|
|
51543
|
-
|
|
51544
|
-
|
|
51545
|
-
|
|
51546
|
-
|
|
51547
|
-
|
|
51548
|
-
|
|
51549
|
-
|
|
51550
|
-
|
|
51551
|
-
|
|
51552
|
-
)
|
|
51553
|
-
|
|
51554
|
-
|
|
51555
|
-
|
|
51556
|
-
|
|
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
|
+
};
|
|
51557
52470
|
}
|
|
51558
|
-
|
|
51559
|
-
|
|
51560
|
-
|
|
51561
|
-
|
|
51562
|
-
|
|
51563
|
-
|
|
51564
|
-
|
|
51565
|
-
|
|
51566
|
-
|
|
51567
|
-
|
|
51568
|
-
|
|
51569
|
-
|
|
51570
|
-
|
|
51571
|
-
|
|
51572
|
-
|
|
51573
|
-
|
|
51574
|
-
|
|
51575
|
-
|
|
51576
|
-
|
|
51577
|
-
|
|
51578
|
-
notes: res.notes
|
|
51579
|
-
};
|
|
51580
|
-
if (res.bucket !== void 0) structured.bucket = res.bucket;
|
|
51581
|
-
return {
|
|
51582
|
-
content: [{ type: "text", text: JSON.stringify(res, null, 2) }],
|
|
51583
|
-
structuredContent: structured
|
|
51584
|
-
};
|
|
51585
|
-
}
|
|
51586
|
-
);
|
|
51587
|
-
server.registerTool(
|
|
51588
|
-
"account_overview",
|
|
51589
|
-
{
|
|
51590
|
-
title: "Bootstrap account overview",
|
|
51591
|
-
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.",
|
|
51592
|
-
_meta: { agentSafetyTier: "read" },
|
|
51593
|
-
inputSchema: external_exports.object({}).strict(),
|
|
51594
|
-
outputSchema: {
|
|
51595
|
-
version: external_exports.string(),
|
|
51596
|
-
schemaVersion: external_exports.string(),
|
|
51597
|
-
devices: external_exports.array(external_exports.object({
|
|
51598
|
-
deviceId: external_exports.string(),
|
|
51599
|
-
deviceName: external_exports.string(),
|
|
51600
|
-
deviceType: external_exports.string().optional()
|
|
51601
|
-
}).passthrough()).describe("All physical devices"),
|
|
51602
|
-
infraredRemotes: external_exports.array(external_exports.object({
|
|
51603
|
-
deviceId: external_exports.string(),
|
|
51604
|
-
deviceName: external_exports.string(),
|
|
51605
|
-
remoteType: external_exports.string()
|
|
51606
|
-
}).passthrough()).describe("All IR remotes"),
|
|
51607
|
-
scenes: external_exports.array(external_exports.object({
|
|
51608
|
-
sceneId: external_exports.string(),
|
|
51609
|
-
sceneName: external_exports.string()
|
|
51610
|
-
}).passthrough()).describe("All manual scenes"),
|
|
51611
|
-
quota: external_exports.object({
|
|
51612
|
-
date: external_exports.string(),
|
|
51613
|
-
total: external_exports.number(),
|
|
51614
|
-
remaining: external_exports.number(),
|
|
51615
|
-
endpoints: external_exports.record(external_exports.string(), external_exports.number()).optional()
|
|
51616
|
-
}).describe("Today's quota usage"),
|
|
51617
|
-
cache: external_exports.object({
|
|
51618
|
-
list: external_exports.object({
|
|
51619
|
-
path: external_exports.string(),
|
|
51620
|
-
exists: external_exports.boolean(),
|
|
51621
|
-
lastUpdated: external_exports.string().optional(),
|
|
51622
|
-
ageMs: external_exports.number().optional(),
|
|
51623
|
-
deviceCount: external_exports.number().optional()
|
|
51624
|
-
}),
|
|
51625
|
-
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({
|
|
51626
52491
|
path: external_exports.string(),
|
|
51627
|
-
|
|
51628
|
-
|
|
51629
|
-
|
|
51630
|
-
|
|
51631
|
-
|
|
51632
|
-
|
|
51633
|
-
|
|
51634
|
-
|
|
51635
|
-
|
|
51636
|
-
|
|
51637
|
-
|
|
51638
|
-
|
|
51639
|
-
|
|
51640
|
-
|
|
51641
|
-
|
|
51642
|
-
|
|
51643
|
-
|
|
51644
|
-
|
|
51645
|
-
|
|
51646
|
-
|
|
51647
|
-
|
|
51648
|
-
|
|
51649
|
-
scenes: sceneList.map((s2) => ({
|
|
51650
|
-
sceneId: s2.sceneId,
|
|
51651
|
-
sceneName: s2.sceneName
|
|
51652
|
-
})),
|
|
51653
|
-
quota: {
|
|
51654
|
-
date: quota.date,
|
|
51655
|
-
total: quota.total,
|
|
51656
|
-
remaining: quota.remaining,
|
|
51657
|
-
endpoints: quota.endpoints
|
|
51658
|
-
},
|
|
51659
|
-
cache: {
|
|
51660
|
-
list: cacheInfo.list,
|
|
51661
|
-
status: cacheInfo.status
|
|
51662
|
-
},
|
|
51663
|
-
...eventManager ? {
|
|
51664
|
-
mqtt: {
|
|
51665
|
-
state: eventManager.getState(),
|
|
51666
|
-
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);
|
|
51667
52514
|
}
|
|
51668
|
-
|
|
51669
|
-
|
|
51670
|
-
|
|
51671
|
-
|
|
51672
|
-
|
|
51673
|
-
|
|
51674
|
-
|
|
51675
|
-
|
|
51676
|
-
|
|
51677
|
-
|
|
51678
|
-
|
|
51679
|
-
|
|
51680
|
-
|
|
51681
|
-
|
|
51682
|
-
|
|
51683
|
-
|
|
51684
|
-
|
|
51685
|
-
|
|
51686
|
-
|
|
51687
|
-
|
|
51688
|
-
|
|
51689
|
-
|
|
51690
|
-
|
|
51691
|
-
|
|
51692
|
-
|
|
51693
|
-
|
|
51694
|
-
|
|
51695
|
-
|
|
51696
|
-
|
|
51697
|
-
|
|
51698
|
-
|
|
51699
|
-
|
|
51700
|
-
|
|
51701
|
-
|
|
51702
|
-
|
|
51703
|
-
|
|
51704
|
-
|
|
51705
|
-
|
|
51706
|
-
|
|
51707
|
-
|
|
51708
|
-
|
|
51709
|
-
|
|
51710
|
-
|
|
51711
|
-
|
|
51712
|
-
|
|
51713
|
-
|
|
51714
|
-
|
|
51715
|
-
|
|
51716
|
-
|
|
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
|
+
};
|
|
51717
52571
|
}
|
|
51718
|
-
|
|
51719
|
-
|
|
52572
|
+
throw err;
|
|
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()
|
|
51720
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" });
|
|
51721
52606
|
const structured = {
|
|
51722
|
-
policyPath
|
|
51723
|
-
schemaVersion:
|
|
51724
|
-
|
|
51725
|
-
|
|
51726
|
-
present: true,
|
|
51727
|
-
valid: result.valid,
|
|
51728
|
-
errors: result.errors
|
|
52607
|
+
policyPath,
|
|
52608
|
+
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
52609
|
+
bytesWritten: Buffer.byteLength(template, "utf-8"),
|
|
52610
|
+
overwritten: doForce
|
|
51729
52611
|
};
|
|
51730
52612
|
return {
|
|
51731
52613
|
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
51732
52614
|
structuredContent: structured
|
|
51733
52615
|
};
|
|
51734
|
-
}
|
|
51735
|
-
|
|
51736
|
-
|
|
51737
|
-
|
|
51738
|
-
|
|
51739
|
-
|
|
51740
|
-
|
|
51741
|
-
|
|
51742
|
-
|
|
51743
|
-
|
|
51744
|
-
|
|
51745
|
-
|
|
51746
|
-
|
|
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}"\``
|
|
51747
52686
|
};
|
|
51748
52687
|
return {
|
|
51749
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51750
|
-
structuredContent:
|
|
52688
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52689
|
+
structuredContent: structured2
|
|
51751
52690
|
};
|
|
51752
52691
|
}
|
|
51753
|
-
if (
|
|
51754
|
-
const
|
|
51755
|
-
|
|
51756
|
-
|
|
51757
|
-
|
|
51758
|
-
|
|
51759
|
-
"Does not resolve aliases against the live device inventory.",
|
|
51760
|
-
"Does not verify commands against the real target device, live capabilities, or current firmware."
|
|
51761
|
-
],
|
|
51762
|
-
present: true,
|
|
51763
|
-
valid: false,
|
|
51764
|
-
errors: err.yamlErrors.map((e) => ({
|
|
51765
|
-
path: "",
|
|
51766
|
-
line: e.line,
|
|
51767
|
-
col: e.col,
|
|
51768
|
-
keyword: "yaml-parse",
|
|
51769
|
-
message: e.message,
|
|
51770
|
-
schemaPath: ""
|
|
51771
|
-
}))
|
|
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(", ")})`
|
|
51772
52698
|
};
|
|
51773
52699
|
return {
|
|
51774
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51775
|
-
structuredContent:
|
|
52700
|
+
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
52701
|
+
structuredContent: structured2
|
|
51776
52702
|
};
|
|
51777
52703
|
}
|
|
51778
|
-
|
|
51779
|
-
}
|
|
51780
|
-
}
|
|
51781
|
-
);
|
|
51782
|
-
server.registerTool(
|
|
51783
|
-
"policy_new",
|
|
51784
|
-
{
|
|
51785
|
-
title: "Scaffold a starter policy.yaml",
|
|
51786
|
-
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.",
|
|
51787
|
-
_meta: { agentSafetyTier: "action" },
|
|
51788
|
-
inputSchema: external_exports.object({
|
|
51789
|
-
path: external_exports.string().optional().describe("Optional target path; defaults to the resolved default"),
|
|
51790
|
-
force: external_exports.boolean().optional().describe("When true, overwrite an existing file")
|
|
51791
|
-
}).strict(),
|
|
51792
|
-
outputSchema: {
|
|
51793
|
-
policyPath: external_exports.string(),
|
|
51794
|
-
schemaVersion: external_exports.string(),
|
|
51795
|
-
bytesWritten: external_exports.number(),
|
|
51796
|
-
overwritten: external_exports.boolean()
|
|
51797
|
-
}
|
|
51798
|
-
},
|
|
51799
|
-
async ({ path: pathArg, force }) => {
|
|
51800
|
-
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
51801
|
-
const doForce = force === true;
|
|
51802
|
-
if (fs18.existsSync(policyPath) && !doForce) {
|
|
51803
|
-
return mcpError("guard", 5, `refusing to overwrite existing policy at ${policyPath}`, {
|
|
51804
|
-
hint: "pass force=true to overwrite, or choose a different path",
|
|
51805
|
-
context: { policyPath }
|
|
51806
|
-
});
|
|
51807
|
-
}
|
|
51808
|
-
const template = readPolicyExampleYaml();
|
|
51809
|
-
fs18.mkdirSync(pathDirname(policyPath), { recursive: true });
|
|
51810
|
-
fs18.writeFileSync(policyPath, template, { encoding: "utf-8" });
|
|
51811
|
-
const structured = {
|
|
51812
|
-
policyPath,
|
|
51813
|
-
schemaVersion: CURRENT_POLICY_SCHEMA_VERSION,
|
|
51814
|
-
bytesWritten: Buffer.byteLength(template, "utf-8"),
|
|
51815
|
-
overwritten: doForce
|
|
51816
|
-
};
|
|
51817
|
-
return {
|
|
51818
|
-
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
51819
|
-
structuredContent: structured
|
|
51820
|
-
};
|
|
51821
|
-
}
|
|
51822
|
-
);
|
|
51823
|
-
server.registerTool(
|
|
51824
|
-
"policy_migrate",
|
|
51825
|
-
{
|
|
51826
|
-
title: "Migrate a policy file to the latest supported schema",
|
|
51827
|
-
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.',
|
|
51828
|
-
_meta: { agentSafetyTier: "action" },
|
|
51829
|
-
inputSchema: external_exports.object({
|
|
51830
|
-
path: external_exports.string().optional().describe("Optional policy file path; defaults to the resolved default path"),
|
|
51831
|
-
dryRun: external_exports.boolean().optional().describe("When true, report what would change without writing"),
|
|
51832
|
-
to: external_exports.string().optional().describe(`Target schema version (default: latest supported, "${LATEST_SUPPORTED_VERSION}")`)
|
|
51833
|
-
}).strict(),
|
|
51834
|
-
outputSchema: {
|
|
51835
|
-
policyPath: external_exports.string(),
|
|
51836
|
-
fileVersion: external_exports.string().optional(),
|
|
51837
|
-
targetVersion: external_exports.string(),
|
|
51838
|
-
supportedVersions: external_exports.array(external_exports.string()),
|
|
51839
|
-
status: external_exports.enum([
|
|
51840
|
-
"already-current",
|
|
51841
|
-
"migrated",
|
|
51842
|
-
"dry-run",
|
|
51843
|
-
"no-version-field",
|
|
51844
|
-
"unsupported",
|
|
51845
|
-
"precheck-failed",
|
|
51846
|
-
"file-not-found"
|
|
51847
|
-
]),
|
|
51848
|
-
from: external_exports.string().optional(),
|
|
51849
|
-
to: external_exports.string().optional(),
|
|
51850
|
-
bytesWritten: external_exports.number().optional(),
|
|
51851
|
-
message: external_exports.string(),
|
|
51852
|
-
errors: external_exports.array(external_exports.object({ path: external_exports.string(), keyword: external_exports.string(), message: external_exports.string() })).optional()
|
|
51853
|
-
}
|
|
51854
|
-
},
|
|
51855
|
-
async ({ path: pathArg, dryRun, to }) => {
|
|
51856
|
-
const policyPath = resolvePolicyPath({ flag: pathArg });
|
|
51857
|
-
const target = to ?? LATEST_SUPPORTED_VERSION;
|
|
51858
|
-
let loaded;
|
|
51859
|
-
try {
|
|
51860
|
-
loaded = loadPolicyFile(policyPath);
|
|
51861
|
-
} catch (err) {
|
|
51862
|
-
if (err instanceof PolicyFileNotFoundError) {
|
|
52704
|
+
if (fileVersion === target) {
|
|
51863
52705
|
const structured2 = {
|
|
51864
|
-
|
|
51865
|
-
|
|
51866
|
-
|
|
51867
|
-
|
|
51868
|
-
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
|
|
51869
52710
|
};
|
|
51870
52711
|
return {
|
|
51871
52712
|
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
51872
52713
|
structuredContent: structured2
|
|
51873
52714
|
};
|
|
51874
52715
|
}
|
|
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
|
-
structuredContent: structured2
|
|
51906
|
-
};
|
|
51907
|
-
}
|
|
51908
|
-
if (fileVersion === target) {
|
|
51909
|
-
const structured2 = {
|
|
51910
|
-
...base,
|
|
51911
|
-
status: "already-current",
|
|
51912
|
-
message: `already on schema v${target}; no migration needed`,
|
|
51913
|
-
bytesWritten: 0
|
|
51914
|
-
};
|
|
51915
|
-
return {
|
|
51916
|
-
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
51917
|
-
structuredContent: structured2
|
|
51918
|
-
};
|
|
51919
|
-
}
|
|
51920
|
-
const plan = planMigration(loaded, fileVersion, target);
|
|
51921
|
-
if (!plan.precheck.valid) {
|
|
51922
|
-
const structured2 = {
|
|
51923
|
-
...base,
|
|
51924
|
-
status: "precheck-failed",
|
|
51925
|
-
message: `migrated policy fails schema v${target} precheck; file not written`,
|
|
51926
|
-
errors: plan.precheck.errors.map((e) => ({ path: e.path, keyword: e.keyword, message: e.message }))
|
|
51927
|
-
};
|
|
51928
|
-
return {
|
|
51929
|
-
content: [{ type: "text", text: JSON.stringify(structured2, null, 2) }],
|
|
51930
|
-
structuredContent: structured2
|
|
51931
|
-
};
|
|
51932
|
-
}
|
|
51933
|
-
const bytes = Buffer.byteLength(plan.nextSource, "utf-8");
|
|
51934
|
-
if (dryRun) {
|
|
51935
|
-
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 = {
|
|
51936
52746
|
...base,
|
|
51937
|
-
status: "
|
|
52747
|
+
status: "migrated",
|
|
51938
52748
|
from: plan.fromVersion,
|
|
51939
52749
|
to: plan.toVersion,
|
|
51940
|
-
bytesWritten:
|
|
51941
|
-
message: `
|
|
52750
|
+
bytesWritten: bytes,
|
|
52751
|
+
message: `migrated ${policyPath} to schema v${plan.toVersion} (from v${plan.fromVersion})`
|
|
51942
52752
|
};
|
|
51943
52753
|
return {
|
|
51944
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
51945
|
-
structuredContent:
|
|
52754
|
+
content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
|
|
52755
|
+
structuredContent: structured
|
|
51946
52756
|
};
|
|
51947
52757
|
}
|
|
51948
|
-
|
|
51949
|
-
|
|
51950
|
-
|
|
51951
|
-
|
|
51952
|
-
|
|
51953
|
-
|
|
51954
|
-
|
|
51955
|
-
|
|
51956
|
-
|
|
51957
|
-
|
|
51958
|
-
|
|
51959
|
-
|
|
51960
|
-
|
|
51961
|
-
|
|
51962
|
-
|
|
51963
|
-
|
|
51964
|
-
|
|
51965
|
-
|
|
51966
|
-
|
|
51967
|
-
|
|
51968
|
-
|
|
51969
|
-
|
|
51970
|
-
|
|
51971
|
-
|
|
51972
|
-
|
|
51973
|
-
|
|
51974
|
-
|
|
51975
|
-
|
|
51976
|
-
|
|
51977
|
-
|
|
51978
|
-
truncated: external_exports.boolean(),
|
|
51979
|
-
stats: external_exports.object({
|
|
51980
|
-
added: external_exports.number().int(),
|
|
51981
|
-
removed: external_exports.number().int(),
|
|
51982
|
-
changed: external_exports.number().int()
|
|
51983
|
-
}),
|
|
51984
|
-
changes: external_exports.array(external_exports.object({
|
|
51985
|
-
path: external_exports.string(),
|
|
51986
|
-
kind: external_exports.enum(["added", "removed", "changed"]),
|
|
51987
|
-
before: external_exports.unknown().optional(),
|
|
51988
|
-
after: external_exports.unknown().optional()
|
|
51989
|
-
})),
|
|
51990
|
-
diff: external_exports.string()
|
|
51991
|
-
}
|
|
51992
|
-
},
|
|
51993
|
-
({ left_path, right_path }) => {
|
|
51994
|
-
let leftSource = "";
|
|
51995
|
-
let rightSource = "";
|
|
51996
|
-
try {
|
|
51997
|
-
leftSource = fs18.readFileSync(left_path, "utf-8");
|
|
51998
|
-
} catch (err) {
|
|
51999
|
-
if (err?.code === "ENOENT") {
|
|
52000
|
-
return mcpError("usage", 2, `policy file not found: ${left_path}`, {
|
|
52001
|
-
context: { policyPath: left_path }
|
|
52002
|
-
});
|
|
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()
|
|
52003
52788
|
}
|
|
52004
|
-
|
|
52005
|
-
}
|
|
52006
|
-
|
|
52007
|
-
rightSource =
|
|
52008
|
-
|
|
52009
|
-
|
|
52010
|
-
|
|
52011
|
-
|
|
52012
|
-
|
|
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)}`);
|
|
52013
52802
|
}
|
|
52014
|
-
|
|
52015
|
-
|
|
52016
|
-
|
|
52017
|
-
|
|
52018
|
-
|
|
52019
|
-
|
|
52020
|
-
|
|
52021
|
-
|
|
52022
|
-
|
|
52023
|
-
|
|
52024
|
-
|
|
52025
|
-
|
|
52026
|
-
|
|
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
|
+
};
|
|
52027
52834
|
}
|
|
52028
|
-
|
|
52029
|
-
leftPath: left_path,
|
|
52030
|
-
rightPath: right_path,
|
|
52031
|
-
...diffPolicyValues(leftDoc, rightDoc, leftSource, rightSource)
|
|
52032
|
-
};
|
|
52033
|
-
return {
|
|
52034
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
52035
|
-
structuredContent: result
|
|
52036
|
-
};
|
|
52037
|
-
}
|
|
52038
|
-
);
|
|
52835
|
+
);
|
|
52039
52836
|
if (eventManager) {
|
|
52040
52837
|
server.registerResource(
|
|
52041
52838
|
"events",
|
|
@@ -52058,559 +52855,568 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`
|
|
|
52058
52855
|
}
|
|
52059
52856
|
);
|
|
52060
52857
|
}
|
|
52061
|
-
|
|
52062
|
-
|
|
52063
|
-
|
|
52064
|
-
|
|
52065
|
-
|
|
52066
|
-
|
|
52067
|
-
|
|
52068
|
-
|
|
52069
|
-
|
|
52070
|
-
|
|
52071
|
-
|
|
52072
|
-
|
|
52073
|
-
|
|
52074
|
-
|
|
52075
|
-
|
|
52076
|
-
|
|
52077
|
-
|
|
52078
|
-
const
|
|
52079
|
-
|
|
52080
|
-
|
|
52081
|
-
try {
|
|
52082
|
-
const { plan, warnings } = suggestPlan({ intent, devices });
|
|
52083
|
-
return {
|
|
52084
|
-
content: [{ type: "text", text: JSON.stringify({ plan, warnings }, null, 2) }],
|
|
52085
|
-
structuredContent: { plan, warnings }
|
|
52086
|
-
};
|
|
52087
|
-
} catch (err) {
|
|
52088
|
-
return apiErrorToMcpError(err);
|
|
52089
|
-
}
|
|
52090
|
-
}
|
|
52091
|
-
);
|
|
52092
|
-
server.registerTool(
|
|
52093
|
-
"plan_run",
|
|
52094
|
-
{
|
|
52095
|
-
title: "Validate and execute a SwitchBot plan",
|
|
52096
|
-
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.",
|
|
52097
|
-
_meta: { agentSafetyTier: "action" },
|
|
52098
|
-
inputSchema: external_exports.object({
|
|
52099
|
-
plan: external_exports.unknown().describe("Plan JSON object (same schema as `switchbot plan run`)."),
|
|
52100
|
-
yes: external_exports.boolean().optional().describe("Authorize destructive command steps."),
|
|
52101
|
-
continue_on_error: external_exports.boolean().optional().describe("Keep executing later steps after a failed step.")
|
|
52102
|
-
}).strict(),
|
|
52103
|
-
outputSchema: {
|
|
52104
|
-
ran: external_exports.boolean(),
|
|
52105
|
-
plan: external_exports.unknown(),
|
|
52106
|
-
results: external_exports.array(external_exports.unknown()),
|
|
52107
|
-
summary: external_exports.object({
|
|
52108
|
-
total: external_exports.number().int(),
|
|
52109
|
-
ok: external_exports.number().int(),
|
|
52110
|
-
error: external_exports.number().int(),
|
|
52111
|
-
skipped: external_exports.number().int()
|
|
52112
|
-
})
|
|
52113
|
-
}
|
|
52114
|
-
},
|
|
52115
|
-
async ({ plan, yes, continue_on_error }) => {
|
|
52116
|
-
const validated = validatePlan(plan);
|
|
52117
|
-
if (!validated.ok) {
|
|
52118
|
-
return mcpError("usage", 2, "plan invalid", {
|
|
52119
|
-
context: { issues: validated.issues },
|
|
52120
|
-
hint: "Fix the reported issues and retry plan_run."
|
|
52121
|
-
});
|
|
52122
|
-
}
|
|
52123
|
-
const out = {
|
|
52124
|
-
ran: true,
|
|
52125
|
-
plan: validated.plan,
|
|
52126
|
-
results: [],
|
|
52127
|
-
summary: { total: validated.plan.steps.length, ok: 0, error: 0, skipped: 0 }
|
|
52128
|
-
};
|
|
52129
|
-
const continueOnError = continue_on_error === true;
|
|
52130
|
-
const allowDestructive = yes === true;
|
|
52131
|
-
const destructiveSteps = validated.plan.steps.map((step, index) => ({ step, index })).filter((entry) => entry.step.type === "command").map(({ step, index }) => {
|
|
52132
|
-
const resolvedDeviceId = resolveDeviceId(step.deviceId, step.deviceName);
|
|
52133
|
-
const commandType = step.commandType ?? "command";
|
|
52134
|
-
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
52135
|
-
return {
|
|
52136
|
-
index: index + 1,
|
|
52137
|
-
deviceId: resolvedDeviceId,
|
|
52138
|
-
command: step.command,
|
|
52139
|
-
commandType,
|
|
52140
|
-
deviceType: deviceType ?? null,
|
|
52141
|
-
destructive: isDestructiveCommand(deviceType, step.command, commandType)
|
|
52142
|
-
};
|
|
52143
|
-
}).filter((step) => step.destructive);
|
|
52144
|
-
if (allowDestructive && destructiveSteps.length > 0 && !allowsDirectDestructiveExecution()) {
|
|
52145
|
-
return mcpError("guard", 3, "Direct destructive execution is disabled for plan_run.", {
|
|
52146
|
-
hint: destructiveExecutionHint(),
|
|
52147
|
-
context: {
|
|
52148
|
-
destructiveSteps: destructiveSteps.map((step) => ({
|
|
52149
|
-
step: step.index,
|
|
52150
|
-
deviceId: step.deviceId,
|
|
52151
|
-
deviceType: step.deviceType,
|
|
52152
|
-
command: step.command,
|
|
52153
|
-
commandType: step.commandType
|
|
52154
|
-
})),
|
|
52155
|
-
requiredWorkflow: "plan-approval"
|
|
52156
|
-
}
|
|
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 };
|
|
52157
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
|
+
}
|
|
52158
52888
|
}
|
|
52159
|
-
|
|
52160
|
-
|
|
52161
|
-
|
|
52162
|
-
|
|
52163
|
-
|
|
52164
|
-
|
|
52165
|
-
|
|
52166
|
-
|
|
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
|
+
})
|
|
52167
52912
|
}
|
|
52168
|
-
|
|
52169
|
-
|
|
52170
|
-
|
|
52171
|
-
|
|
52172
|
-
|
|
52173
|
-
|
|
52174
|
-
|
|
52175
|
-
|
|
52176
|
-
out.summary.error++;
|
|
52177
|
-
if (!continueOnError) break;
|
|
52178
|
-
}
|
|
52179
|
-
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
|
+
});
|
|
52180
52921
|
}
|
|
52181
|
-
|
|
52182
|
-
|
|
52183
|
-
|
|
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);
|
|
52184
52932
|
const commandType = step.commandType ?? "command";
|
|
52185
52933
|
const deviceType = getCachedDevice(resolvedDeviceId)?.type;
|
|
52186
|
-
|
|
52187
|
-
|
|
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);
|
|
52188
53000
|
out.results.push({
|
|
52189
53001
|
step: idx,
|
|
52190
53002
|
type: "command",
|
|
52191
53003
|
deviceId: resolvedDeviceId,
|
|
52192
53004
|
command: step.command,
|
|
52193
|
-
status: "
|
|
52194
|
-
error: "destructive \u2014 rerun with yes=true"
|
|
53005
|
+
status: "ok"
|
|
52195
53006
|
});
|
|
52196
|
-
out.summary.
|
|
52197
|
-
|
|
52198
|
-
|
|
52199
|
-
|
|
52200
|
-
|
|
52201
|
-
|
|
52202
|
-
|
|
52203
|
-
|
|
52204
|
-
|
|
52205
|
-
|
|
52206
|
-
|
|
52207
|
-
|
|
52208
|
-
|
|
52209
|
-
|
|
52210
|
-
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);
|
|
52211
53021
|
out.results.push({
|
|
52212
53022
|
step: idx,
|
|
52213
53023
|
type: "command",
|
|
52214
53024
|
deviceId: resolvedDeviceId || step.deviceId || "unknown",
|
|
52215
53025
|
command: step.command,
|
|
52216
|
-
status: "
|
|
53026
|
+
status: "error",
|
|
53027
|
+
error: msg
|
|
52217
53028
|
});
|
|
52218
|
-
out.summary.
|
|
52219
|
-
|
|
53029
|
+
out.summary.error++;
|
|
53030
|
+
if (!continueOnError) break;
|
|
52220
53031
|
}
|
|
52221
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
52222
|
-
out.results.push({
|
|
52223
|
-
step: idx,
|
|
52224
|
-
type: "command",
|
|
52225
|
-
deviceId: resolvedDeviceId || step.deviceId || "unknown",
|
|
52226
|
-
command: step.command,
|
|
52227
|
-
status: "error",
|
|
52228
|
-
error: msg
|
|
52229
|
-
});
|
|
52230
|
-
out.summary.error++;
|
|
52231
|
-
if (!continueOnError) break;
|
|
52232
53032
|
}
|
|
52233
|
-
}
|
|
52234
|
-
return {
|
|
52235
|
-
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52236
|
-
structuredContent: out
|
|
52237
|
-
};
|
|
52238
|
-
}
|
|
52239
|
-
);
|
|
52240
|
-
server.registerTool(
|
|
52241
|
-
"audit_query",
|
|
52242
|
-
{
|
|
52243
|
-
title: "Query command/rule audit log entries",
|
|
52244
|
-
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.",
|
|
52245
|
-
_meta: { agentSafetyTier: "read" },
|
|
52246
|
-
inputSchema: external_exports.object({
|
|
52247
|
-
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
52248
|
-
since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h"). Mutually exclusive with from/to.'),
|
|
52249
|
-
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
52250
|
-
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
52251
|
-
kinds: external_exports.array(external_exports.enum(["command", "rule-fire", "rule-fire-dry", "rule-throttled", "rule-webhook-rejected"])).optional().describe("Filter by entry kind."),
|
|
52252
|
-
device_id: external_exports.string().optional().describe("Filter by deviceId."),
|
|
52253
|
-
rule_name: external_exports.string().optional().describe("Filter by rule.name (rule-engine entries)."),
|
|
52254
|
-
results: external_exports.array(external_exports.enum(["ok", "error", "dry-run"])).optional().describe("Filter by execution result."),
|
|
52255
|
-
limit: external_exports.number().int().min(1).max(5e3).optional().describe("Max entries returned from the tail of the filtered set (default 200).")
|
|
52256
|
-
}).strict(),
|
|
52257
|
-
outputSchema: {
|
|
52258
|
-
file: external_exports.string(),
|
|
52259
|
-
totalMatched: external_exports.number().int(),
|
|
52260
|
-
returned: external_exports.number().int(),
|
|
52261
|
-
entries: external_exports.array(external_exports.unknown())
|
|
52262
|
-
}
|
|
52263
|
-
},
|
|
52264
|
-
({ file: file2, since, from, to, kinds, device_id, rule_name, results, limit }) => {
|
|
52265
|
-
const filePath = file2 ?? DEFAULT_AUDIT_LOG_FILE;
|
|
52266
|
-
const entries = readAudit(filePath);
|
|
52267
|
-
try {
|
|
52268
|
-
const filtered = filterAuditEntries(entries, {
|
|
52269
|
-
since,
|
|
52270
|
-
from,
|
|
52271
|
-
to,
|
|
52272
|
-
kinds,
|
|
52273
|
-
deviceId: device_id,
|
|
52274
|
-
ruleName: rule_name,
|
|
52275
|
-
results
|
|
52276
|
-
});
|
|
52277
|
-
const bounded = filtered.slice(-Math.max(1, limit ?? 200));
|
|
52278
|
-
const out = {
|
|
52279
|
-
file: filePath,
|
|
52280
|
-
totalMatched: filtered.length,
|
|
52281
|
-
returned: bounded.length,
|
|
52282
|
-
entries: bounded
|
|
52283
|
-
};
|
|
52284
53033
|
return {
|
|
52285
53034
|
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52286
53035
|
structuredContent: out
|
|
52287
53036
|
};
|
|
52288
|
-
} catch (err) {
|
|
52289
|
-
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit query options");
|
|
52290
53037
|
}
|
|
52291
|
-
|
|
52292
|
-
)
|
|
52293
|
-
|
|
52294
|
-
|
|
52295
|
-
|
|
52296
|
-
|
|
52297
|
-
|
|
52298
|
-
|
|
52299
|
-
|
|
52300
|
-
|
|
52301
|
-
|
|
52302
|
-
|
|
52303
|
-
|
|
52304
|
-
|
|
52305
|
-
|
|
52306
|
-
|
|
52307
|
-
|
|
52308
|
-
|
|
52309
|
-
|
|
52310
|
-
|
|
52311
|
-
|
|
52312
|
-
|
|
52313
|
-
|
|
52314
|
-
|
|
52315
|
-
|
|
52316
|
-
|
|
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
|
+
}
|
|
52317
53091
|
}
|
|
52318
|
-
|
|
52319
|
-
|
|
52320
|
-
|
|
52321
|
-
|
|
52322
|
-
|
|
52323
|
-
|
|
52324
|
-
|
|
52325
|
-
|
|
52326
|
-
|
|
52327
|
-
|
|
52328
|
-
|
|
52329
|
-
|
|
52330
|
-
|
|
52331
|
-
|
|
52332
|
-
|
|
52333
|
-
|
|
52334
|
-
|
|
52335
|
-
|
|
52336
|
-
|
|
52337
|
-
|
|
52338
|
-
|
|
52339
|
-
|
|
52340
|
-
|
|
52341
|
-
|
|
52342
|
-
|
|
52343
|
-
|
|
52344
|
-
|
|
52345
|
-
|
|
52346
|
-
|
|
52347
|
-
|
|
52348
|
-
|
|
52349
|
-
|
|
52350
|
-
|
|
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 };
|
|
52351
53196
|
return {
|
|
52352
53197
|
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
|
|
52353
53198
|
structuredContent: out
|
|
52354
53199
|
};
|
|
52355
|
-
} catch (err) {
|
|
52356
|
-
return mcpError("usage", 2, err instanceof Error ? err.message : "invalid audit stats options");
|
|
52357
|
-
}
|
|
52358
|
-
}
|
|
52359
|
-
);
|
|
52360
|
-
server.registerTool(
|
|
52361
|
-
"rule_notifications",
|
|
52362
|
-
{
|
|
52363
|
-
title: "Query rule notification delivery history",
|
|
52364
|
-
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.",
|
|
52365
|
-
_meta: { agentSafetyTier: "read" },
|
|
52366
|
-
inputSchema: external_exports.object({
|
|
52367
|
-
file: external_exports.string().optional().describe("Optional audit log path; defaults to ~/.switchbot/audit.log."),
|
|
52368
|
-
rule: external_exports.string().optional().describe("Filter by rule name (exact match)."),
|
|
52369
|
-
since: external_exports.string().optional().describe('Relative window ending now (e.g. "30m", "24h").'),
|
|
52370
|
-
from: external_exports.string().optional().describe("Range start (ISO-8601)."),
|
|
52371
|
-
to: external_exports.string().optional().describe("Range end (ISO-8601)."),
|
|
52372
|
-
result: external_exports.enum(["ok", "error"]).optional().describe("Filter by delivery result."),
|
|
52373
|
-
channel: external_exports.enum(["webhook", "openclaw", "file"]).optional().describe("Filter by notify channel."),
|
|
52374
|
-
limit: external_exports.number().int().min(1).max(500).default(100).describe("Max entries to return (newest first).")
|
|
52375
|
-
}).strict(),
|
|
52376
|
-
outputSchema: {
|
|
52377
|
-
entries: external_exports.array(external_exports.unknown()).describe("Matching audit entries, newest first."),
|
|
52378
|
-
total: external_exports.number().int().describe("Count after filtering.")
|
|
52379
53200
|
}
|
|
52380
|
-
|
|
52381
|
-
|
|
52382
|
-
|
|
52383
|
-
|
|
52384
|
-
|
|
52385
|
-
|
|
52386
|
-
|
|
52387
|
-
|
|
52388
|
-
|
|
52389
|
-
|
|
52390
|
-
|
|
52391
|
-
|
|
52392
|
-
|
|
52393
|
-
|
|
52394
|
-
|
|
52395
|
-
|
|
52396
|
-
|
|
52397
|
-
|
|
52398
|
-
|
|
52399
|
-
|
|
52400
|
-
|
|
52401
|
-
|
|
52402
|
-
|
|
52403
|
-
|
|
52404
|
-
|
|
52405
|
-
|
|
52406
|
-
|
|
52407
|
-
|
|
52408
|
-
trigger: external_exports.enum(["mqtt", "cron", "webhook"]).optional().describe("Trigger type (inferred from intent if omitted)."),
|
|
52409
|
-
device_ids: external_exports.array(external_exports.string().min(1)).optional().describe("Device IDs; first is sensor for mqtt triggers, rest are action targets."),
|
|
52410
|
-
event: external_exports.string().optional().describe("MQTT event name override (e.g. motion.detected)."),
|
|
52411
|
-
schedule: external_exports.string().optional().describe('5-field cron expression override (e.g. "0 22 * * *").'),
|
|
52412
|
-
days: external_exports.array(external_exports.string()).optional().describe('Weekday filter (e.g. ["mon","tue","wed","thu","fri"]).'),
|
|
52413
|
-
webhook_path: external_exports.string().optional().describe("Webhook path override (default /action)."),
|
|
52414
|
-
llm: external_exports.enum(["auto", "openai", "anthropic"]).optional().describe("LLM backend (auto | openai | anthropic). Omit to use keyword heuristics.")
|
|
52415
|
-
}).strict(),
|
|
52416
|
-
outputSchema: {
|
|
52417
|
-
rule: external_exports.unknown().describe("Rule object matching the v0.2 policy schema."),
|
|
52418
|
-
rule_yaml: external_exports.string().describe("YAML string ready to pipe to policy_add_rule."),
|
|
52419
|
-
warnings: external_exports.array(external_exports.string()).describe("Informational warnings (e.g. unrecognized intent defaulted).")
|
|
52420
|
-
}
|
|
52421
|
-
},
|
|
52422
|
-
async ({ intent, trigger, device_ids, event, schedule, days, webhook_path, llm }) => {
|
|
52423
|
-
const devices = (device_ids ?? []).map((id) => {
|
|
52424
|
-
const cached2 = getCachedDevice(id);
|
|
52425
|
-
return { id, name: cached2?.name, type: cached2?.type };
|
|
52426
|
-
});
|
|
52427
|
-
try {
|
|
52428
|
-
const { rule, ruleYaml, warnings } = await suggestRule({
|
|
52429
|
-
intent,
|
|
52430
|
-
trigger,
|
|
52431
|
-
devices,
|
|
52432
|
-
event,
|
|
52433
|
-
schedule,
|
|
52434
|
-
days,
|
|
52435
|
-
webhookPath: webhook_path,
|
|
52436
|
-
llm
|
|
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 };
|
|
52437
53229
|
});
|
|
52438
|
-
return {
|
|
52439
|
-
content: [{ type: "text", text: ruleYaml }],
|
|
52440
|
-
structuredContent: { rule, rule_yaml: ruleYaml, warnings }
|
|
52441
|
-
};
|
|
52442
|
-
} catch (err) {
|
|
52443
|
-
return apiErrorToMcpError(err);
|
|
52444
|
-
}
|
|
52445
|
-
}
|
|
52446
|
-
);
|
|
52447
|
-
server.registerTool(
|
|
52448
|
-
"rules_explain",
|
|
52449
|
-
{
|
|
52450
|
-
title: "Show why a rule evaluation fired or was blocked",
|
|
52451
|
-
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".',
|
|
52452
|
-
_meta: { agentSafetyTier: "read" },
|
|
52453
|
-
inputSchema: external_exports.object({
|
|
52454
|
-
fire_id: external_exports.string().optional().describe("Specific fireId to explain."),
|
|
52455
|
-
rule_name: external_exports.string().optional().describe("Filter to this rule name."),
|
|
52456
|
-
since: external_exports.string().optional().describe("Duration string (e.g. 1h, 7d) \u2014 show evaluations in this window."),
|
|
52457
|
-
last: external_exports.boolean().optional().describe("Return only the most recent evaluation (requires rule_name)."),
|
|
52458
|
-
audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
|
|
52459
|
-
}).strict(),
|
|
52460
|
-
outputSchema: {
|
|
52461
|
-
records: external_exports.array(external_exports.unknown()).describe("Array of trace + relatedAudit objects."),
|
|
52462
|
-
count: external_exports.number().describe("Number of trace records returned.")
|
|
52463
|
-
}
|
|
52464
|
-
},
|
|
52465
|
-
async ({ fire_id, rule_name, since, last, audit_log }) => {
|
|
52466
|
-
const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
|
|
52467
|
-
const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
|
|
52468
|
-
const sinceIso = since ? new Date(Date.now() - (parseDurationToMs(since) ?? 0)).toISOString() : void 0;
|
|
52469
|
-
let records = loadTraceRecords(auditFile, {
|
|
52470
|
-
fireId: fire_id,
|
|
52471
|
-
ruleName: rule_name,
|
|
52472
|
-
since: sinceIso
|
|
52473
|
-
});
|
|
52474
|
-
if (records.length === 0) {
|
|
52475
|
-
return {
|
|
52476
|
-
content: [{ type: "text", text: 'No rule-evaluate trace records found. Check that automation.audit.evaluate_trace is "sampled" or "full".' }],
|
|
52477
|
-
structuredContent: { records: [], count: 0 }
|
|
52478
|
-
};
|
|
52479
|
-
}
|
|
52480
|
-
if (last) {
|
|
52481
|
-
records = [records[records.length - 1]];
|
|
52482
|
-
}
|
|
52483
|
-
const output = records.map((record2) => {
|
|
52484
|
-
const related = loadRelatedAudit(auditFile, record2.fireId);
|
|
52485
|
-
return JSON.parse(formatExplainJson(record2, related));
|
|
52486
|
-
});
|
|
52487
|
-
return {
|
|
52488
|
-
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
52489
|
-
structuredContent: { records: output, count: output.length }
|
|
52490
|
-
};
|
|
52491
|
-
}
|
|
52492
|
-
);
|
|
52493
|
-
server.registerTool(
|
|
52494
|
-
"rules_simulate",
|
|
52495
|
-
{
|
|
52496
|
-
title: "Simulate a rule against historical events",
|
|
52497
|
-
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.",
|
|
52498
|
-
_meta: { agentSafetyTier: "read" },
|
|
52499
|
-
inputSchema: external_exports.object({
|
|
52500
|
-
rule_yaml: external_exports.string().optional().describe("Standalone rule YAML (takes precedence over policy_path + rule_name)."),
|
|
52501
|
-
policy_path: external_exports.string().optional().describe("Path to policy.yaml (defaults to ~/.switchbot/policy.yaml)."),
|
|
52502
|
-
rule_name: external_exports.string().optional().describe("Name of the rule in policy.yaml to simulate."),
|
|
52503
|
-
since: external_exports.string().optional().describe("Replay events from this window (e.g. 7d, 24h)."),
|
|
52504
|
-
against: external_exports.string().optional().describe("JSONL file path of EngineEvent objects to replay."),
|
|
52505
|
-
live_llm: external_exports.boolean().optional().describe("Allow live LLM calls for llm conditions (default: skip and report as would-call)."),
|
|
52506
|
-
audit_log: external_exports.string().optional().describe(`Audit log path (default: ${pathJoin(os14.homedir(), ".switchbot", "audit.log")}).`)
|
|
52507
|
-
}).strict(),
|
|
52508
|
-
outputSchema: {
|
|
52509
|
-
report: external_exports.unknown().describe("SimulateReport object.")
|
|
52510
|
-
}
|
|
52511
|
-
},
|
|
52512
|
-
async ({ rule_yaml, policy_path, rule_name, since, against, live_llm, audit_log }) => {
|
|
52513
|
-
const DEFAULT_AUDIT_PATH4 = pathJoin(os14.homedir(), ".switchbot", "audit.log");
|
|
52514
|
-
const auditFile = audit_log ?? DEFAULT_AUDIT_PATH4;
|
|
52515
|
-
let rule;
|
|
52516
|
-
if (rule_yaml) {
|
|
52517
53230
|
try {
|
|
52518
|
-
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
|
+
};
|
|
52519
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) {
|
|
52520
53279
|
return {
|
|
52521
|
-
content: [{ type: "text", text:
|
|
52522
|
-
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 }
|
|
52523
53282
|
};
|
|
52524
53283
|
}
|
|
52525
|
-
|
|
52526
|
-
|
|
52527
|
-
|
|
52528
|
-
|
|
52529
|
-
const
|
|
52530
|
-
|
|
52531
|
-
|
|
52532
|
-
|
|
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) {
|
|
52533
53325
|
return {
|
|
52534
|
-
content: [{ type: "text", text: `
|
|
53326
|
+
content: [{ type: "text", text: `Failed to parse rule_yaml: ${String(err)}` }],
|
|
52535
53327
|
structuredContent: { report: null }
|
|
52536
53328
|
};
|
|
52537
53329
|
}
|
|
52538
|
-
|
|
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
|
+
};
|
|
52539
53368
|
} catch (err) {
|
|
52540
53369
|
return {
|
|
52541
|
-
content: [{ type: "text", text: `
|
|
53370
|
+
content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
|
|
52542
53371
|
structuredContent: { report: null }
|
|
52543
53372
|
};
|
|
52544
53373
|
}
|
|
52545
|
-
} else {
|
|
52546
|
-
return {
|
|
52547
|
-
content: [{ type: "text", text: "Provide rule_yaml or (policy_path + rule_name) to specify the rule to simulate." }],
|
|
52548
|
-
structuredContent: { report: null }
|
|
52549
|
-
};
|
|
52550
|
-
}
|
|
52551
|
-
try {
|
|
52552
|
-
const report = await simulateRule({
|
|
52553
|
-
rule,
|
|
52554
|
-
since,
|
|
52555
|
-
against,
|
|
52556
|
-
auditLog: auditFile,
|
|
52557
|
-
liveLlm: live_llm ?? false
|
|
52558
|
-
});
|
|
52559
|
-
return {
|
|
52560
|
-
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
52561
|
-
structuredContent: { report }
|
|
52562
|
-
};
|
|
52563
|
-
} catch (err) {
|
|
52564
|
-
return {
|
|
52565
|
-
content: [{ type: "text", text: `Simulate error: ${String(err)}` }],
|
|
52566
|
-
structuredContent: { report: null }
|
|
52567
|
-
};
|
|
52568
53374
|
}
|
|
52569
|
-
|
|
52570
|
-
)
|
|
52571
|
-
|
|
52572
|
-
|
|
52573
|
-
|
|
52574
|
-
|
|
52575
|
-
|
|
52576
|
-
|
|
52577
|
-
|
|
52578
|
-
|
|
52579
|
-
|
|
52580
|
-
|
|
52581
|
-
|
|
52582
|
-
|
|
52583
|
-
|
|
52584
|
-
|
|
52585
|
-
|
|
52586
|
-
|
|
52587
|
-
|
|
52588
|
-
|
|
52589
|
-
|
|
52590
|
-
|
|
52591
|
-
|
|
52592
|
-
|
|
52593
|
-
|
|
52594
|
-
|
|
52595
|
-
|
|
52596
|
-
|
|
52597
|
-
|
|
52598
|
-
|
|
52599
|
-
|
|
52600
|
-
|
|
52601
|
-
|
|
52602
|
-
|
|
52603
|
-
|
|
52604
|
-
|
|
52605
|
-
|
|
52606
|
-
|
|
52607
|
-
|
|
52608
|
-
|
|
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);
|
|
52609
53417
|
}
|
|
52610
|
-
return apiErrorToMcpError(err);
|
|
52611
53418
|
}
|
|
52612
|
-
|
|
52613
|
-
);
|
|
53419
|
+
);
|
|
52614
53420
|
return server;
|
|
52615
53421
|
}
|
|
52616
53422
|
function listRegisteredTools(server) {
|
|
@@ -52636,8 +53442,8 @@ function listRegisteredToolsWithMeta(server) {
|
|
|
52636
53442
|
function listRegisteredResources() {
|
|
52637
53443
|
return ["switchbot://events"];
|
|
52638
53444
|
}
|
|
52639
|
-
function printMcpToolDirectory() {
|
|
52640
|
-
const server = createSwitchBotMcpServer();
|
|
53445
|
+
function printMcpToolDirectory(toolProfile) {
|
|
53446
|
+
const server = createSwitchBotMcpServer({ toolProfile });
|
|
52641
53447
|
const tools = listRegisteredToolsWithMeta(server);
|
|
52642
53448
|
const resources = listRegisteredResources().map((uri) => ({ uri }));
|
|
52643
53449
|
if (isJsonMode()) {
|
|
@@ -52709,16 +53515,30 @@ Example Claude Desktop config (~/Library/Application Support/Claude/claude_deskt
|
|
|
52709
53515
|
Inspect locally:
|
|
52710
53516
|
$ npx @modelcontextprotocol/inspector switchbot mcp serve
|
|
52711
53517
|
`);
|
|
52712
|
-
mcp.command("tools").description("Print the registered MCP tools in human or JSON form").action(() =>
|
|
52713
|
-
|
|
52714
|
-
|
|
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", `
|
|
52715
53533
|
Examples:
|
|
52716
53534
|
$ switchbot mcp serve
|
|
53535
|
+
$ switchbot mcp serve --tools all
|
|
52717
53536
|
$ switchbot mcp serve --port 8787
|
|
52718
53537
|
$ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
|
|
52719
53538
|
$ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
|
|
52720
53539
|
`).action(async (options) => {
|
|
52721
53540
|
try {
|
|
53541
|
+
const toolProfile = resolveToolProfile(options.tools);
|
|
52722
53542
|
if (options.port) {
|
|
52723
53543
|
const port = Number(options.port);
|
|
52724
53544
|
if (!Number.isFinite(port) || port < 1 || port > 65535) {
|
|
@@ -52867,7 +53687,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
52867
53687
|
}
|
|
52868
53688
|
}
|
|
52869
53689
|
const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: void 0 });
|
|
52870
|
-
const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2 });
|
|
53690
|
+
const reqServer = createSwitchBotMcpServer({ eventManager: eventManager2, toolProfile });
|
|
52871
53691
|
res.on("close", () => {
|
|
52872
53692
|
reqTransport.close();
|
|
52873
53693
|
reqServer.close();
|
|
@@ -52919,7 +53739,7 @@ process_uptime_seconds ${Math.floor(process.uptime())}
|
|
|
52919
53739
|
console.error("MQTT initialization failed:", err instanceof Error ? err.message : String(err));
|
|
52920
53740
|
});
|
|
52921
53741
|
}
|
|
52922
|
-
const server = createSwitchBotMcpServer({ eventManager });
|
|
53742
|
+
const server = createSwitchBotMcpServer({ eventManager, toolProfile });
|
|
52923
53743
|
const transport = new StdioServerTransport();
|
|
52924
53744
|
await server.connect(transport);
|
|
52925
53745
|
let isShuttingDown = false;
|
|
@@ -55689,7 +56509,7 @@ Examples:
|
|
|
55689
56509
|
$ switchbot history show --limit 10
|
|
55690
56510
|
$ switchbot history replay 3
|
|
55691
56511
|
`);
|
|
55692
|
-
history.command("show").description("Print recent audit entries").option("--file <path>", `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg("--file")).option("--limit <n>", "Show only the last N entries", intArg("--limit", { min: 1 })).action((options) => {
|
|
56512
|
+
history.command("show").alias("list").description("Print recent audit entries").option("--file <path>", `Path to the audit log (default ${DEFAULT_AUDIT})`, stringArg("--file")).option("--limit <n>", "Show only the last N entries", intArg("--limit", { min: 1 })).action((options) => {
|
|
55693
56513
|
const file2 = options.file ?? DEFAULT_AUDIT;
|
|
55694
56514
|
const entries = readAudit(file2);
|
|
55695
56515
|
const limited = options.limit !== void 0 ? entries.slice(-Math.max(1, Number(options.limit) || 1)) : entries;
|
|
@@ -59338,6 +60158,7 @@ import os25 from "node:os";
|
|
|
59338
60158
|
import path27 from "node:path";
|
|
59339
60159
|
var DEFAULT_AUDIT_PATH3 = path27.join(os25.homedir(), ".switchbot", "audit.log");
|
|
59340
60160
|
var AUDIT_ERROR_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
60161
|
+
var EXPECTED_ERROR_CODES = /* @__PURE__ */ new Set([161, 171, 190]);
|
|
59341
60162
|
function getHealthReport(auditPath = DEFAULT_AUDIT_PATH3) {
|
|
59342
60163
|
const now = /* @__PURE__ */ new Date();
|
|
59343
60164
|
const procHealth = {
|
|
@@ -59358,20 +60179,46 @@ function getHealthReport(auditPath = DEFAULT_AUDIT_PATH3) {
|
|
|
59358
60179
|
};
|
|
59359
60180
|
let auditHealth;
|
|
59360
60181
|
if (!fs31.existsSync(auditPath)) {
|
|
59361
|
-
auditHealth = {
|
|
60182
|
+
auditHealth = {
|
|
60183
|
+
present: false,
|
|
60184
|
+
recentErrors: 0,
|
|
60185
|
+
recentTotal: 0,
|
|
60186
|
+
errorRatePercent: 0,
|
|
60187
|
+
expectedErrors: 0,
|
|
60188
|
+
unexpectedErrors: 0,
|
|
60189
|
+
unexpectedRatePercent: 0,
|
|
60190
|
+
breakdown: {},
|
|
60191
|
+
status: "ok"
|
|
60192
|
+
};
|
|
59362
60193
|
} else {
|
|
59363
60194
|
const entries = readAudit(auditPath);
|
|
59364
60195
|
const windowStart = now.getTime() - AUDIT_ERROR_WINDOW_MS;
|
|
59365
60196
|
const recent = entries.filter((e) => new Date(e.t).getTime() >= windowStart);
|
|
59366
|
-
const
|
|
60197
|
+
const errorEntries = recent.filter((e) => e.result === "error");
|
|
59367
60198
|
const total = recent.length;
|
|
60199
|
+
const errors = errorEntries.length;
|
|
59368
60200
|
const errorRate = total > 0 ? Math.round(errors / total * 100) : 0;
|
|
60201
|
+
const breakdown = {};
|
|
60202
|
+
let expectedErrors = 0;
|
|
60203
|
+
for (const e of errorEntries) {
|
|
60204
|
+
const code = e.statusCode !== void 0 ? String(e.statusCode) : "unknown";
|
|
60205
|
+
breakdown[code] = (breakdown[code] ?? 0) + 1;
|
|
60206
|
+
if (e.statusCode !== void 0 && EXPECTED_ERROR_CODES.has(e.statusCode)) {
|
|
60207
|
+
expectedErrors++;
|
|
60208
|
+
}
|
|
60209
|
+
}
|
|
60210
|
+
const unexpectedErrors = errors - expectedErrors;
|
|
60211
|
+
const unexpectedRatePercent = total > 0 ? Math.round(unexpectedErrors / total * 100 * 10) / 10 : 0;
|
|
59369
60212
|
auditHealth = {
|
|
59370
60213
|
present: true,
|
|
59371
60214
|
recentErrors: errors,
|
|
59372
60215
|
recentTotal: total,
|
|
59373
60216
|
errorRatePercent: errorRate,
|
|
59374
|
-
|
|
60217
|
+
expectedErrors,
|
|
60218
|
+
unexpectedErrors,
|
|
60219
|
+
unexpectedRatePercent,
|
|
60220
|
+
breakdown,
|
|
60221
|
+
status: unexpectedRatePercent >= 30 ? "warn" : "ok"
|
|
59375
60222
|
};
|
|
59376
60223
|
}
|
|
59377
60224
|
const cbStats = apiCircuitBreaker.getStats();
|