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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,7 +894,7 @@ Queries the npm registry for the latest published version and compares it agains

```json
{
"current": "3.3.0",
"current": "3.3.1",
"latest": "4.0.0",
"upToDate": false,
"updateAvailable": true,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "3.3.0",
"version": "3.3.1",
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
"keywords": [
"switchbot",
Expand Down Expand Up @@ -48,6 +48,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"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",
"prepublishOnly": "npm test && npm run build && npm run smoke:pack-install"
Expand Down
2 changes: 0 additions & 2 deletions src/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,6 @@ Examples:
: undefined,
}));
const planDoc = {
schemaVersion: '1.1',
dryRun: true,
plan: {
command: cmd,
Expand Down Expand Up @@ -503,7 +502,6 @@ Examples:
skipped: dryRunned.length + preSkipped.length,
durationMs: Date.now() - startedAt,
unverifiableCount: succeeded.filter((s) => getCachedDevice(s.deviceId)?.category === 'ir').length,
schemaVersion: '1.1',
maxConcurrent: concurrency,
staggerMs,
...(dryRun ? { dryRun: true } : {}),
Expand Down
42 changes: 39 additions & 3 deletions src/commands/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ const EXPAND_HINTS: Record<string, { command: string; flags: string }> = {
'Relay Switch 2PM': { command: 'setMode', flags: '--channel 1 --mode edge' },
};

function annotateStatusPayload(deviceId: string, body: Record<string, unknown>): Record<string, unknown> {
const annotated = { ...body };
if (Object.keys(body).length === 0) {
annotated.supported = false;
annotated.note = 'this device does not expose cloud status';
return annotated;
}

const cached = getCachedDevice(deviceId);
const looksLikeMeter = cached?.type?.toLowerCase().includes('meter') ?? false;
const staleZeroReading =
looksLikeMeter &&
!Object.prototype.hasOwnProperty.call(body, 'onlineStatus') &&
body.battery === 0 &&
body.temperature === 0 &&
body.humidity === 0;

if (staleZeroReading) {
annotated.hint = 'readings look stale; check batteries or hub connectivity';
}

return annotated;
}

export function registerDevicesCommand(program: Command): void {
const COMMAND_TYPES = ['command', 'customize'] as const;
const devices = program
Expand Down Expand Up @@ -303,7 +327,7 @@ Examples:
const fetchedAt = new Date().toISOString();
const batch = results.map((r, i) =>
r.status === 'fulfilled'
? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...(r.value as object) }
? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...annotateStatusPayload(ids[i], r.value as Record<string, unknown>) }
: { deviceId: ids[i], ok: false, error: (r.reason as Error)?.message ?? String(r.reason) },
);
const batchFmt = resolveFormat();
Expand Down Expand Up @@ -342,7 +366,7 @@ Examples:
category: options.nameCategory,
room: options.nameRoom,
});
const body = await fetchDeviceStatus(deviceId);
const body = annotateStatusPayload(deviceId, await fetchDeviceStatus(deviceId));
const fetchedAt = new Date().toISOString();
const fmt = resolveFormat();

Expand Down Expand Up @@ -678,7 +702,7 @@ Examples:
if (isJsonMode()) {
printJson({ dryRun: true, wouldSend });
} else {
console.log(`[dry-run] Would POST devices/${_deviceId}/commands with ${JSON.stringify({ command: _cmd, parameter: _parsedParam, commandType })}`);
console.log(`dry-run intercepted for ${_cmd} on ${_deviceId}; see stderr preview for the HTTP request.`);
}
return;
}
Expand Down Expand Up @@ -856,6 +880,8 @@ Examples:
capabilities,
source,
suggestedActions: picks,
...(result.catalogNote ? { catalogNote: result.catalogNote } : {}),
...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
...(expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}),
});
return;
Expand Down Expand Up @@ -893,8 +919,17 @@ Examples:
capabilities && 'liveStatus' in capabilities ? capabilities.liveStatus : undefined;

console.log('');
if (result.warnings && result.warnings.length > 0) {
for (const warning of result.warnings) {
console.log(`Warning: ${warning}`);
}
console.log('');
}
if (!catalog) {
console.log(`(Type "${typeName}" is not in the built-in catalog — no command reference available.)`);
if (result.catalogNote) {
console.log(result.catalogNote);
}
if (isPhysical) {
console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`);
} else {
Expand Down Expand Up @@ -995,6 +1030,7 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void {
if (entry.statusFields && entry.statusFields.length > 0) {
console.log('\nStatus fields (from "devices status"):');
console.log(' ' + entry.statusFields.join(', '));
console.log(' Note: statusFields are advisory; actual fields can vary by firmware and device variant.');
}

const expandHint = EXPAND_HINTS[entry.type];
Expand Down
75 changes: 73 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { execSync } from 'node:child_process';
import { printJson, isJsonMode, exitWithError } from '../utils/output.js';
import { getEffectiveCatalog } from '../devices/catalog.js';
import { configFilePath, listProfiles, readProfileMeta } from '../config.js';
import { describeCache, resetListCache } from '../devices/cache.js';
import { describeCache, resetListCache, loadCache } from '../devices/cache.js';
import { DAILY_QUOTA, todayUsage } from '../utils/quota.js';
import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js';
import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js';
import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js';
import { getReleaseMetadata } from '../version-notes.js';
import { VERSION as currentVersion } from '../version.js';
import {
resolvePolicyPath,
loadPolicyFile,
Expand Down Expand Up @@ -355,10 +357,56 @@ interface AuditRecord {
kind?: string;
deviceId?: string;
command?: string;
result?: 'ok' | 'error';
result?: 'ok' | 'error' | 'dry-run';
error?: string;
}

function checkInventoryConsistency(): Check {
const cache = loadCache();
if (!cache) {
return {
name: 'inventory',
status: 'ok',
detail: "no local inventory cache — run 'switchbot devices list' to enable hub-reference checks",
};
}

const dangling = Object.entries(cache.devices)
.filter(([, device]) => device.category === 'physical')
.filter(([deviceId, device]) => {
const hubDeviceId = device.hubDeviceId;
return Boolean(
hubDeviceId &&
hubDeviceId !== '000000000000' &&
hubDeviceId !== deviceId &&
!cache.devices[hubDeviceId],
);
})
.map(([deviceId, device]) => ({
deviceId,
deviceName: device.name,
hubDeviceId: device.hubDeviceId!,
deviceType: device.type,
}));

if (dangling.length === 0) {
return {
name: 'inventory',
status: 'ok',
detail: `inventory graph consistent across ${Object.keys(cache.devices).length} cached devices`,
};
}

return {
name: 'inventory',
status: 'warn',
detail: {
message: `${dangling.length} device(s) reference a hubDeviceId that is not present in the current inventory`,
dangling: dangling.slice(0, 10),
},
};
}

function checkAudit(): Check {
// P9: surface recent command failures so agents / ops can spot problems
// before they page. When --audit-log was never enabled, the file won't
Expand Down Expand Up @@ -853,6 +901,27 @@ function checkMcp(): Check {
}
}

function checkReleaseNotes(): Check {
const meta = getReleaseMetadata(currentVersion);
if (!meta || !meta.breaking) {
return {
name: 'release-notes',
status: 'ok',
detail: { version: currentVersion, message: 'no known breaking-change notice for the current release' },
};
}
return {
name: 'release-notes',
status: 'warn',
detail: {
version: currentVersion,
breaking: true,
message: meta.summary,
hint: 'If you have scripts pinned to 3.2.x JSON output, update them before rolling this release wider.',
},
};
}

interface CheckDef {
name: string;
description: string;
Expand All @@ -871,6 +940,7 @@ const CHECK_REGISTRY: CheckDef[] = [
{ name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() },
{ name: 'catalog', description: 'catalog loads', run: () => checkCatalog() },
{ 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() },
{ name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() },
{ name: 'clock', description: 'system clock skew', run: () => checkClockSkew() },
Expand All @@ -880,6 +950,7 @@ const CHECK_REGISTRY: CheckDef[] = [
run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()),
},
{ name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() },
{ name: 'release-notes', description: 'current release breaking-change notice', run: () => checkReleaseNotes() },
{ name: 'policy', description: 'policy.yaml present + schema-valid (if configured)', run: () => checkPolicy() },
{ name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() },
{ name: 'daemon', description: 'daemon state file + runtime status', run: () => checkDaemon() },
Expand Down
16 changes: 12 additions & 4 deletions src/commands/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ interface EventRecord {
matched: boolean;
}

function emitJsonStreamRecord<T extends { schemaVersion: string }>(record: T): void {
const { schemaVersion, ...rest } = record as T & Record<string, unknown>;
printJson({
payloadVersion: schemaVersion,
...rest,
});
}

function matchFilterDetail(
body: unknown,
clauses: FilterClause[] | null,
Expand Down Expand Up @@ -262,7 +270,7 @@ Examples:
if (!ev.matched) return;
matchedCount++;
if (isJsonMode()) {
printJson(ev);
emitJsonStreamRecord(ev);
} else {
const when = new Date(ev.t).toLocaleTimeString();
console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`);
Expand Down Expand Up @@ -457,7 +465,7 @@ Examples:
// connected" even when mqtt-tail exits before the broker connects.
if (isJsonMode()) {
const sessionStartAt = new Date().toISOString();
printJson({
emitJsonStreamRecord({
schemaVersion: EVENTS_SCHEMA_VERSION,
source: 'mqtt',
kind: 'control',
Expand Down Expand Up @@ -518,7 +526,7 @@ Examples:
payload: parsed,
};
if (isJsonMode()) {
printJson(record);
emitJsonStreamRecord(record);
} else {
console.log(JSON.stringify(record));
}
Expand Down Expand Up @@ -554,7 +562,7 @@ Examples:
// Control events always go to stdout as JSONL so consumers that
// filter real events by presence of `payload` can skip them.
if (isJsonMode()) {
printJson(ctl);
emitJsonStreamRecord(ctl);
} else {
console.log(JSON.stringify(ctl));
}
Expand Down
19 changes: 16 additions & 3 deletions src/commands/expand.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Command } from 'commander';
import { intArg, stringArg } from '../utils/arg-parsers.js';
import { intArg, stringArg, enumArg } from '../utils/arg-parsers.js';
import { handleError, isJsonMode, printJson, UsageError, exitWithError } from '../utils/output.js';
import { getCachedDevice } from '../devices/cache.js';
import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js';
import { isDryRun } from '../utils/flags.js';
import { resolveDeviceId } from '../utils/name-resolver.js';
import { resolveDeviceId, ALL_STRATEGIES, type NameResolveStrategy } from '../utils/name-resolver.js';
import { DryRunSignal } from '../api/client.js';
import {
buildAcSetAll,
Expand All @@ -22,6 +22,10 @@ export function registerExpandCommand(devices: Command): void {
.argument('[deviceId]', 'Target device ID from "devices list" (or use --name)')
.argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)')
.option('--name <query>', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name'))
.option('--name-strategy <s>', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: require-unique)`, stringArg('--name-strategy'))
.option('--name-type <type>', 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg('--name-type'))
.option('--name-category <cat>', 'Narrow --name by category: physical|ir', enumArg('--name-category', ['physical', 'ir'] as const))
.option('--name-room <room>', 'Narrow --name by room name (substring match)', stringArg('--name-room'))
.option('--temp <celsius>', 'AC setAll: temperature in Celsius (16-30)', intArg('--temp', { min: 16, max: 30 }))
.option('--mode <mode>', 'AC: auto|cool|dry|fan|heat Curtain: default|performance|silent Relay: toggle|edge|detached|momentary', stringArg('--mode'))
.option('--fan <speed>', 'AC setAll: fan speed auto|low|mid|high', stringArg('--fan'))
Expand Down Expand Up @@ -65,6 +69,10 @@ Examples:
commandArg: string | undefined,
options: {
name?: string;
nameStrategy?: string;
nameType?: string;
nameCategory?: 'physical' | 'ir';
nameRoom?: string;
temp?: string; mode?: string; fan?: string; power?: string;
position?: string; direction?: string; angle?: string;
channel?: string; yes?: boolean;
Expand All @@ -82,7 +90,12 @@ Examples:
effectiveDeviceIdArg = undefined;
}

deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name);
deviceId = resolveDeviceId(effectiveDeviceIdArg, options.name, {
strategy: (options.nameStrategy as NameResolveStrategy | undefined) ?? 'require-unique',
type: options.nameType,
category: options.nameCategory,
room: options.nameRoom,
});
if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode).');

command = effectiveCommand;
Expand Down
Loading
Loading