diff --git a/README.md b/README.md index 35cd438..71bfc52 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,8 @@ Generic parameter shapes (which one applies is decided by the device — see the | `` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` | | Custom IR button | `devices command 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 diff --git a/package-lock.json b/package-lock.json index c441b5e..4acaf08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.2.1", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.2.1", + "version": "2.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 1f36f3a..94159a1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 0e5ec7b..f04ccc1 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -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'; @@ -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" [parameter]` → Commander binds deviceIdArg=, 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 [parameter].'); + } + parameter = cmdArg; + } effectiveDeviceIdArg = undefined; } else { if (!cmdArg) { @@ -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 && diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 3814178..8e26c87 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -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 = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 }; -const AC_FAN_MAP: Record = { auto: 1, low: 2, mid: 3, high: 4 }; -const CURTAIN_MODE_MAP: Record = { default: 'ff', performance: '0', silent: '1' }; -const RELAY_MODE_MAP: Record = { 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 ---------------------------------------------------------- diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts new file mode 100644 index 0000000..b8e2174 --- /dev/null +++ b/src/devices/param-validator.ts @@ -0,0 +1,286 @@ +import { UsageError } from '../utils/output.js'; + +export const AC_MODE_MAP: Record = { auto: 1, cool: 2, dry: 3, fan: 4, heat: 5 }; +export const AC_FAN_MAP: Record = { auto: 1, low: 2, mid: 3, high: 4 }; +export const CURTAIN_MODE_MAP: Record = { default: 'ff', performance: '0', silent: '1' }; +export const RELAY_MODE_MAP: Record = { toggle: 0, edge: 1, detached: 2, momentary: 3 }; +const BLIND_DIRECTION = new Set(['up', 'down']); + +// ---- Semantic-flag builders (used by `devices expand`) -------------------- + +export 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}`; +} + +export 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}`; +} + +export 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}`; +} + +export 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}`; +} + +// ---- Raw-parameter validator (used by `devices command`) ------------------ + +export type ValidateResult = + | { ok: true; normalized?: string } + | { ok: false; error: string }; + +/** + * Validate a raw wire-format parameter string for (deviceType, command) + * combos where the shape is well-defined. Unknown combos pass through so + * `devices command` remains a usable escape hatch for types/commands the + * CLI hasn't modelled yet. + * + * On passthrough, `normalized` is left undefined so the caller keeps the + * original parameter value (preserving the `undefined → "default"` default + * for no-arg commands). + */ +export function validateParameter( + deviceType: string | undefined, + command: string, + raw: string | undefined, +): ValidateResult { + if (!deviceType) return { ok: true }; + + if (deviceType === 'Air Conditioner' && command === 'setAll') { + return validateAcSetAll(raw); + } + if (deviceType.startsWith('Curtain') && command === 'setPosition') { + return validateCurtainSetPosition(raw); + } + if (deviceType.startsWith('Blind Tilt') && command === 'setPosition') { + return validateBlindTiltSetPosition(raw); + } + if (deviceType.startsWith('Relay Switch') && command === 'setMode') { + return validateRelaySetMode(raw); + } + + return { ok: true }; +} + +function validateAcSetAll(raw: string | undefined): ValidateResult { + if (raw === undefined || raw === '' || raw === 'default') { + return { + ok: false, + error: `setAll requires a parameter ",,,". Example: "26,2,2,on".`, + }; + } + if (raw.startsWith('{') || raw.startsWith('[')) { + return { + ok: false, + error: `setAll parameter must be a CSV string like "26,2,2,on", not JSON (got ${JSON.stringify(raw)}).`, + }; + } + const parts = raw.split(','); + if (parts.length !== 4) { + return { + ok: false, + error: `setAll expects 4 comma-separated fields ",,,", got ${parts.length} (${JSON.stringify(raw)}). Example: "26,2,2,on".`, + }; + } + const [tempStr, modeStr, fanStr, powerStr] = parts.map((s) => s.trim()); + + const temp = Number(tempStr); + if (!Number.isInteger(temp) || temp < 16 || temp > 30) { + return { + ok: false, + error: `setAll field 1 (temp) must be an integer 16-30, got "${tempStr}". Example: "26,2,2,on".`, + }; + } + const mode = Number(modeStr); + if (!Number.isInteger(mode) || mode < 1 || mode > 5) { + return { + ok: false, + error: `setAll field 2 (mode) must be 1-5 (1=auto 2=cool 3=dry 4=fan 5=heat), got "${modeStr}". Example: "26,2,2,on".`, + }; + } + const fan = Number(fanStr); + if (!Number.isInteger(fan) || fan < 1 || fan > 4) { + return { + ok: false, + error: `setAll field 3 (fan) must be 1-4 (1=auto 2=low 3=mid 4=high), got "${fanStr}". Example: "26,2,2,on".`, + }; + } + const power = powerStr.toLowerCase(); + if (power !== 'on' && power !== 'off') { + return { + ok: false, + error: `setAll field 4 (power) must be "on" or "off", got "${powerStr}". Example: "26,2,2,on".`, + }; + } + return { ok: true, normalized: `${temp},${mode},${fan},${power}` }; +} + +function validateCurtainSetPosition(raw: string | undefined): ValidateResult { + if (raw === undefined || raw === '' || raw === 'default') { + return { + ok: false, + error: `setPosition requires a parameter. Expected: "<0-100>" or ",,<0-100>". Example: "50" or "0,ff,50".`, + }; + } + if (!raw.includes(',')) { + const pos = Number(raw); + if (!Number.isInteger(pos) || pos < 0 || pos > 100) { + return { + ok: false, + error: `setPosition must be an integer 0-100, got "${raw}". Example: "50".`, + }; + } + return { ok: true, normalized: String(pos) }; + } + const parts = raw.split(',').map((s) => s.trim()); + if (parts.length !== 3) { + return { + ok: false, + error: `setPosition tuple form expects 3 comma-separated fields ",,<0-100>", got ${parts.length} (${JSON.stringify(raw)}).`, + }; + } + const [idxStr, modeStr, posStr] = parts; + const idx = Number(idxStr); + if (!Number.isInteger(idx) || idx < 0) { + return { + ok: false, + error: `setPosition field 1 (index) must be a non-negative integer, got "${idxStr}".`, + }; + } + const modeLower = modeStr.toLowerCase(); + if (!['ff', '0', '1'].includes(modeLower)) { + return { + ok: false, + error: `setPosition field 2 (mode) must be "ff", "0", or "1", got "${modeStr}". (ff=default, 0=performance, 1=silent)`, + }; + } + const pos = Number(posStr); + if (!Number.isInteger(pos) || pos < 0 || pos > 100) { + return { + ok: false, + error: `setPosition field 3 (position) must be an integer 0-100, got "${posStr}".`, + }; + } + return { ok: true, normalized: `${idx},${modeLower},${pos}` }; +} + +function validateBlindTiltSetPosition(raw: string | undefined): ValidateResult { + if (raw === undefined || raw === '' || raw === 'default') { + return { + ok: false, + error: `Blind Tilt setPosition requires a parameter. Expected: ";<0-100>". Example: "up;50".`, + }; + } + const parts = raw.split(';'); + if (parts.length !== 2) { + return { + ok: false, + error: `Blind Tilt setPosition expects ";", got ${JSON.stringify(raw)}. Example: "up;50".`, + }; + } + const dir = parts[0].toLowerCase(); + if (!BLIND_DIRECTION.has(dir)) { + return { + ok: false, + error: `Blind Tilt setPosition direction must be "up" or "down", got "${parts[0]}".`, + }; + } + const angle = Number(parts[1]); + if (!Number.isInteger(angle) || angle < 0 || angle > 100) { + return { + ok: false, + error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`, + }; + } + return { ok: true, normalized: `${dir};${angle}` }; +} + +function validateRelaySetMode(raw: string | undefined): ValidateResult { + if (raw === undefined || raw === '' || raw === 'default') { + return { + ok: false, + error: `Relay Switch setMode requires a parameter. Expected: "<1|2>;<0|1|2|3>". Example: "1;1" (channel 1, edge mode).`, + }; + } + const parts = raw.split(';'); + if (parts.length !== 2) { + return { + ok: false, + error: `Relay Switch setMode expects ";", got ${JSON.stringify(raw)}. Example: "1;1".`, + }; + } + const ch = Number(parts[0]); + if (ch !== 1 && ch !== 2) { + return { + ok: false, + error: `Relay Switch setMode channel must be 1 or 2, got "${parts[0]}".`, + }; + } + const mode = Number(parts[1]); + if (!Number.isInteger(mode) || mode < 0 || mode > 3) { + return { + ok: false, + error: `Relay Switch setMode mode must be 0-3 (0=toggle 1=edge 2=detached 3=momentary), got "${parts[1]}".`, + }; + } + return { ok: true, normalized: `${ch};${mode}` }; +} diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 69b0aa9..00773aa 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -206,15 +206,17 @@ export async function executeCommand( /** * Validate a command against the locally-cached device → catalog mapping. * Returns `{ ok: true }` when validation passes or is skipped (unknown device, - * custom IR button, etc.); returns `{ ok: false, error }` when the caller - * should refuse the call. + * custom IR button, etc.). On a case-only mismatch the canonical command name + * is returned via `normalized` along with a `caseNormalizedFrom` field so the + * caller can emit a warning and continue with the canonical name. + * Returns `{ ok: false, error }` only when the caller should refuse the call. */ export function validateCommand( deviceId: string, cmd: string, parameter: string | undefined, commandType: string -): { ok: true } | { ok: false; error: CommandValidationError } { +): { ok: true; normalized?: string; caseNormalizedFrom?: string } | { ok: false; error: CommandValidationError } { if (commandType === 'customize') return { ok: true }; const cached = getCachedDevice(deviceId); @@ -226,32 +228,42 @@ export function validateCommand( const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize'); if (builtinCommands.length === 0) return { ok: true }; - const spec = builtinCommands.find((c) => c.command === cmd); + let spec = builtinCommands.find((c) => c.command === cmd); + let caseNormalizedFrom: string | undefined; + let normalizedCmd = cmd; + if (!spec) { const unique = [...new Set(builtinCommands.map((c) => c.command))]; const caseMatch = unique.find((c) => c.toLowerCase() === cmd.toLowerCase()); - const hint = caseMatch - ? `Did you mean "${caseMatch}"? Supported commands: ${unique.join(', ')}` - : `Supported commands: ${unique.join(', ')}`; - return { - ok: false, - error: new CommandValidationError( - `"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, - 'unknown-command', - hint - ), - }; + if (caseMatch) { + // Case-only mismatch: normalize and continue. + caseNormalizedFrom = cmd; + normalizedCmd = caseMatch; + spec = builtinCommands.find((c) => c.command === caseMatch); + } else { + const hint = `Supported commands: ${unique.join(', ')}`; + return { + ok: false, + error: new CommandValidationError( + `"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, + 'unknown-command', + hint + ), + }; + } } + if (!spec) return { ok: true, normalized: normalizedCmd, caseNormalizedFrom }; + const noParamExpected = spec.parameter === '—'; const userProvidedParam = parameter !== undefined && parameter !== 'default'; if (noParamExpected && userProvidedParam) { return { ok: false, error: new CommandValidationError( - `"${cmd}" takes no parameter, but one was provided: "${parameter}".`, + `"${normalizedCmd}" takes no parameter, but one was provided: "${parameter}".`, 'unexpected-parameter', - `Try: switchbot devices command ${deviceId} ${cmd}` + `Try: switchbot devices command ${deviceId} ${normalizedCmd}` ), }; } @@ -263,16 +275,16 @@ export function validateCommand( return { ok: false, error: new CommandValidationError( - `"${cmd}" requires a parameter (${spec.parameter}).`, + `"${normalizedCmd}" requires a parameter (${spec.parameter}).`, 'missing-parameter', example - ? `Example: switchbot devices command ${cmd} "${example}"` + ? `Example: switchbot devices command ${normalizedCmd} "${example}"` : `See: switchbot devices commands ${cached.type}`, ), }; } - return { ok: true }; + return { ok: true, normalized: normalizedCmd, caseNormalizedFrom }; } /** diff --git a/src/utils/arg-parsers.ts b/src/utils/arg-parsers.ts index e0af82a..d2cdbca 100644 --- a/src/utils/arg-parsers.ts +++ b/src/utils/arg-parsers.ts @@ -14,7 +14,10 @@ export function intArg( opts?: { min?: number; max?: number }, ): (value: string) => string { return (value: string) => { - if (value.startsWith('-')) { + // Flag-like tokens (`--something`, `-x`) are rejected up-front. + // Pure negative integers (`-1`, `-42`) fall through to min/max so the + // error classifies as a range error rather than "requires a numeric value". + if (value.startsWith('-') && !/^-\d+$/.test(value)) { throw new InvalidArgumentError( `${flagName} requires a numeric value, got "${value}". ` + `Did you forget a value? Use ${flagName}= if the value really starts with "-".`, diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 4d521b3..231b8d2 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -1829,4 +1829,236 @@ describe('devices command', () => { expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); }); }); + + // ===================================================================== + // command — raw-parameter validation (setAll / setPosition / setMode) + // ===================================================================== + describe('command — raw-parameter validation', () => { + let tmpDir: string; + const AC_ID = 'AC-VAL'; + const CURTAIN_ID = 'CURTAIN-VAL'; + const BLIND_ID = 'BLIND-VAL'; + const RELAY_ID = 'RELAY-VAL'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-param-validate-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: CURTAIN_ID, deviceName: 'Living Curtain', deviceType: 'Curtain' }, + { deviceId: BLIND_ID, deviceName: 'Bedroom Blind', deviceType: 'Blind Tilt' }, + { deviceId: RELAY_ID, deviceName: 'Relay', deviceType: 'Relay Switch 2PM' }, + ], + infraredRemoteList: [ + { deviceId: AC_ID, deviceName: 'Living AC', remoteType: 'Air Conditioner' }, + ], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rejects malformed setAll ("on,2,2,30") before POST', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', AC_ID, 'setAll', 'on,2,2,30', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/temp.*16-30/i); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('rejects empty setAll parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', AC_ID, 'setAll', '', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/requires a parameter/); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('rejects JSON-shaped setAll parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', AC_ID, 'setAll', '{"temp":30}', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/CSV string/); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('rejects setAll with wrong field count', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', AC_ID, 'setAll', '30', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/4 comma-separated/); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('accepts valid setAll CSV', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', AC_ID, 'setAll', '26,2,2,on', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${AC_ID}/commands`, + { command: 'setAll', parameter: '26,2,2,on', commandType: 'command' } + ); + }); + + it('accepts Curtain setPosition single-value form', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', CURTAIN_ID, 'setPosition', '50', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${CURTAIN_ID}/commands`, + { command: 'setPosition', parameter: 50, commandType: 'command' } + ); + }); + + it('accepts Curtain setPosition tuple form', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', CURTAIN_ID, 'setPosition', '0,ff,50', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${CURTAIN_ID}/commands`, + { command: 'setPosition', parameter: '0,ff,50', commandType: 'command' } + ); + }); + + it('rejects Blind Tilt setPosition with bad direction', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', BLIND_ID, 'setPosition', 'left;50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/up.*down/); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('rejects Relay Switch setMode with bad channel', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', RELAY_ID, 'setMode', '3;1', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/1 or 2/); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + }); + + // ===================================================================== + // command — case-insensitive command name normalization + // ===================================================================== + describe('command — case normalization', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-case-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: DID, deviceName: 'Living Bot', deviceType: 'Bot' }, + ], + infraredRemoteList: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('normalizes "turnon" to "turnOn" and POSTs canonical name', async () => { + const res = await runCmd('turnon'); + expect(res.exitCode).toBeNull(); + expect(res.stderr.join('\n')).toMatch(/'turnon' normalized to 'turnOn'/); + expectPost('turnOn', 'default'); + }); + + it('normalizes "TurnOn" to "turnOn"', async () => { + const res = await runCmd('TurnOn'); + expect(res.exitCode).toBeNull(); + expect(res.stderr.join('\n')).toMatch(/'TurnOn' normalized to 'turnOn'/); + expectPost('turnOn', 'default'); + }); + + it('--json output uses the canonical command name', async () => { + const res = await runCmd('turnon', '--json'); + expect(res.exitCode).toBeNull(); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.command).toBe('turnOn'); + }); + + it('still rejects genuinely unknown commands with exit 2', async () => { + const res = await runCmd('foobar'); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/is not a supported command/); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + }); + + // ===================================================================== + // command — --name resolver + positional args + // ===================================================================== + describe('command — --name + positional shift', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-name-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: 'AC-FUZZY', deviceName: 'Living Room AC', deviceType: 'Air Conditioner' }, + { deviceId: 'BOT-FUZZY', deviceName: 'Kitchen Bot', deviceType: 'Bot' }, + ], + infraredRemoteList: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves --name + bare command (no parameter)', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', '--name', 'Kitchen', 'turnOn', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + '/v1.1/devices/BOT-FUZZY/commands', + { command: 'turnOn', parameter: 'default', commandType: 'command' } + ); + }); + + it('resolves --name + command + parameter (positional shift)', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', '--name', 'Living Room', 'setAll', '26,2,2,on', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + '/v1.1/devices/AC-FUZZY/commands', + { command: 'setAll', parameter: '26,2,2,on', commandType: 'command' } + ); + }); + + it('resolves --name + command + color parameter with colons', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: 'BULB-FUZZY', deviceName: 'Desk Lamp', deviceType: 'Color Bulb' }, + ], + infraredRemoteList: [], + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', '--name', 'Desk', 'setColor', '255:0:0', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + '/v1.1/devices/BULB-FUZZY/commands', + { command: 'setColor', parameter: '255:0:0', commandType: 'command' } + ); + }); + }); }); diff --git a/tests/devices/param-validator.test.ts b/tests/devices/param-validator.test.ts new file mode 100644 index 0000000..1a3acb7 --- /dev/null +++ b/tests/devices/param-validator.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { + buildAcSetAll, + buildCurtainSetPosition, + buildBlindTiltSetPosition, + buildRelaySetMode, + validateParameter, +} from '../../src/devices/param-validator.js'; + +describe('buildAcSetAll (semantic-flag → wire)', () => { + it('maps mode + fan + on/off to CSV', () => { + expect( + buildAcSetAll({ temp: '26', mode: 'cool', fan: 'low', power: 'on' }) + ).toBe('26,2,2,on'); + expect( + buildAcSetAll({ temp: '22', mode: 'heat', fan: 'auto', power: 'on' }) + ).toBe('22,5,1,on'); + }); + + it('rejects out-of-range temperature', () => { + expect(() => buildAcSetAll({ temp: '99', mode: 'cool', fan: 'low', power: 'on' })).toThrow(/16 and 30/); + expect(() => buildAcSetAll({ temp: '10', mode: 'cool', fan: 'low', power: 'on' })).toThrow(/16 and 30/); + }); + + it('rejects unknown mode / fan / power', () => { + expect(() => buildAcSetAll({ temp: '22', mode: 'turbo', fan: 'low', power: 'on' })).toThrow(/auto, cool, dry, fan, heat/); + expect(() => buildAcSetAll({ temp: '22', mode: 'cool', fan: 'breeze', power: 'on' })).toThrow(/auto, low, mid, high/); + expect(() => buildAcSetAll({ temp: '22', mode: 'cool', fan: 'low', power: 'yes' })).toThrow(/"on" or "off"/); + }); + + it('rejects missing flags', () => { + expect(() => buildAcSetAll({ mode: 'cool', fan: 'low', power: 'on' })).toThrow(/--temp/); + expect(() => buildAcSetAll({ temp: '22', fan: 'low', power: 'on' })).toThrow(/--mode/); + }); +}); + +describe('buildCurtainSetPosition', () => { + it('defaults mode to ff', () => { + expect(buildCurtainSetPosition({ position: '30' })).toBe('0,ff,30'); + }); + + it('maps silent/performance/default modes', () => { + expect(buildCurtainSetPosition({ position: '50', mode: 'silent' })).toBe('0,1,50'); + expect(buildCurtainSetPosition({ position: '50', mode: 'performance' })).toBe('0,0,50'); + expect(buildCurtainSetPosition({ position: '50', mode: 'default' })).toBe('0,ff,50'); + }); + + it('rejects out-of-range position and bad mode', () => { + expect(() => buildCurtainSetPosition({ position: '101' })).toThrow(/0 and 100/); + expect(() => buildCurtainSetPosition({ position: '50', mode: 'turbo' })).toThrow(/default, performance, silent/); + }); +}); + +describe('buildBlindTiltSetPosition', () => { + it('combines direction + angle', () => { + expect(buildBlindTiltSetPosition({ direction: 'up', angle: '50' })).toBe('up;50'); + expect(buildBlindTiltSetPosition({ direction: 'down', angle: '0' })).toBe('down;0'); + }); + + it('rejects invalid direction and angle', () => { + expect(() => buildBlindTiltSetPosition({ direction: 'left', angle: '50' })).toThrow(/"up" or "down"/); + expect(() => buildBlindTiltSetPosition({ direction: 'up', angle: '150' })).toThrow(/0 and 100/); + }); +}); + +describe('buildRelaySetMode', () => { + it('combines channel + mode', () => { + expect(buildRelaySetMode({ channel: '1', mode: 'edge' })).toBe('1;1'); + expect(buildRelaySetMode({ channel: '2', mode: 'momentary' })).toBe('2;3'); + }); + + it('rejects invalid channel and mode', () => { + expect(() => buildRelaySetMode({ channel: '3', mode: 'edge' })).toThrow(/1 or 2/); + expect(() => buildRelaySetMode({ channel: '1', mode: 'pulse' })).toThrow(/toggle, edge, detached, momentary/); + }); +}); + +describe('validateParameter (raw wire-format validator)', () => { + // ---- AC setAll ---- + it('accepts valid AC setAll CSV', () => { + const r = validateParameter('Air Conditioner', 'setAll', '26,2,2,on'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBe('26,2,2,on'); + }); + + it('rejects empty / default / undefined AC setAll parameter', () => { + for (const raw of [undefined, '', 'default']) { + const r = validateParameter('Air Conditioner', 'setAll', raw); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/requires a parameter/); + } + }); + + it('rejects JSON-shaped AC setAll parameter', () => { + const r = validateParameter('Air Conditioner', 'setAll', '{"temp":30}'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/CSV string/); + }); + + it('rejects wrong field count', () => { + const r = validateParameter('Air Conditioner', 'setAll', '30'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/4 comma-separated/); + }); + + it('rejects non-integer / out-of-range temp', () => { + const r1 = validateParameter('Air Conditioner', 'setAll', 'on,2,2,30'); + expect(r1.ok).toBe(false); + if (!r1.ok) expect(r1.error).toMatch(/temp.*16-30/i); + + const r2 = validateParameter('Air Conditioner', 'setAll', '99,2,2,on'); + expect(r2.ok).toBe(false); + if (!r2.ok) expect(r2.error).toMatch(/temp.*16-30/i); + }); + + it('rejects out-of-range mode and fan', () => { + const bad = validateParameter('Air Conditioner', 'setAll', '26,9,2,on'); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.error).toMatch(/mode/); + }); + + it('rejects bad power field', () => { + const r = validateParameter('Air Conditioner', 'setAll', '26,2,2,yes'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/power.*on.*off/i); + }); + + // ---- Curtain setPosition ---- + it('accepts Curtain setPosition single-value form', () => { + const r = validateParameter('Curtain', 'setPosition', '50'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBe('50'); + }); + + it('accepts Curtain setPosition tuple form', () => { + const r = validateParameter('Curtain 3', 'setPosition', '0,ff,80'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBe('0,ff,80'); + }); + + it('rejects Curtain setPosition out-of-range', () => { + const r = validateParameter('Curtain', 'setPosition', '150'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/0-100/); + }); + + it('rejects Curtain setPosition bad mode flag', () => { + const r = validateParameter('Curtain', 'setPosition', '0,bogus,50'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/ff.*0.*1/); + }); + + // ---- Blind Tilt setPosition ---- + it('accepts Blind Tilt setPosition', () => { + const r = validateParameter('Blind Tilt', 'setPosition', 'up;50'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBe('up;50'); + }); + + it('rejects Blind Tilt setPosition bad direction', () => { + const r = validateParameter('Blind Tilt', 'setPosition', 'left;50'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/up.*down/); + }); + + // ---- Relay Switch setMode ---- + it('accepts Relay Switch setMode', () => { + const r = validateParameter('Relay Switch 2PM', 'setMode', '1;1'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBe('1;1'); + }); + + it('rejects Relay Switch setMode bad channel', () => { + const r = validateParameter('Relay Switch 2PM', 'setMode', '3;1'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/1 or 2/); + }); + + // ---- Passthrough ---- + it('passes through unknown (type, command) combos', () => { + const r = validateParameter('Color Bulb', 'setColor', '255:0:0'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBeUndefined(); + }); + + it('passes through when deviceType is undefined', () => { + const r = validateParameter(undefined, 'setAll', 'anything'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBeUndefined(); + }); + + it('passes through unknown commands on known device types', () => { + const r = validateParameter('Air Conditioner', 'customButton', 'xyz'); + expect(r.ok).toBe(true); + if (r.ok) expect(r.normalized).toBeUndefined(); + }); +}); diff --git a/tests/utils/arg-parsers.test.ts b/tests/utils/arg-parsers.test.ts index 3632b1a..15daeb6 100644 --- a/tests/utils/arg-parsers.test.ts +++ b/tests/utils/arg-parsers.test.ts @@ -11,11 +11,13 @@ describe('intArg', () => { expect(parse('0')).toBe('0'); }); - it('rejects values starting with "-" (tokens that look like flags)', () => { - expect(() => parse('-5')).toThrow(InvalidArgumentError); - expect(() => parse('-5')).toThrow(/requires a numeric value/); + it('rejects flag-like tokens but not pure negative integers', () => { + // Bare negative integers fall through to min/max so the error classifies + // as a range error instead of "requires a numeric value". + expect(parse('-5')).toBe('-5'); expect(() => parse('--help')).toThrow(InvalidArgumentError); expect(() => parse('--help')).toThrow(/requires a numeric value/); + expect(() => parse('-x')).toThrow(/requires a numeric value/); }); it('rejects non-numeric strings', () => { @@ -31,6 +33,12 @@ describe('intArg', () => { expect(() => bounded('70000')).toThrow(/<= 65535/); }); + it('reports negative values as a range error (not flag-like) when min is set', () => { + const bounded = intArg('--max', { min: 1 }); + expect(() => bounded('-1')).toThrow(/>= 1/); + expect(() => bounded('-100')).toThrow(/>= 1/); + }); + it('rejects values that look like subcommand names', () => { // `switchbot --timeout devices list` — Commander would normally swallow // "devices" as the --timeout value; argParser must catch it.