Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ Generic parameter shapes (which one applies is decided by the device — see the
| `<json object>` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` |
| Custom IR button | `devices command <id> MyButton --type customize` |

Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.

For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).

#### `devices expand` — named flags for packed parameters
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "2.2.1",
"version": "2.3.0",
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
"keywords": [
"switchbot",
Expand Down
52 changes: 44 additions & 8 deletions src/commands/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
DeviceNotFoundError,
type Device,
} from '../lib/devices.js';
import { validateParameter } from '../devices/param-validator.js';
import { registerBatchCommand } from './batch.js';
import { registerWatchCommand } from './watch.js';
import { registerExplainCommand } from './explain.js';
Expand Down Expand Up @@ -358,19 +359,22 @@ Examples:
`)
.action(async (deviceIdArg: string | undefined, cmdArg: string | undefined, parameter: string | undefined, options: { name?: string; type: string; yes?: boolean; idempotencyKey?: string }) => {
try {
// BUG-FIX: When --name is provided, Commander misassigns the first positional
// to [deviceId] instead of [cmd]. Detect and shift positionals accordingly.
// BUG-FIX: When --name is provided, Commander fills positionals left-to-right
// starting at [deviceId]. Shift them back to their semantic slots.
let cmd: string;
let effectiveDeviceIdArg: string | undefined;
if (options.name) {
if (deviceIdArg && cmdArg) {
throw new UsageError('Provide either a deviceId argument or --name, not both.');
}
if (!deviceIdArg && !cmdArg) {
// `--name "x" <cmd> [parameter]` → Commander binds deviceIdArg=<cmd>, cmdArg=[parameter].
if (!deviceIdArg) {
throw new UsageError('Command name is required (e.g. turnOn, turnOff, setAll).');
}
// --name "x" turnOn → deviceIdArg="turnOn", cmdArg=undefined → shift
cmd = (deviceIdArg ?? cmdArg) as string;
cmd = deviceIdArg;
if (cmdArg !== undefined) {
if (parameter !== undefined) {
throw new UsageError('Too many positional arguments after --name. Expected: --name <query> <cmd> [parameter].');
}
parameter = cmdArg;
}
effectiveDeviceIdArg = undefined;
} else {
if (!cmdArg) {
Expand Down Expand Up @@ -412,6 +416,38 @@ Examples:
process.exit(2);
}

// Case-only mismatch: emit a warning and continue with the canonical name.
if (validation.caseNormalizedFrom && validation.normalized) {
console.error(
`Note: '${validation.caseNormalizedFrom}' normalized to '${validation.normalized}' (case mismatch). Use exact casing to silence this warning.`
);
cmd = validation.normalized;
} else if (validation.normalized) {
cmd = validation.normalized;
}

// Raw-parameter validation (runs for known (deviceType, command) pairs only).
const cachedForParam = getCachedDevice(deviceId);
if (cachedForParam && options.type === 'command') {
const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
if (!paramCheck.ok) {
if (isJsonMode()) {
console.error(JSON.stringify({
error: {
code: 2,
kind: 'usage',
message: paramCheck.error,
context: { command: cmd, deviceType: cachedForParam.type, deviceId },
},
}));
} else {
console.error(`Error: ${paramCheck.error}`);
}
process.exit(2);
}
if (paramCheck.normalized !== undefined) parameter = paramCheck.normalized;
}

const cachedForGuard = getCachedDevice(deviceId);
if (
!options.yes &&
Expand Down
90 changes: 6 additions & 84 deletions src/commands/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,90 +6,12 @@ import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../l
import { isDryRun } from '../utils/flags.js';
import { resolveDeviceId } from '../utils/name-resolver.js';
import { DryRunSignal } from '../api/client.js';

// ---- Mapping tables --------------------------------------------------------

const AC_MODE_MAP: Record<string, number> = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 };
const AC_FAN_MAP: Record<string, number> = { auto: 1, low: 2, mid: 3, high: 4 };
const CURTAIN_MODE_MAP: Record<string, string> = { default: 'ff', performance: '0', silent: '1' };
const RELAY_MODE_MAP: Record<string, number> = { toggle: 0, edge: 1, detached: 2, momentary: 3 };
const BLIND_DIRECTION = new Set(['up', 'down']);

// ---- Translators -----------------------------------------------------------

function buildAcSetAll(opts: {
temp?: string; mode?: string; fan?: string; power?: string;
}): string {
if (!opts.temp) throw new UsageError('--temp is required for setAll (e.g. --temp 26)');
if (!opts.mode) throw new UsageError('--mode is required for setAll (auto|cool|dry|fan|heat)');
if (!opts.fan) throw new UsageError('--fan is required for setAll (auto|low|mid|high)');
if (!opts.power) throw new UsageError('--power is required for setAll (on|off)');

const temp = parseInt(opts.temp, 10);
if (!Number.isFinite(temp) || temp < 16 || temp > 30) {
throw new UsageError(`--temp must be an integer between 16 and 30 (got "${opts.temp}")`);
}
const modeInt = AC_MODE_MAP[opts.mode.toLowerCase()];
if (modeInt === undefined) {
throw new UsageError(`--mode must be one of: auto, cool, dry, fan, heat (got "${opts.mode}")`);
}
const fanInt = AC_FAN_MAP[opts.fan.toLowerCase()];
if (fanInt === undefined) {
throw new UsageError(`--fan must be one of: auto, low, mid, high (got "${opts.fan}")`);
}
const power = opts.power.toLowerCase();
if (power !== 'on' && power !== 'off') {
throw new UsageError(`--power must be "on" or "off" (got "${opts.power}")`);
}
return `${temp},${modeInt},${fanInt},${power}`;
}

function buildCurtainSetPosition(opts: {
position?: string; mode?: string;
}): string {
if (!opts.position) throw new UsageError('--position is required (0-100)');
const pos = parseInt(opts.position, 10);
if (!Number.isFinite(pos) || pos < 0 || pos > 100) {
throw new UsageError(`--position must be an integer between 0 and 100 (got "${opts.position}")`);
}
const modeStr = opts.mode ? CURTAIN_MODE_MAP[opts.mode.toLowerCase()] : 'ff';
if (modeStr === undefined) {
throw new UsageError(`--mode must be one of: default, performance, silent (got "${opts.mode}")`);
}
return `0,${modeStr},${pos}`;
}

function buildBlindTiltSetPosition(opts: {
direction?: string; angle?: string;
}): string {
if (!opts.direction) throw new UsageError('--direction is required (up|down)');
if (!opts.angle) throw new UsageError('--angle is required (0-100)');
const dir = opts.direction.toLowerCase();
if (!BLIND_DIRECTION.has(dir)) {
throw new UsageError(`--direction must be "up" or "down" (got "${opts.direction}")`);
}
const angle = parseInt(opts.angle, 10);
if (!Number.isFinite(angle) || angle < 0 || angle > 100) {
throw new UsageError(`--angle must be an integer between 0 and 100 (got "${opts.angle}")`);
}
return `${dir};${angle}`;
}

function buildRelaySetMode(opts: {
channel?: string; mode?: string;
}): string {
if (!opts.channel) throw new UsageError('--channel is required (1 or 2)');
if (!opts.mode) throw new UsageError('--mode is required (toggle|edge|detached|momentary)');
const ch = parseInt(opts.channel, 10);
if (ch !== 1 && ch !== 2) {
throw new UsageError(`--channel must be 1 or 2 (got "${opts.channel}")`);
}
const modeInt = RELAY_MODE_MAP[opts.mode.toLowerCase()];
if (modeInt === undefined) {
throw new UsageError(`--mode must be one of: toggle, edge, detached, momentary (got "${opts.mode}")`);
}
return `${ch};${modeInt}`;
}
import {
buildAcSetAll,
buildCurtainSetPosition,
buildBlindTiltSetPosition,
buildRelaySetMode,
} from '../devices/param-validator.js';

// ---- Registration ----------------------------------------------------------

Expand Down
Loading
Loading