diff --git a/.gitignore b/.gitignore index 06accec..f24b917 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ tmp/ smoke-v3/ switchbot-skill/ docs/superpowers/ +docs/smoke-* diff --git a/README.md b/README.md index 0ff87c7..ac13fe5 100644 --- a/README.md +++ b/README.md @@ -633,7 +633,7 @@ switchbot doctor switchbot doctor --json ``` -Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. +Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, catalog-coverage, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. `--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating: diff --git a/package.json b/package.json index 9604750..04206c3 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "hooks:install": "node scripts/install-git-hooks.mjs", "lint:md": "markdownlint \"**/*.md\"", "lint:md:changelog": "markdownlint CHANGELOG.md", + "lint:stdout": "bash scripts/lint-stdout.sh", "prepare": "node scripts/install-git-hooks.mjs", "start": "node dist/index.js", "smoke:pack-install": "node scripts/smoke-pack-install.mjs", @@ -51,6 +52,7 @@ "test:release-smoke:manual": "npm test -- tests/commands/policy.test.ts tests/commands/devices.test.ts tests/commands/explain.test.ts tests/commands/doctor.test.ts tests/commands/mcp.test.ts tests/commands/health-check.test.ts tests/commands/quota.test.ts tests/commands/status-sync.test.ts tests/status-sync/smoke.test.ts tests/commands/watch.test.ts tests/commands/events.test.ts tests/devices/catalog-fidelity.test.ts tests/commands/schema.test.ts tests/commands/auth.test.ts tests/commands/config.test.ts tests/commands/scenes.test.ts tests/commands/batch.test.ts tests/commands/history.test.ts tests/commands/expand.test.ts tests/commands/webhook.test.ts tests/commands/daemon.test.ts tests/commands/upgrade-check.test.ts tests/commands/install.test.ts tests/commands/uninstall.test.ts tests/commands/rules.test.ts tests/commands/plan.test.ts", "verify:pre-commit": "npm run build && npm test -- tests/version.test.ts", "verify:pre-push": "npm run build && npm test -- tests/version.test.ts && npm run smoke:pack-install", + "verify:release": "node scripts/verify-release.mjs", "prepublishOnly": "npm test && npm run build && npm run smoke:pack-install" }, "dependencies": { diff --git a/scripts/lint-stdout.sh b/scripts/lint-stdout.sh new file mode 100755 index 0000000..61abcdf --- /dev/null +++ b/scripts/lint-stdout.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# scripts/lint-stdout.sh +# Prevents diagnostic/debug messages from leaking into stdout. +# Legitimate table-mode output (human-readable results) is allowed. +# This catches the class of bugs where dry-run, debug, or warning text +# goes to console.log instead of console.error. +set -euo pipefail + +cd "$(dirname "$0")/.." + +errors=0 + +# 1. No dry-run messages on stdout (must use console.error) +hits=$(grep -rn 'console\.log.*dry.run\|console\.log.*◦' src/commands/ 2>/dev/null | grep -v '// stdout-ok' || true) +if [ -n "$hits" ]; then + echo "ERROR: dry-run messages must use console.error, not console.log:" + echo "$hits" + errors=$((errors + 1)) +fi + +# 2. No bare Number() in param-validator validate* functions (must use parseStrictInt) +# Excludes: parseStrictInt itself, build* functions (pre-validated), Number.isX checks +bare_number=$(grep -n 'Number(' src/devices/param-validator.ts | grep -v 'parseStrictInt\|Number.isInteger\|Number.isNaN\|Number.isFinite\|// number-ok\|function parseStrictInt' || true) +if [ -n "$bare_number" ]; then + echo "WARNING: bare Number() in param-validator.ts — consider using parseStrictInt():" + echo "$bare_number" + echo "(add '// number-ok' comment to suppress if pre-validated)" + echo "" +fi + +# 3. Every registerXxxCommand in src/commands/ must have a test file +missing_tests="" +for cmd in $(grep -roh 'export function register\w\+Command' src/commands/ | sed 's/export function //' | sort -u); do + if ! grep -rl "$cmd" tests/commands/ >/dev/null 2>&1; then + missing_tests="$missing_tests $cmd\n" + fi +done +if [ -n "$missing_tests" ]; then + echo "WARNING: commands without test coverage:" + printf "$missing_tests" + echo "(not blocking — add tests before next release)" + echo "" +fi + +if [ "$errors" -gt 0 ]; then + echo "" + echo "FAILED: $errors check(s) failed" + exit 1 +fi + +echo "OK: all stdout/quality checks passed" diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..0df2203 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +/** + * scripts/verify-release.mjs + * Pre-release verification gate — checks that documented counts and versions + * match the actual codebase. Exits non-zero if any discrepancy is found. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +function readFile(rel) { + return fs.readFileSync(path.join(ROOT, rel), 'utf-8'); +} + +function countMatches(content, regex) { + return (content.match(regex) || []).length; +} + +let errors = 0; +let warnings = 0; + +function fail(msg) { + console.error(` ✗ ${msg}`); + errors++; +} + +function warn(msg) { + console.error(` ! ${msg}`); + warnings++; +} + +function pass(msg) { + console.error(` ✓ ${msg}`); +} + +console.error('verify-release: checking pre-release invariants\n'); + +// 1. MCP tool count +const mcpSrc = readFile('src/commands/mcp.ts'); +const mcpToolCount = countMatches(mcpSrc, /server\.registerTool\(/g); +const agentGuide = readFile('docs/agent-guide.md'); +const agentGuideMatch = agentGuide.match(/Available tools \((\d+)\)/); +const agentGuideCount = agentGuideMatch ? Number(agentGuideMatch[1]) : null; + +if (agentGuideCount === null) { + fail('docs/agent-guide.md: could not find "Available tools (N)" heading'); +} else if (mcpToolCount !== agentGuideCount) { + fail(`MCP tool count mismatch: code has ${mcpToolCount}, docs/agent-guide.md says ${agentGuideCount}`); +} else { + pass(`MCP tools: ${mcpToolCount} (code = docs/agent-guide.md)`); +} + +// 2. Doctor check count +const doctorSrc = readFile('src/commands/doctor.ts'); +const doctorChecks = new Set(doctorSrc.match(/name: '[^']+'/g) || []); +const doctorCount = doctorChecks.size; +const readme = readFile('README.md'); +const readmeDoctorMatch = readme.match(/(\d+)\s*(?:health|doctor|diagnostic)\s*check/i); +if (readmeDoctorMatch) { + const readmeDoctorCount = Number(readmeDoctorMatch[1]); + if (doctorCount !== readmeDoctorCount) { + fail(`Doctor check count mismatch: code has ${doctorCount}, README says ${readmeDoctorCount}`); + } else { + pass(`Doctor checks: ${doctorCount} (code = README)`); + } +} else { + warn(`README.md: could not find doctor check count pattern — manual verification needed (code has ${doctorCount})`); +} + +// 3. Audit version +const auditSrc = readFile('src/utils/audit.ts'); +const auditVersionMatch = auditSrc.match(/AUDIT_VERSION\s*=\s*(\d+)/); +const auditVersion = auditVersionMatch ? Number(auditVersionMatch[1]) : null; +const auditDoc = readFile('docs/audit-log.md'); +const auditDocMatch = auditDoc.match(/Current:\s*`(\d+)`/); +const auditDocVersion = auditDocMatch ? Number(auditDocMatch[1]) : null; + +if (auditVersion === null) { + fail('src/utils/audit.ts: could not find AUDIT_VERSION constant'); +} else if (auditDocVersion === null) { + fail('docs/audit-log.md: could not find "Current: `N`" pattern'); +} else if (auditVersion !== auditDocVersion) { + fail(`Audit version mismatch: code has ${auditVersion}, docs/audit-log.md says ${auditDocVersion}`); +} else { + pass(`Audit version: ${auditVersion} (code = docs/audit-log.md)`); +} + +// 4. package.json version vs tag (informational) +const pkg = JSON.parse(readFile('package.json')); +pass(`package.json version: ${pkg.version}`); + +// 5. Test count (informational — just report, don't fail on mismatch since test count changes frequently) +const readmeTestMatch = readme.match(/(\d{3,})\s*tests/); +if (readmeTestMatch) { + const readmeTestCount = Number(readmeTestMatch[1]); + warn(`README says ${readmeTestCount} tests — run \`npm test\` and update if stale`); +} else { + warn('README.md: could not find test count pattern'); +} + +// Summary +console.error(''); +if (errors > 0) { + console.error(`FAILED: ${errors} error(s), ${warnings} warning(s)`); + process.exit(1); +} else if (warnings > 0) { + console.error(`PASSED with ${warnings} warning(s)`); +} else { + console.error('PASSED: all checks green'); +} diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 9bf7c33..cfda2cb 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -511,8 +511,8 @@ Examples: printJson(result); } else { if (dryRunned.length > 0) { - console.log(`\nPlanned (dry-run): ${dryRunned.length} device(s)`); - for (const d of dryRunned) console.log(` - ${d.deviceId}`); + console.error(`\nPlanned (dry-run): ${dryRunned.length} device(s)`); + for (const d of dryRunned) console.error(` - ${d.deviceId}`); } if (preSkipped.length > 0) { console.log(`\nSkipped (offline): ${preSkipped.length} device(s)`); diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 11599c6..e10a774 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -173,6 +173,7 @@ Examples: .option('--strict', 'Only return entries whose type name matches (skip alias/role/command fallbacks)') .action((keyword: string, options: { strict?: boolean }) => { try { + if (!keyword.trim()) throw new UsageError('catalog search requires a non-empty keyword.'); const q = keyword.toLowerCase(); const entries = getEffectiveCatalog(); const strict = options.strict === true; diff --git a/src/commands/devices.ts b/src/commands/devices.ts index a53d31f..218d806 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -297,7 +297,7 @@ Examples: devices .command('status') .description('Query the real-time status of a specific device') - .argument('[deviceId]', 'Device ID from "devices list" (or use --name or --ids)') + .argument('[deviceId...]', 'Device ID(s) from "devices list" (or use --name or --ids)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) .option('--name-strategy ', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: fuzzy)`, stringArg('--name-strategy')) .option('--name-type ', 'Narrow --name by device type (e.g. "Bot", "Color Bulb")', stringArg('--name-type')) @@ -314,6 +314,7 @@ all field names returned by your specific device, then narrow with --fields. Examples: $ switchbot devices status ABC123DEF456 + $ switchbot devices status ABC123 DEF456 GHI789 $ switchbot devices status --name "Living Room AC" $ switchbot devices status ABC123DEF456 --json $ switchbot devices status ABC123DEF456 --format yaml @@ -322,13 +323,16 @@ Examples: $ switchbot devices status --ids ABC123,DEF456,GHI789 $ switchbot devices status --ids ABC123,DEF456 --fields power,battery `) - .action(async (deviceIdArg: string | undefined, options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string }) => { + .action(async (deviceIdArgs: string[], options: { name?: string; nameStrategy?: string; nameType?: string; nameCategory?: 'physical' | 'ir'; nameRoom?: string; ids?: string }) => { try { - // Batch mode: --ids id1,id2,id3 - if (options.ids) { + // Batch mode: --ids id1,id2,id3 OR multiple positional args + const batchIds = options.ids + ? options.ids.split(',').map((s) => s.trim()).filter(Boolean) + : deviceIdArgs.length > 1 ? deviceIdArgs : undefined; + if (batchIds) { if (options.name) throw new UsageError('--ids and --name cannot be used together.'); - const ids = options.ids.split(',').map((s) => s.trim()).filter(Boolean); - if (ids.length === 0) throw new UsageError('--ids requires at least one device ID.'); + if (batchIds.length === 0) throw new UsageError('--ids requires at least one device ID.'); + const ids = batchIds; const results = await Promise.allSettled(ids.map((id) => fetchDeviceStatus(id))); const fetchedAt = new Date().toISOString(); const batch = results.map((r, i) => @@ -366,7 +370,7 @@ Examples: return; } - const deviceId = resolveDeviceId(deviceIdArg, options.name, { + const deviceId = resolveDeviceId(deviceIdArgs[0], options.name, { strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'fuzzy', type: options.nameType, category: options.nameCategory, @@ -702,7 +706,7 @@ Examples: if (isJsonMode()) { printJson({ dryRun: true, wouldSend }); } else { - console.log(`◦ dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`); + console.error(`◦ dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`); } return; } @@ -1004,13 +1008,20 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log(`Type: ${entry.type}`); console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`); if (entry.role) console.log(`Role: ${entry.role}`); - if (entry.readOnly) console.log(`ReadOnly: yes (status-only device, no control commands)`); + const hasStatusFields = (entry.statusFields?.length ?? 0) > 0; + if (entry.readOnly) { + console.log(hasStatusFields + ? `ReadOnly: yes (status-only device, no control commands)` + : `ReadOnly: yes (no cloud control commands cataloged)`); + } if (entry.aliases && entry.aliases.length > 0) { console.log(`Aliases: ${entry.aliases.join(', ')}`); } if (entry.commands.length === 0) { - console.log('\nCommands: (none — status-only device)'); + console.log(hasStatusFields + ? '\nCommands: (none — status-only device)' + : '\nCommands: (none — no cloud control commands cataloged)'); } else { console.log('\nCommands:'); const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f421c7a..f428fc3 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -265,6 +265,26 @@ function checkCatalog(): Check { }; } +function checkCatalogCoverage(): Check { + const cache = loadCache(); + if (!cache) { + return { name: 'catalog-coverage', status: 'ok', detail: 'no device cache — run "switchbot devices list" first' }; + } + const catalog = getEffectiveCatalog(); + const catalogTypes = new Set(catalog.map((e) => e.type.toLowerCase())); + const aliases = new Set(catalog.flatMap((e) => (e.aliases ?? []).map((a) => a.toLowerCase()))); + const deviceTypes = [...new Set(Object.values(cache.devices).map((d) => d.type))]; + const missing = deviceTypes.filter((t) => !catalogTypes.has(t.toLowerCase()) && !aliases.has(t.toLowerCase())); + if (missing.length === 0) { + return { name: 'catalog-coverage', status: 'ok', detail: `all ${deviceTypes.length} device types have catalog entries` }; + } + return { + name: 'catalog-coverage', + status: 'warn', + detail: { missing, message: `${missing.length} device type(s) without catalog entry` }, + }; +} + function checkCache(): Check { try { const info = describeCache(); @@ -402,6 +422,7 @@ function checkInventoryConsistency(): Check { status: 'warn', detail: { message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`, + hint: 'This usually means the hub was removed or replaced. Re-pair affected devices in the SwitchBot app, or ignore if the devices still work.', dangling: dangling.slice(0, 10), }, }; @@ -976,6 +997,7 @@ const CHECK_REGISTRY: CheckDef[] = [ { name: 'keychain', description: 'OS keychain backend availability and usage', run: () => checkKeychain() }, { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() }, { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() }, + { name: 'catalog-coverage', description: 'all cached device types have catalog entries', run: () => checkCatalogCoverage() }, { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() }, { name: 'inventory', description: 'cached inventory graph consistency (hubDeviceId references)', run: () => checkInventoryConsistency() }, { name: 'cache', description: 'device cache state', run: () => checkCache() }, @@ -1051,6 +1073,7 @@ interface DoctorCliOptions { fix?: boolean; yes?: boolean; probe?: boolean; + quiet?: boolean; } export function registerDoctorCommand(program: Command): void { @@ -1062,6 +1085,7 @@ export function registerDoctorCommand(program: Command): void { .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)') .option('--yes', 'Required together with --fix to confirm write actions') .option('--probe', 'Perform live-probe variant of checks that support it (mqtt)') + .option('-q, --quiet', 'Only show warn/fail checks, hide passing checks') .addHelpText('after', ` Runs a battery of local sanity checks and exits with code 0 only when every check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1. @@ -1159,7 +1183,9 @@ Examples: if (fixes !== undefined) payload.fixes = fixes; printJson(payload); } else { + const quiet = Boolean(opts.quiet); for (const c of checks) { + if (quiet && c.status === 'ok') continue; const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗'; const detailStr = typeof c.detail === 'string' diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 556c3f0..70a9663 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -231,7 +231,7 @@ Examples: if (isJsonMode()) { printJson({ ok: true, dryRun: true, command, deviceId }); } else { - console.log(`◦ dry-run: ${command} would be sent to ${deviceId}`); + console.error(`◦ dry-run: ${command} would be sent to ${deviceId}`); } return; } diff --git a/src/commands/health.ts b/src/commands/health.ts index 378e821..501efcd 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,8 +1,9 @@ import http from 'node:http'; import { Command } from 'commander'; -import { printJson, isJsonMode, printTable, handleError } from '../utils/output.js'; +import { printJson, isJsonMode, printTable, handleError, UsageError } from '../utils/output.js'; import { getHealthReport, toPrometheusText } from '../utils/health.js'; import { intArg } from '../utils/arg-parsers.js'; +import { resolveFormat, renderRows } from '../utils/format.js'; const HEALTHZ_SCHEMA_VERSION = '1.1'; @@ -12,28 +13,36 @@ function runHealthCheck(opts: { prometheus?: boolean; auditLog?: string }): void process.stdout.write(toPrometheusText(report)); return; } - if (isJsonMode()) { + const fmt = resolveFormat(); + if (fmt === 'json' || isJsonMode()) { printJson(report); return; } + const headers = ['Component', 'Status', 'Detail']; + const rows: string[][] = [ + ['quota', report.quota.status, + `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`], + ['audit', report.audit.status, + report.audit.present + ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` + : 'log not present'], + ['circuit', report.circuit.status, + `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`], + ['process', 'ok', + `pid ${report.process.pid} · uptime ${report.process.uptimeSeconds}s · mem ${report.process.memoryMb}MB`], + ]; + if (fmt !== 'table') { + if (fmt === 'id') { + handleError(new UsageError('--format=id is not supported for health check (no deviceId column). Use --format json, yaml, tsv, jsonl, or markdown.')); + } + renderRows(headers, rows, fmt); + if (report.overall !== 'ok') process.exit(1); + return; + } const statusEmoji = report.overall === 'ok' ? '✓' : report.overall === 'degraded' ? '⚠' : '✗'; console.log(`${statusEmoji} overall: ${report.overall} (${report.generatedAt})`); console.log(''); - printTable( - ['Component', 'Status', 'Detail'], - [ - ['quota', report.quota.status, - `${report.quota.used}/${report.quota.limit} (${report.quota.percentUsed}% used, ${report.quota.remaining} remaining)`], - ['audit', report.audit.status, - report.audit.present - ? `${report.audit.recentErrors}/${report.audit.recentTotal} errors in 24h (${report.audit.errorRatePercent}%)` - : 'log not present'], - ['circuit', report.circuit.status, - `${report.circuit.name}: ${report.circuit.state} (failures: ${report.circuit.failures})`], - ['process', 'ok', - `pid ${report.process.pid} · uptime ${report.process.uptimeSeconds}s · mem ${report.process.memoryMb}MB`], - ], - ); + printTable(headers, rows); if (report.overall !== 'ok') process.exit(1); } diff --git a/src/commands/install.ts b/src/commands/install.ts index 777d3ad..b2d5752 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -108,18 +108,18 @@ function printDryRun(steps: InstallStep[], ctx: InstallContext): }); return; } - console.log(chalk.bold('switchbot install — dry run')); - console.log(` profile: ${ctx.profile}`); - console.log(` agent: ${ctx.agent}`); - console.log(` skill: ${ctx.skillPath ?? '(none — recipe will be printed)'}`); - console.log(` policy: ${ctx.policyPath}`); - console.log(''); - console.log(chalk.bold('Steps that would run (in order):')); + console.error(chalk.bold('switchbot install — dry run')); + console.error(` profile: ${ctx.profile}`); + console.error(` agent: ${ctx.agent}`); + console.error(` skill: ${ctx.skillPath ?? '(none — recipe will be printed)'}`); + console.error(` policy: ${ctx.policyPath}`); + console.error(''); + console.error(chalk.bold('Steps that would run (in order):')); for (const s of steps) { - console.log(` • ${s.name}${s.description ? ` — ${s.description}` : ''}`); + console.error(` • ${s.name}${s.description ? ` — ${s.description}` : ''}`); } - console.log(''); - console.log(chalk.dim('No changes made. Re-run without --dry-run to apply.')); + console.error(''); + console.error(chalk.dim('No changes made. Re-run without --dry-run to apply.')); } export function registerInstallCommand(program: Command): void { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index f023e59..d590ea2 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2328,7 +2328,7 @@ Inspect locally: mcp .command('tools') .description('Print the registered MCP tools in human or JSON form') - .option('--tools ', 'Tool profile: default, readonly, all (default: all)', stringArg('--tools'), 'all') + .option('--tools ', 'Tool profile: "default" (13 tools), "readonly" (10), or "all" (24). Lists all when omitted', stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2337,7 +2337,7 @@ Inspect locally: mcp .command('list-tools') .description('Alias of `mcp tools`') - .option('--tools ', 'Tool profile: default, readonly, all (default: all)', stringArg('--tools'), 'all') + .option('--tools ', 'Tool profile: "default" (13 tools), "readonly" (10), or "all" (24). Lists all when omitted', stringArg('--tools'), 'all') .action((opts: { tools?: string }) => { try { printMcpToolDirectory(resolveToolProfile(opts.tools)); } catch (e) { handleError(e); } @@ -2351,7 +2351,7 @@ Inspect locally: .option('--auth-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 ', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin')) .option('--rate-limit ', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60') - .option('--tools ', 'Tool profile: default, readonly, all (default: default)', stringArg('--tools'), 'default') + .option('--tools ', 'Tool profile: "default" (13 tools), "readonly" (10), or "all" (24)', stringArg('--tools'), 'default') .addHelpText('after', ` Examples: $ switchbot mcp serve diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 50ae3de..cceda18 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -357,7 +357,7 @@ async function executePlanSteps( if (err instanceof Error && err.name === 'DryRunSignal') { out.results.push({ step: idx, type: 'command', deviceId: resolvedDeviceId, command: step.command, status: 'dry-run' }); out.summary.dryRun++; - if (!isJsonMode()) console.log(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`); + if (!isJsonMode()) console.error(` ${idx}. ◦ dry-run ${step.command} on ${resolvedDeviceId}`); continue; } const msg = err instanceof Error ? err.message : String(err); diff --git a/src/commands/policy.ts b/src/commands/policy.ts index ae6f1f7..e9d72a6 100644 --- a/src/commands/policy.ts +++ b/src/commands/policy.ts @@ -389,8 +389,8 @@ Examples: if (opts.dryRun) { if (isJsonMode()) printJson(finalPayload); else { - console.log(`• dry-run: would upgrade ${policyPath} (v${plan.fromVersion} → v${plan.toVersion})`); - console.log(` bytes: ${bytesWritten}`); + console.error(`• dry-run: would upgrade ${policyPath} (v${plan.fromVersion} → v${plan.toVersion})`); + console.error(` bytes: ${bytesWritten}`); console.log(` precheck: valid against v${target}`); } return; @@ -526,7 +526,7 @@ Reads rule YAML from stdin. Combine with 'rules suggest' for a full pipeline: if (result.written) { console.log(`✓ rule "${result.ruleName}" added to ${policyPath}`); } else { - console.log(`• dry-run: rule "${result.ruleName}" not written`); + console.error(`• dry-run: rule "${result.ruleName}" not written`); } } } catch (err) { diff --git a/src/commands/rules.ts b/src/commands/rules.ts index 32bd817..d90a688 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -893,7 +893,7 @@ function registerExplain(rules: Command): void { if (detail.hysteresis) console.log(`hysteresis: ${detail.hysteresis}`); if (detail.maxFiringsPerHour !== null) console.log(`maxFiringsPerHour: ${detail.maxFiringsPerHour}`); if (detail.suppressIfAlreadyDesired) console.log(`suppressIfAlreadyDesired: true`); - if (detail.dryRun) console.log(`dry_run: true`); + if (detail.dryRun) console.log(`dry_run: true`); // stdout-ok: rule property, not diagnostic console.log(`last fired: ${detail.lastFired ?? '(never)'}`); }); } diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index 5033a24..6b69db6 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -74,7 +74,7 @@ Example: if (isJsonMode()) { printJson({ dryRun: true, wouldSend }); } else { - console.log(`[dry-run] Would POST /v1.1/scenes/${sceneId}/execute (${found.sceneName})`); + console.error(`[dry-run] Would POST /v1.1/scenes/${sceneId}/execute (${found.sceneName})`); } return; } @@ -260,7 +260,7 @@ Example: console.log(`idempotent: unknown (scene steps not exposed by API)`); console.log(`toExecute: ${explanation.toExecute}`); if (explanation.dryRun) { - console.log(`dryRun: true (pass --dry-run to execute would be a no-op)`); + console.error(`dryRun: true (pass --dry-run to execute would be a no-op)`); } console.log(`note: ${explanation.note}`); } catch (error) { diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index 6e57353..a5ac858 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -219,14 +219,14 @@ Examples: plan: plan.map(({ action, detail }) => ({ action, detail })), }); } else { - console.log(chalk.bold('switchbot uninstall — dry run')); - console.log(` profile: ${profile}`); - console.log(` agent: ${agent}`); - console.log(''); - console.log(chalk.bold('Would run:')); - for (const p of plan) console.log(` • ${p.action} — ${p.detail}`); - console.log(''); - console.log(chalk.dim('No changes made. Re-run without --dry-run (add --yes to skip prompts).')); + console.error(chalk.bold('switchbot uninstall — dry run')); + console.error(` profile: ${profile}`); + console.error(` agent: ${agent}`); + console.error(''); + console.error(chalk.bold('Would run:')); + for (const p of plan) console.error(` • ${p.action} — ${p.detail}`); + console.error(''); + console.error(chalk.dim('No changes made. Re-run without --dry-run (add --yes to skip prompts).')); } return; } diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 4fb0bcf..8605c41 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -163,6 +163,8 @@ const STATUS_FIELD_DESCRIPTIONS: Record = { openState: 'Contact sensor open/closed', status: 'Device-specific status word', lightLevel: 'Ambient light level', + detected: 'Presence detected (boolean)', + hubDeviceId: 'Hub device identifier for hub-bound devices', }; /** @@ -305,12 +307,12 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Relay Switch 2PM', category: 'physical', - description: 'Dual-channel relay switch with per-channel on/off/toggle and optional roller-shade mode.', + description: 'Dual-channel relay switch with per-channel on/off/toggle and optional roller-shade mode. IMPORTANT: turnOn/turnOff/toggle require a channel parameter ("1" or "2") — omitting it is a usage error.', role: 'power', commands: [ - { command: 'turnOn', parameter: '1 | 2 (channel)', description: 'Turn on channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, - { command: 'turnOff', parameter: '1 | 2 (channel)', description: 'Turn off channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, - { command: 'toggle', parameter: '1 | 2 (channel)', description: 'Toggle channel 1 or 2', idempotent: false, exampleParams: ['1', '2'] }, + { command: 'turnOn', parameter: '1 | 2 (channel — required)', description: 'Turn on channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, + { command: 'turnOff', parameter: '1 | 2 (channel — required)', description: 'Turn off channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, + { command: 'toggle', parameter: '1 | 2 (channel — required)', description: 'Toggle channel 1 or 2', idempotent: false, exampleParams: ['1', '2'] }, { command: 'setMode', parameter: '";" e.g. "1;0"', description: 'Per-channel mode (see Relay Switch 1 modes)', idempotent: true, exampleParams: ['1;0', '2;3'] }, { command: 'setPosition', parameter: '0-100 (roller percentage)', description: 'Roller-shade-pair mode only', idempotent: true, exampleParams: ['0', '50', '100'] }, ], @@ -332,7 +334,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ category: 'physical', description: 'Evaporative humidifier with multiple speed/auto/sleep/humidity modes and child lock.', role: 'climate', - aliases: ['Evaporative Humidifier'], + aliases: ['Evaporative Humidifier', 'Evaporative Humidifier (Auto-refill)'], commands: [ ...onOff, { command: 'setMode', parameter: '\'{"mode":1-8,"targetHumidify":0-100}\'', description: 'mode: 1=lv4 2=lv3 3=lv2 4=lv1 5=humidity 6=sleep 7=auto 8=drying', idempotent: true, exampleParams: ['{"mode":7,"targetHumidify":50}'] }, @@ -451,7 +453,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ category: 'physical', description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.', role: 'cleaning', - aliases: ['Robot Vacuum', 'Robot Vacuum Cleaner S1 Plus', 'K10+', 'K10+ Pro'], + aliases: ['Robot Vacuum', 'S1', 'S1 Plus', 'Robot Vacuum Cleaner S1 Plus', 'K10+', 'K10+ Pro'], commands: [ { command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true }, { command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true }, @@ -465,7 +467,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ category: 'physical', description: 'Compact robot vacuum and mop combo with sweep/mop sessions, fan level, and water level.', role: 'cleaning', - aliases: ['K10+ Pro Combo', 'K20+ Pro', 'K11+', 'Robot Vacuum Cleaner K11+'], + aliases: ['K10+ Pro Combo', 'K20+ Pro', 'Robot Vacuum Cleaner K20 Plus Pro', 'K11+', 'Robot Vacuum Cleaner K11+'], commands: [ { 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}}'] }, { command: 'pause', parameter: '—', description: 'Pause cleaning', idempotent: true }, @@ -480,7 +482,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ category: 'physical', description: 'Advanced floor cleaning robot with sweep/mop modes, self-wash dock, and humidifier refill.', role: 'cleaning', - aliases: ['Robot Vacuum Cleaner S10', 'Robot Vacuum Cleaner S20', 'S20'], + aliases: ['S10', 'Robot Vacuum Cleaner S10', 'Robot Vacuum Cleaner S20', 'S20'], commands: [ { 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}}'] }, { command: 'pause', parameter: '—', description: 'Pause cleaning', idempotent: true }, @@ -595,7 +597,18 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ description: 'Battery-powered temperature and humidity sensor; read-only, no control commands.', role: 'sensor', readOnly: true, - aliases: ['Meter Plus', 'MeterPro', 'MeterPro(CO2)', 'WoIOSensor'], + aliases: [ + 'MeterPlus', + 'Meter Plus', + 'Meter Plus (JP)', + 'Meter Plus (US)', + 'Outdoor Meter', + 'MeterPro', + 'Meter Pro', + 'MeterPro(CO2)', + 'Meter Pro (CO2 Monitor)', + 'WoIOSensor', + ], commands: [], statusFields: ['temperature', 'humidity', 'battery', 'version'], }, @@ -608,6 +621,16 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ commands: [], statusFields: ['battery', 'version', 'moveDetected', 'brightness', 'openState'], }, + { + type: 'Presence Sensor', + category: 'physical', + description: 'Presence detector that reports occupancy and ambient light through a paired hub; read-only.', + role: 'sensor', + readOnly: true, + aliases: ['Human Presence Sensor', 'Prensence Sensor'], + commands: [], + statusFields: ['version', 'battery', 'lightLevel', 'detected', 'hubDeviceId'], + }, { type: 'Contact Sensor', category: 'physical', @@ -643,7 +666,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ description: 'IR hub that bridges BLE devices to the cloud and learns IR remotes; no direct control commands.', role: 'hub', readOnly: true, - aliases: ['Hub Mini2'], + aliases: ['Hub', 'Hub Plus', 'Hub Mini2'], commands: [], statusFields: ['version'], }, @@ -692,6 +715,41 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ commands: [], statusFields: ['battery', 'version'], }, + { + type: 'Indoor Cam', + category: 'physical', + description: 'Indoor security camera; listed by the cloud API but exposes no cloud status or control commands.', + role: 'security', + readOnly: true, + commands: [], + }, + { + type: 'Pan/Tilt Cam', + category: 'physical', + description: 'Pan/tilt indoor security camera; listed by the cloud API but exposes no cloud status or control commands.', + role: 'security', + readOnly: true, + aliases: ['Pan/Tilt Cam 2K'], + commands: [], + }, + { + type: 'Pan/Tilt Cam Plus 3K', + category: 'physical', + description: 'Pan/tilt indoor security camera; listed by the cloud API but exposes no cloud status or control commands.', + role: 'security', + readOnly: true, + aliases: ['Pan/Tilt Cam Plus 2K', 'Pan/Tilt Cam Plus', 'Pan Tilt Cam Plus 3K'], + commands: [], + }, + { + type: 'Remote', + category: 'physical', + description: 'Bluetooth wireless button remote paired to a hub or device; listed by the cloud API but exposes no cloud status or control commands.', + role: 'other', + readOnly: true, + aliases: ['SwitchBot Remote', 'Remote Button', 'Wireless Remote'], + commands: [], + }, // ---------- Virtual IR remotes ---------- { diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index 1d7af40..913f103 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -400,15 +400,15 @@ function validateSetBrightness(raw: string | undefined, deviceType: string): Val error: `setBrightness requires an integer ${min}-${max} (percent). Example: "50".`, }; } - const trimmed = stripQuotes(raw.trim()); - if (!/^-?\d+$/.test(trimmed)) { + const parsed = parseStrictInt(raw); + if (!parsed.ok) { return { ok: false, error: `setBrightness must be an integer ${min}-${max}, got ${JSON.stringify(raw)}. ${hintBrightnessRetry(min, max)}`, }; } - const n = Number(trimmed); - if (!Number.isInteger(n) || n < min || n > max) { + const n = parsed.value; + if (n < min || n > max) { return { ok: false, error: `setBrightness must be an integer ${min}-${max}, got "${raw}". ${hintBrightnessRetry(min, max)}`, @@ -492,7 +492,7 @@ function validateSetColor(raw: string | undefined): ValidateResult { error: `setColor component "${p}" is not an integer. ${hintColorRetry()}`, }; } - const n = Number(p); + const n = Number(p); // number-ok: regex-guarded above if (!Number.isInteger(n) || n < 0 || n > 255) { return { ok: false, @@ -515,15 +515,15 @@ function validateSetColorTemperature(raw: string | undefined): ValidateResult { error: `setColorTemperature requires an integer Kelvin value 2700-6500. Example: "4000".`, }; } - const trimmed = stripQuotes(raw.trim()); - if (!/^-?\d+$/.test(trimmed)) { + const parsed = parseStrictInt(raw); + if (!parsed.ok) { return { ok: false, error: `setColorTemperature must be an integer 2700-6500, got ${JSON.stringify(raw)}.`, }; } - const n = Number(trimmed); - if (!Number.isInteger(n) || n < 2700 || n > 6500) { + const n = parsed.value; + if (n < 2700 || n > 6500) { return { ok: false, error: `setColorTemperature must be an integer 2700-6500, got "${raw}".`, @@ -555,27 +555,30 @@ function validateAcSetAll(raw: string | undefined): ValidateResult { } const [tempStr, modeStr, fanStr, powerStr] = parts.map((s) => s.trim()); - const temp = Number(tempStr); - if (!Number.isInteger(temp) || temp < 16 || temp > 30) { + const tempP = parseStrictInt(tempStr); + if (!tempP.ok || tempP.value < 16 || tempP.value > 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) { + const temp = tempP.value; + const modeP = parseStrictInt(modeStr); + if (!modeP.ok || modeP.value < 1 || modeP.value > 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) { + const mode = modeP.value; + const fanP = parseStrictInt(fanStr); + if (!fanP.ok || fanP.value < 1 || fanP.value > 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 fan = fanP.value; const power = powerStr.toLowerCase(); if (power !== 'on' && power !== 'off') { return { @@ -595,14 +598,14 @@ function validateCurtainSetPosition(raw: string | undefined): ValidateResult { } const stripped = stripQuotes(raw.trim()); if (!stripped.includes(',')) { - const pos = Number(stripped); - if (!Number.isInteger(pos) || pos < 0 || pos > 100) { + const posP = parseStrictInt(stripped); + if (!posP.ok || posP.value < 0 || posP.value > 100) { return { ok: false, error: `setPosition must be an integer 0-100, got "${raw}". Example: "50".`, }; } - return { ok: true, normalized: String(pos) }; + return { ok: true, normalized: String(posP.value) }; } const parts = stripped.split(',').map((s) => s.trim()); if (parts.length !== 3) { @@ -612,13 +615,14 @@ function validateCurtainSetPosition(raw: string | undefined): ValidateResult { }; } const [idxStr, modeStr, posStr] = parts; - const idx = Number(idxStr); - if (!Number.isInteger(idx) || idx < 0) { + const idxP = parseStrictInt(idxStr); + if (!idxP.ok || idxP.value < 0) { return { ok: false, error: `setPosition field 1 (index) must be a non-negative integer, got "${idxStr}".`, }; } + const idx = idxP.value; const modeLower = modeStr.toLowerCase(); if (!['ff', '0', '1'].includes(modeLower)) { return { @@ -626,13 +630,14 @@ function validateCurtainSetPosition(raw: string | undefined): ValidateResult { 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) { + const posP = parseStrictInt(posStr); + if (!posP.ok || posP.value < 0 || posP.value > 100) { return { ok: false, error: `setPosition field 3 (position) must be an integer 0-100, got "${posStr}".`, }; } + const pos = posP.value; return { ok: true, normalized: `${idx},${modeLower},${pos}` }; } @@ -658,13 +663,14 @@ function validateBlindTiltSetPosition(raw: string | undefined): ValidateResult { 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) { + const angleP = parseStrictInt(parts[1]); + if (!angleP.ok || angleP.value < 0 || angleP.value > 100) { return { ok: false, error: `Blind Tilt setPosition angle must be an integer 0-100, got "${parts[1]}".`, }; } + const angle = angleP.value; if (angle % 2 !== 0) { return { ok: false, @@ -689,21 +695,22 @@ function validateRelay2PmSetMode(raw: string | undefined): ValidateResult { error: `Relay Switch setMode expects ";", got ${JSON.stringify(raw)}. Example: "1;1".`, }; } - const ch = Number(parts[0]); - if (ch !== 1 && ch !== 2) { + const chP = parseStrictInt(parts[0]); + if (!chP.ok || (chP.value !== 1 && chP.value !== 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) { + const ch = chP.value; + const modeP = parseStrictInt(parts[1]); + if (!modeP.ok || modeP.value < 0 || modeP.value > 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}` }; + return { ok: true, normalized: `${ch};${modeP.value}` }; } // ---- Relay Switch 2PM channel (turnOn/turnOff/toggle) ----------------------- @@ -739,6 +746,12 @@ function stripQuotes(s: string): string { return s; } +function parseStrictInt(raw: string): { ok: true; value: number } | { ok: false } { + const cleaned = stripQuotes(raw); + if (!/^[+-]?(?:0|[1-9]\d*)$/.test(cleaned)) return { ok: false }; + return { ok: true, value: Number(cleaned) }; // number-ok: regex-validated above +} + function validateIntRange( raw: string | undefined, command: string, @@ -752,14 +765,14 @@ function validateIntRange( error: `${command} requires an integer ${min}-${max} (${label}). Example: "${Math.round((min + max) / 2)}".`, }; } - const trimmed = stripQuotes(raw.trim()); - if (!/^-?\d+$/.test(trimmed)) { + const parsed = parseStrictInt(raw); + if (!parsed.ok) { return { ok: false, error: `${command} must be an integer ${min}-${max} (${label}), got ${JSON.stringify(raw)}.`, }; } - const n = Number(trimmed); + const n = parsed.value; if (n < min || n > max) { return { ok: false, @@ -805,7 +818,7 @@ function validateHumidifierSetMode(raw: string | undefined): ValidateResult { if (trimmed === 'auto') return { ok: true, normalized: 'auto' }; if (['101', '102', '103'].includes(trimmed)) return { ok: true, normalized: trimmed }; if (/^\d+$/.test(trimmed)) { - const n = Number(trimmed); + const n = Number(trimmed); // number-ok: regex-guarded above if (n >= 0 && n <= 100) return { ok: true, normalized: String(n) }; } return { @@ -835,14 +848,14 @@ function validateHumidifier2SetMode(raw: string | undefined): ValidateResult { if (!isNumericish(o.mode)) { return { ok: false, error: `Humidifier2 setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` }; } - const mode = Number(o.mode); + const mode = Number(o.mode); // number-ok: isNumericish-guarded if (!Number.isInteger(mode) || mode < 1 || mode > 8) { return { ok: false, error: `Humidifier2 setMode "mode" must be 1-8, got ${JSON.stringify(o.mode)}.` }; } if (!isNumericish(o.targetHumidify)) { return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be a number or numeric string, got ${JSON.stringify(o.targetHumidify)}.` }; } - const hum = Number(o.targetHumidify); + const hum = Number(o.targetHumidify); // number-ok: isNumericish-guarded if (!Number.isInteger(hum) || hum < 0 || hum > 100) { return { ok: false, error: `Humidifier2 setMode "targetHumidify" must be 0-100, got ${JSON.stringify(o.targetHumidify)}.` }; } @@ -872,7 +885,7 @@ function validateAirPurifierSetMode(raw: string | undefined): ValidateResult { if (!isNumericish(o.mode)) { return { ok: false, error: `Air Purifier setMode "mode" must be a number or numeric string, got ${JSON.stringify(o.mode)}.` }; } - const mode = Number(o.mode); + const mode = Number(o.mode); // number-ok: isNumericish-guarded if (!Number.isInteger(mode) || mode < 1 || mode > 4) { 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)}.` }; } @@ -884,7 +897,7 @@ function validateAirPurifierSetMode(raw: string | undefined): ValidateResult { if (!isNumericish(o.fanGear)) { return { ok: false, error: `Air Purifier setMode "fanGear" must be a number or numeric string, got ${JSON.stringify(o.fanGear)}.` }; } - const fg = Number(o.fanGear); + const fg = Number(o.fanGear); // number-ok: isNumericish-guarded if (!Number.isInteger(fg) || fg < 1 || fg > 3) { return { ok: false, error: `Air Purifier setMode "fanGear" must be 1-3, got ${JSON.stringify(o.fanGear)}.` }; } @@ -931,7 +944,7 @@ function validateVacuumStartClean(raw: string | undefined, deviceType: string): if (!isNumericish(p.fanLevel)) { return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be a number or numeric string, got ${JSON.stringify(p.fanLevel)}.` }; } - const fl = Number(p.fanLevel); + const fl = Number(p.fanLevel); // number-ok: isNumericish-guarded if (!Number.isInteger(fl) || fl < 1 || fl > 4) { return { ok: false, error: `${deviceType} startClean "param.fanLevel" must be 1-4, got ${JSON.stringify(p.fanLevel)}.` }; } @@ -944,7 +957,7 @@ function validateVacuumStartClean(raw: string | undefined, deviceType: string): if (!isNumericish(p.waterLevel)) { return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be a number or numeric string, got ${JSON.stringify(p.waterLevel)}.` }; } - const wl = Number(p.waterLevel); + const wl = Number(p.waterLevel); // number-ok: isNumericish-guarded if (!Number.isInteger(wl) || wl < 1 || wl > 2) { return { ok: false, error: `${deviceType} startClean "param.waterLevel" must be 1-2, got ${JSON.stringify(p.waterLevel)}.` }; } @@ -954,7 +967,7 @@ function validateVacuumStartClean(raw: string | undefined, deviceType: string): if (!isNumericish(p.times)) { return { ok: false, error: `${deviceType} startClean "param.times" must be a number or numeric string, got ${JSON.stringify(p.times)}.` }; } - const t = Number(p.times); + const t = Number(p.times); // number-ok: isNumericish-guarded if (!Number.isInteger(t) || t < 1 || t > 2639999) { return { ok: false, error: `${deviceType} startClean "param.times" must be an integer 1-2639999, got ${JSON.stringify(p.times)}.` }; } @@ -988,7 +1001,7 @@ function validateVacuumChangeParam(raw: string | undefined, deviceType: string): if (!isNumericish(p.fanLevel)) { return { ok: false, error: `changeParam "fanLevel" must be a number or numeric string, got ${JSON.stringify(p.fanLevel)}.` }; } - const fl = Number(p.fanLevel); + const fl = Number(p.fanLevel); // number-ok: isNumericish-guarded if (!Number.isInteger(fl) || fl < 1 || fl > 4) { return { ok: false, error: `changeParam "fanLevel" must be 1-4, got ${JSON.stringify(p.fanLevel)}.` }; } @@ -1001,7 +1014,7 @@ function validateVacuumChangeParam(raw: string | undefined, deviceType: string): if (!isNumericish(p.waterLevel)) { return { ok: false, error: `changeParam "waterLevel" must be a number or numeric string, got ${JSON.stringify(p.waterLevel)}.` }; } - const wl = Number(p.waterLevel); + const wl = Number(p.waterLevel); // number-ok: isNumericish-guarded if (!Number.isInteger(wl) || wl < 1 || wl > 2) { return { ok: false, error: `changeParam "waterLevel" must be 1-2, got ${JSON.stringify(p.waterLevel)}.` }; } @@ -1011,7 +1024,7 @@ function validateVacuumChangeParam(raw: string | undefined, deviceType: string): if (!isNumericish(p.times)) { return { ok: false, error: `changeParam "times" must be a number or numeric string, got ${JSON.stringify(p.times)}.` }; } - const t = Number(p.times); + const t = Number(p.times); // number-ok: isNumericish-guarded if (!Number.isInteger(t) || t < 1 || t > 2639999) { return { ok: false, error: `changeParam "times" must be an integer 1-2639999, got ${JSON.stringify(p.times)}.` }; } diff --git a/src/index.ts b/src/index.ts index d10303c..65f9ade 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) { } const program = new Command(); +program.allowExcessArguments(false); if (isJsonMode()) { // In --json mode, commander writes plain-text usage errors by default. // Silence that channel and emit a single structured error in the catch block. diff --git a/src/utils/health.ts b/src/utils/health.ts index f902d3f..98a7ba0 100644 --- a/src/utils/health.ts +++ b/src/utils/health.ts @@ -110,7 +110,7 @@ export function getHealthReport(auditPath = DEFAULT_AUDIT_PATH): HealthReport { const breakdown: Record = {}; let expectedErrors = 0; for (const e of errorEntries) { - const code = e.statusCode !== undefined ? String(e.statusCode) : 'unknown'; + const code = e.statusCode !== undefined ? String(e.statusCode) : 'client'; breakdown[code] = (breakdown[code] ?? 0) + 1; if (e.statusCode !== undefined && EXPECTED_ERROR_CODES.has(e.statusCode)) { expectedErrors++; diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 917740b..545385b 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -642,12 +642,13 @@ describe('devices batch', () => { // Only BOT1 reaches the post path; BOT2 was pre-filtered. expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); const out = result.stdout.join('\n'); - expect(out).toContain('Planned (dry-run): 1 device(s)'); - expect(out).toContain('- BOT1'); + const err = result.stderr.join('\n'); + expect(err).toContain('Planned (dry-run): 1 device(s)'); + expect(err).toContain('- BOT1'); expect(out).toContain('Skipped (offline): 1 device(s)'); expect(out).toContain('- BOT2'); // BOT2 must never show up in a dry-run POST log. - expect(out).not.toMatch(/Would POST.*BOT2/); + expect(err).not.toMatch(/Would POST.*BOT2/); // Summary line now includes both "planned" and "skipped_offline" counts. expect(out).toMatch(/Summary:.*1 planned.*1 skipped_offline/); }); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index beed1b6..8c29faa 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -1740,6 +1740,24 @@ describe('devices command', () => { expect(out).toContain('moveDetected'); }); + it('recognizes official GitHub-documented device types observed in the real account', async () => { + const cases = [ + ['Pan/Tilt Cam Plus 3K', 'Pan/Tilt Cam Plus'], + ['Presence Sensor', 'detected'], + ['Remote', 'Wireless Remote'], + ]; + + for (const [type, expected] of cases) { + const res = await runCli(registerDevicesCommand, ['devices', 'commands', type]); + const out = res.stdout.join('\n'); + expect(res.exitCode, type).toBeNull(); + expect(res.stderr.join('\n'), type).toBe(''); + expect(out).toContain(type); + expect(out).toContain('Commands: (none'); + expect(out).toContain(expected); + } + }); + it('--json mode outputs the catalog entry as JSON', async () => { const res = await runCli(registerDevicesCommand, ['devices', 'commands', 'Bot', '--json']); const out = res.stdout.join('\n'); @@ -2674,15 +2692,15 @@ describe('devices command', () => { expect(parsed.data.wouldSend.commandType).toBe('command'); }); - it('emits human-readable dry-run message to stdout when --dry-run (no --json)', async () => { + it('emits human-readable dry-run message to stderr when --dry-run (no --json)', async () => { const res = await runCli(registerDevicesCommand, [ '--dry-run', 'devices', 'command', DRY_ID, 'turnOn', ]); expect(res.exitCode).toBeNull(); - const out = res.stdout.join('\n'); - expect(out).toMatch(/dry-run/i); - expect(out).toContain(DRY_ID); - expect(out).not.toMatch(/Would POST/i); + const err = res.stderr.join('\n'); + expect(err).toMatch(/dry-run/i); + expect(err).toContain(DRY_ID); + expect(err).not.toMatch(/Would POST/i); }); }); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index bc0e357..daf76dd 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -717,6 +717,47 @@ describe('doctor command', () => { }); }); + it('catalog-coverage accepts real account types discovered in deviceList', async () => { + updateCacheFromDeviceList({ + deviceList: [ + { + deviceId: 'CAM-1', + deviceName: 'Office Camera', + deviceType: 'Pan/Tilt Cam Plus 3K', + controlType: 'Cameras', + enableCloudService: true, + }, + { + deviceId: 'REMOTE-1', + deviceName: 'Desk Remote', + deviceType: 'Remote', + controlType: 'Wireless Remote', + enableCloudService: true, + }, + { + deviceId: 'PRESENCE-1', + deviceName: 'Presence', + deviceType: 'Presence Sensor', + controlType: 'Motion Sensor', + enableCloudService: true, + }, + ], + infraredRemoteList: [ + { + deviceId: 'IR-AC', + deviceName: 'AC', + remoteType: 'Air Conditioner', + }, + ], + }); + + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'catalog-coverage']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const coverage = payload.data.checks.find((c: { name: string }) => c.name === 'catalog-coverage'); + expect(coverage.status).toBe('ok'); + expect(coverage.detail).toContain('all 4 device types have catalog entries'); + }); + it('release-notes check is ok when RELEASE_METADATA carries no breaking notice for the current release', async () => { // The release-notes check is a contract between doctor and // src/version-notes.ts RELEASE_METADATA. When no entry exists for diff --git a/tests/commands/health-check.test.ts b/tests/commands/health-check.test.ts index 3c0711f..966dbd1 100644 --- a/tests/commands/health-check.test.ts +++ b/tests/commands/health-check.test.ts @@ -150,7 +150,7 @@ describe('audit health layering', () => { expectedErrors: 0, unexpectedErrors: 4, unexpectedRatePercent: 40, - breakdown: { 'unknown': 4 }, + breakdown: { 'client': 4 }, status: 'warn', }, }; diff --git a/tests/commands/install.test.ts b/tests/commands/install.test.ts index 4d0dbd7..c002180 100644 --- a/tests/commands/install.test.ts +++ b/tests/commands/install.test.ts @@ -34,14 +34,14 @@ describe('switchbot install (dry-run smoke)', () => { }); it('--dry-run prints the step list without mutating anything', () => { - const { code, stdout } = runCli(['install', '--dry-run', '--agent', 'none']); + const { code, stderr } = runCli(['install', '--dry-run', '--agent', 'none']); expect(code).toBe(0); - expect(stdout).toContain('switchbot install — dry run'); - expect(stdout).toContain('prompt-credentials'); - expect(stdout).toContain('write-keychain'); - expect(stdout).toContain('scaffold-policy'); - expect(stdout).toContain('symlink-skill'); - expect(stdout).toContain('No changes made'); + expect(stderr).toContain('switchbot install — dry run'); + expect(stderr).toContain('prompt-credentials'); + expect(stderr).toContain('write-keychain'); + expect(stderr).toContain('scaffold-policy'); + expect(stderr).toContain('symlink-skill'); + expect(stderr).toContain('No changes made'); }); it('--dry-run --json emits a structured preview', () => { diff --git a/tests/commands/scenes.test.ts b/tests/commands/scenes.test.ts index 8ae5b51..894499f 100644 --- a/tests/commands/scenes.test.ts +++ b/tests/commands/scenes.test.ts @@ -176,14 +176,14 @@ describe('scenes command', () => { expect(parsed.data.wouldSend.sceneName).toBe('Morning'); }); - it('--dry-run plaintext prints Would POST on stdout (bug #54)', async () => { + it('--dry-run plaintext prints Would POST on stderr (bug #54)', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: [{ sceneId: 'SCENE-1', sceneName: 'Morning' }] }, }); const res = await runCli(registerScenesCommand, ['scenes', 'execute', 'SCENE-1', '--dry-run']); expect(res.exitCode).toBeNull(); expect(apiMock.__instance.post).not.toHaveBeenCalled(); - const out = res.stdout.join('\n'); + const out = res.stderr.join('\n'); expect(out).toContain('[dry-run]'); expect(out).toContain('SCENE-1'); }); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts index 0b310f3..2f74731 100644 --- a/tests/commands/schema.test.ts +++ b/tests/commands/schema.test.ts @@ -128,6 +128,30 @@ describe('schema export', () => { expect(hub3.role).toBe('hub'); expect(hub3.statusFields).toEqual(['version', 'temperature', 'humidity', 'lightLevel']); }); + + it('exports official GitHub-documented device types observed in the real account', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--types', 'Pan/Tilt Cam Plus 3K,Presence Sensor,Remote']); + const parsed = JSON.parse(res.stdout.join('')).data; + const byType = new Map(parsed.types.map((t: { type: string }) => [t.type, t])); + + expect([...byType.keys()].sort()).toEqual(['Pan/Tilt Cam Plus 3K', 'Presence Sensor', 'Remote']); + expect(byType.get('Pan/Tilt Cam Plus 3K')).toMatchObject({ + role: 'security', + readOnly: true, + commands: [], + }); + expect(byType.get('Remote')).toMatchObject({ + role: 'other', + readOnly: true, + commands: [], + }); + expect(byType.get('Presence Sensor')).toMatchObject({ + role: 'sensor', + readOnly: true, + commands: [], + statusFields: ['version', 'battery', 'lightLevel', 'detected', 'hubDeviceId'], + }); + }); }); describe('schema export B3 slim flags', () => { diff --git a/tests/commands/uninstall.test.ts b/tests/commands/uninstall.test.ts index 9bfcf7c..3c6538c 100644 --- a/tests/commands/uninstall.test.ts +++ b/tests/commands/uninstall.test.ts @@ -25,12 +25,12 @@ describe('switchbot uninstall (dry-run smoke)', () => { }); it('--dry-run lists the planned removals without mutating anything', () => { - const { code, stdout } = runCli(['--dry-run', 'uninstall', '--agent', 'none']); + const { code, stderr } = runCli(['--dry-run', 'uninstall', '--agent', 'none']); expect(code).toBe(0); - expect(stdout).toContain('switchbot uninstall — dry run'); - expect(stdout).toContain('remove-credentials'); - expect(stdout).toContain('remove-policy'); - expect(stdout).toContain('No changes made'); + expect(stderr).toContain('switchbot uninstall — dry run'); + expect(stderr).toContain('remove-credentials'); + expect(stderr).toContain('remove-policy'); + expect(stderr).toContain('No changes made'); }); it('--dry-run --json emits a structured plan including skill link for claude-code', () => { diff --git a/tests/contract/format-matrix.test.ts b/tests/contract/format-matrix.test.ts new file mode 100644 index 0000000..18da70a --- /dev/null +++ b/tests/contract/format-matrix.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import type { HealthReport } from '../../src/utils/health.js'; +import { runCli } from '../helpers/cli.js'; + +const healthMock = vi.hoisted(() => ({ + getHealthReport: vi.fn<[], HealthReport>(), + toPrometheusText: vi.fn(() => ''), +})); +vi.mock('../../src/utils/health.js', () => healthMock); + +import { registerHealthCommand } from '../../src/commands/health.js'; +import { registerCatalogCommand } from '../../src/commands/catalog.js'; +import { resetCatalogOverlayCache } from '../../src/devices/catalog.js'; + +const OK_REPORT: HealthReport = { + generatedAt: '2026-05-15T00:00:00.000Z', + overall: 'ok', + process: { pid: 1, uptimeSeconds: 1, platform: 'linux', nodeVersion: 'v20.0.0', memoryMb: 50 }, + quota: { used: 0, limit: 10000, percentUsed: 0, remaining: 10000, status: 'ok' }, + audit: { present: false, recentErrors: 0, recentTotal: 0, errorRatePercent: 0, expectedErrors: 0, unexpectedErrors: 0, unexpectedRatePercent: 0, breakdown: {}, status: 'ok' }, + circuit: { name: 'switchbot-api', state: 'closed', failures: 0, status: 'ok' }, +}; + +const FORMATS = ['json', 'jsonl', 'tsv', 'yaml', 'markdown'] as const; + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'switchbot-fmt-matrix-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpRoot); + healthMock.getHealthReport.mockReset().mockReturnValue(OK_REPORT); + resetCatalogOverlayCache(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + resetCatalogOverlayCache(); + try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ } +}); + +function isValidJson(s: string): boolean { + try { JSON.parse(s); return true; } catch { return false; } +} + +function isValidJsonl(s: string): boolean { + const lines = s.split('\n').filter(Boolean); + return lines.length > 0 && lines.every((l) => isValidJson(l)); +} + +function isValidTsv(s: string): boolean { + const lines = s.split('\n').filter(Boolean); + if (lines.length < 2) return false; + const cols = lines[0].split('\t').length; + return lines.every((l) => l.split('\t').length === cols); +} + +function isValidYaml(s: string): boolean { + return s.includes('---') || s.includes(':'); +} + +function assertValidFormat(stdout: string, format: string): void { + if (!stdout.trim()) return; + switch (format) { + case 'json': expect(isValidJson(stdout)).toBe(true); break; + case 'jsonl': expect(isValidJsonl(stdout)).toBe(true); break; + case 'tsv': expect(isValidTsv(stdout)).toBe(true); break; + case 'yaml': expect(isValidYaml(stdout)).toBe(true); break; + case 'markdown': expect(stdout).toMatch(/[|\-]/); break; + } +} + +describe('format matrix contract', () => { + describe('health check', () => { + for (const fmt of FORMATS) { + it(`--format ${fmt} produces valid ${fmt} output`, async () => { + const res = await runCli(registerHealthCommand, ['health', 'check', '--format', fmt]); + expect(res.exitCode).toBeNull(); + const stdout = res.stdout.join('\n'); + assertValidFormat(stdout, fmt); + expect(stdout).not.toMatch(/◦.*dry-run/); + }); + } + }); + + describe('catalog list', () => { + for (const fmt of FORMATS) { + it(`--format ${fmt} produces valid ${fmt} output`, async () => { + const res = await runCli(registerCatalogCommand, ['catalog', 'list', '--format', fmt]); + expect(res.exitCode).toBeNull(); + const stdout = res.stdout.join('\n'); + assertValidFormat(stdout, fmt); + }); + } + }); + + describe('stdout cleanliness', () => { + for (const fmt of FORMATS) { + it(`no dry-run text in stdout for --format ${fmt}`, async () => { + const res = await runCli(registerCatalogCommand, ['catalog', 'list', '--format', fmt]); + const stdout = res.stdout.join('\n'); + expect(stdout).not.toMatch(/dry-run|◦ dry/); + }); + } + }); +}); diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index 6662fb4..57c2c07 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -11,6 +11,166 @@ import { type SafetyTier, } from '../../src/devices/catalog.js'; +// Snapshot from OpenWonderLabs/SwitchBotAPI README.md: +// "device type. *...*" fields in the device list / status sections. +// Keep this list in sync when upstream documents new API deviceType strings. +const OFFICIAL_API_DEVICE_TYPES = [ + 'AI Art Frame', + 'AI Hub', + 'Air Purifier PM2.5', + 'Air Purifier Table PM2.5', + 'Air Purifier Table VOC', + 'Air Purifier VOC', + 'Battery Circulator Fan', + 'Blind Tilt', + 'Bot', + 'Candle Warmer Lamp', + 'Ceiling Light', + 'Ceiling Light Pro', + 'Circulator Fan', + 'Color Bulb', + 'Contact Sensor', + 'Curtain', + 'Curtain3', + 'Floor Lamp', + 'Garage Door Opener', + 'Home Climate Panel', + 'Hub', + 'Hub 2', + 'Hub 3', + 'Hub Mini', + 'Hub Plus', + 'Humidifier', + 'Humidifier2', + 'Indoor Cam', + 'K10+', + 'K10+ Pro', + 'Keypad', + 'Keypad Touch', + 'Keypad Vision', + 'Keypad Vision Pro', + 'Lock Lite', + 'Lock Ultra', + 'Meter', + 'MeterPlus', + 'MeterPro', + 'MeterPro(CO2)', + 'Motion Sensor', + 'Pan/Tilt Cam', + 'Pan/Tilt Cam 2K', + 'Pan/Tilt Cam Plus 2K', + 'Pan/Tilt Cam Plus 3K', + 'Plug', + 'Plug Mini (EU)', + 'Plug Mini (JP)', + 'Plug Mini (US)', + 'Presence Sensor', + 'RGBIC Neon Rope Light', + 'RGBIC Neon Wire Rope Light', + 'RGBICWW Floor Lamp', + 'RGBICWW Strip Light', + 'Relay Switch 1', + 'Relay Switch 1PM', + 'Relay Switch 2PM', + 'Remote', + 'Robot Vacuum Cleaner K10+ Pro Combo', + 'Robot Vacuum Cleaner K11+', + 'Robot Vacuum Cleaner K20 Plus Pro', + 'Robot Vacuum Cleaner S1', + 'Robot Vacuum Cleaner S1 Plus', + 'Robot Vacuum Cleaner S10', + 'Robot Vacuum Cleaner S20', + 'Roller Shade', + 'Smart Lock', + 'Smart Lock Pro', + 'Smart Lock Ultra', + 'Smart Radiator Thermostat', + 'Standing Circulator Fan', + 'Strip Light', + 'Strip Light 3', + 'Video Doorbell', + 'Water Detector', + 'WoIOSensor', +] as const; + +// Snapshot from the same README's "Supported Device List" table. Some names +// are product-table aliases rather than raw API deviceType strings. +const OFFICIAL_SUPPORTED_DEVICE_LIST_NAMES = [ + 'Hub Mini', + 'Hub Plus', + 'Hub 2', + 'Hub 3', + 'Bot', + 'Curtain', + 'Curtain 3', + 'Plug', + 'Meter', + 'Meter Plus (JP)', + 'Meter Plus (US)', + 'Outdoor Meter', + 'Meter Pro', + 'Meter Pro (CO2 Monitor)', + 'Motion Sensor', + 'Contact Sensor', + 'Prensence Sensor', + 'Water Leak Detector', + 'Color Bulb', + 'Strip Light', + 'Plug Mini (US)', + 'Plug Mini (JP)', + 'Plug Mini (EU)', + 'Lock', + 'Lock Pro', + 'Keypad', + 'Keypad Touch', + 'S1', + 'S1 Plus', + 'K10+', + 'K10+ Pro', + 'S10', + 'S20', + 'K10+ Pro Combo', + 'K20+ Pro', + 'Ceiling Light', + 'Ceiling Light Pro', + 'RGBICWW Strip Light', + 'RGBICWW Floor Lamp', + 'RGBIC Neon Rope Light', + 'RGBIC Neon Wire Rope Light', + 'Indoor Cam', + 'Pan/Tilt Cam', + 'Pan/Tilt Cam 2K', + 'Blind Tilt', + 'Battery Circulator Fan', + 'Circulator Fan', + 'Evaporative Humidifier', + 'Evaporative Humidifier (Auto-refill)', + 'Air Purifier PM2.5', + 'Air Purifier Table PM2.5', + 'Air Purifier VOC', + 'Air Purifier Table VOC', + 'Roller Shade', + 'Relay Switch 1PM', + 'Relay Switch 1', + 'Relay Switch 2PM', + 'Garage Door Opener', + 'Floor Lamp', + 'Strip Light 3', + 'Lock Lite', + 'Video Doorbell', + 'Keypad Vision', + 'Keypad Vision Pro', + 'Lock Ultra', + 'Standing Circulator Fan', + 'Pan/Tilt Cam Plus 2K', + 'Pan/Tilt Cam Plus 3K', + 'AI Hub', + 'Candle Warmer Lamp', + 'Home Climate Panel', + 'Smart Radiator Thermostat', + 'AI Art Frame', +] as const; + describe('devices/catalog', () => { describe('schema integrity', () => { it('every entry has a type, category, and commands array', () => { @@ -64,6 +224,22 @@ describe('devices/catalog', () => { } } }); + + it('resolves every official GitHub-documented API deviceType', () => { + for (const type of OFFICIAL_API_DEVICE_TYPES) { + const match = findCatalogEntry(type); + expect(match, `${type} is missing from catalog type/aliases`).not.toBeNull(); + expect(Array.isArray(match), `${type} should resolve to one catalog entry`).toBe(false); + } + }); + + it('resolves every official GitHub Supported Device List name', () => { + for (const type of OFFICIAL_SUPPORTED_DEVICE_LIST_NAMES) { + const match = findCatalogEntry(type); + expect(match, `${type} is missing from catalog type/aliases`).not.toBeNull(); + expect(Array.isArray(match), `${type} should resolve to one catalog entry`).toBe(false); + } + }); }); describe('command annotations', () => { @@ -216,15 +392,48 @@ describe('devices/catalog', () => { expect(security).toContain('Garage Door Opener'); expect(security).toContain('Keypad'); expect(security).toContain('Video Doorbell'); + expect(security).toContain('Pan/Tilt Cam Plus 3K'); }); it('assigns sensor role + readOnly to Meter / Motion Sensor / Contact Sensor', () => { - for (const t of ['Meter', 'Motion Sensor', 'Contact Sensor', 'Water Leak Detector']) { + for (const t of ['Meter', 'Motion Sensor', 'Presence Sensor', 'Contact Sensor', 'Water Leak Detector']) { const entry = DEVICE_CATALOG.find((e) => e.type === t); expect(entry?.role).toBe('sensor'); expect(entry?.readOnly).toBe(true); } }); + + it('covers official GitHub-documented device types observed in the real account', () => { + const camera = DEVICE_CATALOG.find((e) => e.type === 'Pan/Tilt Cam Plus 3K'); + const remote = DEVICE_CATALOG.find((e) => e.type === 'Remote'); + const presence = DEVICE_CATALOG.find((e) => e.type === 'Presence Sensor'); + + expect(camera).toMatchObject({ + category: 'physical', + role: 'security', + readOnly: true, + commands: [], + }); + expect(camera?.statusFields).toBeUndefined(); + expect(camera?.aliases).toContain('Pan/Tilt Cam Plus'); + + expect(remote).toMatchObject({ + category: 'physical', + role: 'other', + readOnly: true, + commands: [], + }); + expect(remote?.statusFields).toBeUndefined(); + expect(remote?.aliases).toContain('Wireless Remote'); + + expect(presence).toMatchObject({ + category: 'physical', + role: 'sensor', + readOnly: true, + commands: [], + statusFields: ['version', 'battery', 'lightLevel', 'detected', 'hubDeviceId'], + }); + }); }); describe('suggestedActions', () => {