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
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "1.3.0",
"version": "1.3.1",
"description": "Command-line interface for SwitchBot API v1.1",
"keywords": [
"switchbot",
Expand Down
31 changes: 19 additions & 12 deletions src/commands/batch.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { Command } from 'commander';
import type { AxiosInstance } from 'axios';
import { printJson, isJsonMode, handleError } from '../utils/output.js';
import {
fetchDeviceList,
executeCommand,
isDestructiveCommand,
buildHubLocationMap,
} from '../lib/devices.js';
import { createClient } from '../api/client.js';
import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
import { isDryRun } from '../utils/flags.js';
import { DryRunSignal } from '../api/client.js';
import { getCachedTypeMap } from '../devices/cache.js';

interface BatchResult {
succeeded: Array<{ deviceId: string; result: unknown }>;
Expand Down Expand Up @@ -58,7 +61,7 @@ async function resolveTargetIds(options: {
filter?: string;
ids?: string;
readStdin: boolean;
}): Promise<{ ids: string[]; typeMap: Map<string, string> }> {
}, getClient?: () => AxiosInstance): Promise<{ ids: string[]; typeMap: Map<string, string> }> {
const explicit: string[] = [];

if (options.ids) {
Expand All @@ -84,25 +87,27 @@ async function resolveTargetIds(options: {
);
}

// Always fetch the device list so we can (a) apply --filter when present
// and (b) build a deviceId → deviceType map for destructive/validation
// checks regardless of how the ids were provided.
const body = await fetchDeviceList();
const hubLoc = buildHubLocationMap(body.deviceList);

const typeMap = new Map<string, string>();
for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType);
for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType);
const typeMap = getCachedTypeMap(explicit);

let ids: string[];
if (hasFilter) {
const body = await fetchDeviceList(getClient?.());
const hubLoc = buildHubLocationMap(body.deviceList);
for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType);
for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType);
const clauses = parseFilter(options.filter);
const matched = applyFilter(clauses, body.deviceList, body.infraredRemoteList, hubLoc);
const filteredIds = new Set(matched.map((m) => m.deviceId));
ids =
explicit.length > 0 ? explicit.filter((id) => filteredIds.has(id)) : [...filteredIds];
} else {
ids = explicit;
const missingTypeInfo = ids.some((id) => !typeMap.has(id));
if (missingTypeInfo) {
const body = await fetchDeviceList(getClient?.());
for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType);
for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType);
}
}

return { ids, typeMap };
Expand Down Expand Up @@ -167,14 +172,16 @@ Examples:
// Trailing "-" sentinel selects stdin mode.
const extra = commandObj.args ?? [];
const readStdin = Boolean(options.stdin) || extra.includes('-');
let client: AxiosInstance | undefined;
const getClient = (): AxiosInstance => (client ??= createClient());

let resolved: Awaited<ReturnType<typeof resolveTargetIds>>;
try {
resolved = await resolveTargetIds({
filter: options.filter,
ids: options.ids,
readStdin,
});
}, getClient);
} catch (error) {
if (error instanceof FilterSyntaxError) {
if (isJsonMode()) {
Expand Down Expand Up @@ -259,7 +266,7 @@ Examples:

const outcomes = await runPool(resolved.ids, concurrency, async (id) => {
try {
const result = await executeCommand(id, cmd, parsedParam, effectiveType);
const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient());
if (!isJsonMode()) {
console.log(`✓ ${id}: ${cmd}`);
}
Expand Down
9 changes: 7 additions & 2 deletions src/commands/expand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,16 @@ Examples:
if (command === 'setAll') {
parameter = buildAcSetAll(options);
} else if (command === 'setPosition') {
const isBlind = deviceType === 'Blind Tilt';
if (!cached) {
throw new UsageError(
`Device ${deviceId} is not in the local cache — run 'switchbot devices list' first so 'expand' knows whether this is a Curtain or a Blind Tilt.`
);
}
const isBlind = deviceType.startsWith('Blind Tilt');
parameter = isBlind
? buildBlindTiltSetPosition(options)
: buildCurtainSetPosition(options);
} else if (command === 'setMode' && (deviceType.startsWith('Relay Switch') || options.channel !== undefined)) {
} else if (command === 'setMode' && deviceType.startsWith('Relay Switch')) {
parameter = buildRelaySetMode(options);
} else {
throw new UsageError(
Expand Down
4 changes: 4 additions & 0 deletions src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const PLAN_JSON_SCHEMA = {
{
type: 'object',
required: ['type', 'command'],
oneOf: [
{ required: ['deviceId'], not: { required: ['deviceName'] } },
{ required: ['deviceName'], not: { required: ['deviceId'] } },
],
properties: {
type: { const: 'command' },
deviceId: { type: 'string', minLength: 1 },
Expand Down
4 changes: 3 additions & 1 deletion src/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.
import { fetchDeviceStatus } from '../lib/devices.js';
import { getCachedDevice } from '../devices/cache.js';
import { parseDurationToMs, getFields } from '../utils/flags.js';
import { createClient } from '../api/client.js';

const DEFAULT_INTERVAL_MS = 30_000;
const MIN_INTERVAL_MS = 1_000;
Expand Down Expand Up @@ -129,6 +130,7 @@ Examples:

try {
const prev = new Map<string, Record<string, unknown>>();
const client = createClient();
let tick = 0;
while (!ac.signal.aborted) {
tick++;
Expand All @@ -139,7 +141,7 @@ Examples:
deviceIds.map(async (id) => {
const cached = getCachedDevice(id);
try {
const body = await fetchDeviceStatus(id);
const body = await fetchDeviceStatus(id, client);
const changed = diff(prev.get(id), body, fields);
prev.set(id, body);
if (Object.keys(changed).length === 0 && !options.includeUnchanged) {
Expand Down
46 changes: 41 additions & 5 deletions src/devices/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,18 @@ function cacheFilePath(): string {

// In-memory hot-cache: undefined = not yet loaded, null = loaded but empty.
let _listCache: DeviceCache | null | undefined = undefined;
let _statusCache: StatusCache | undefined = undefined;

/** Force the next loadCache() call to re-read from disk. Used in tests. */
export function resetListCache(): void {
_listCache = undefined;
}

/** Force the next loadStatusCache() call to re-read from disk. Used in tests. */
export function resetStatusCache(): void {
_statusCache = undefined;
}

export function loadCache(): DeviceCache | null {
if (_listCache !== undefined) return _listCache;
const file = cacheFilePath();
Expand Down Expand Up @@ -88,6 +94,26 @@ export function getCachedDevice(deviceId: string): CachedDevice | null {
return cache.devices[deviceId] ?? null;
}

/** Build a deviceId -> type map from the metadata cache. */
export function getCachedTypeMap(deviceIds?: Iterable<string>): Map<string, string> {
const cache = loadCache();
const out = new Map<string, string>();
if (!cache) return out;

if (deviceIds) {
for (const id of deviceIds) {
const entry = cache.devices[id];
if (entry?.type) out.set(id, entry.type);
}
return out;
}

for (const [deviceId, entry] of Object.entries(cache.devices)) {
if (entry.type) out.set(deviceId, entry.type);
}
return out;
}

export function updateCacheFromDeviceList(body: DeviceListBodyShape): void {
const devices: Record<string, CachedDevice> = {};

Expand Down Expand Up @@ -184,21 +210,29 @@ function statusCacheFilePath(): string {
}

export function loadStatusCache(): StatusCache {
if (_statusCache !== undefined) return _statusCache;
const file = statusCacheFilePath();
if (!fs.existsSync(file)) return { entries: {} };
if (!fs.existsSync(file)) {
_statusCache = { entries: {} };
return _statusCache;
}
try {
const raw = fs.readFileSync(file, 'utf-8');
const parsed = JSON.parse(raw) as StatusCache;
if (!parsed || typeof parsed.entries !== 'object' || parsed.entries === null) {
return { entries: {} };
_statusCache = { entries: {} };
return _statusCache;
}
_statusCache = parsed;
return parsed;
} catch {
return { entries: {} };
_statusCache = { entries: {} };
return _statusCache;
}
}

function saveStatusCache(cache: StatusCache): void {
_statusCache = cache;
try {
const file = statusCacheFilePath();
const dir = path.dirname(file);
Expand Down Expand Up @@ -238,20 +272,22 @@ function evictExpiredStatusEntries(cache: StatusCache, ttlMs: number, now = Date
export function setCachedStatus(
deviceId: string,
body: Record<string, unknown>,
now = new Date()
now = new Date(),
ttlMsForGc = DEFAULT_STATUS_GC_TTL_MS
): void {
const cache = loadStatusCache();
cache.entries[deviceId] = {
fetchedAt: now.toISOString(),
body,
};
evictExpiredStatusEntries(cache, DEFAULT_STATUS_GC_TTL_MS, now.getTime());
evictExpiredStatusEntries(cache, ttlMsForGc, now.getTime());
saveStatusCache(cache);
}

export function clearStatusCache(): void {
const file = statusCacheFilePath();
if (fs.existsSync(file)) fs.unlinkSync(file);
_statusCache = { entries: {} };
}

/** Summary for `switchbot cache show`. */
Expand Down
11 changes: 9 additions & 2 deletions src/devices/device-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export function loadDeviceMeta(): DeviceMetaFile {
try {
const raw = fs.readFileSync(file, 'utf-8');
const parsed = JSON.parse(raw) as DeviceMetaFile;
if (!parsed || typeof parsed.devices !== 'object' || parsed.devices === null) {
if (
!parsed ||
parsed.version !== '1' ||
typeof parsed.devices !== 'object' ||
parsed.devices === null
) {
return { version: '1', devices: {} };
}
return parsed;
Expand All @@ -45,7 +50,9 @@ export function saveDeviceMeta(meta: DeviceMetaFile): void {
const file = metaFilePath();
const dir = path.dirname(file);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(file, JSON.stringify(meta, null, 2), { mode: 0o600 });
const tmp = `${file}.tmp-${process.pid}`;
fs.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o600 });
fs.renameSync(tmp, file);
}

export function getDeviceMeta(deviceId: string): DeviceMeta | null {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export async function fetchDeviceStatus(
`/v1.1/devices/${deviceId}/status`
);
if (mode.statusTtlMs > 0) {
setCachedStatus(deviceId, res.data.body);
setCachedStatus(deviceId, res.data.body, new Date(), mode.statusTtlMs);
}
return res.data.body;
}
Expand Down
9 changes: 5 additions & 4 deletions src/utils/name-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ function resolveDeviceByName(query: string): NameResolveResult {
// exact match
if (rawName === q) return { ok: true, deviceId };

// alias substring/fuzzy
// alias substring/fuzzy (only query ⊂ alias, not the reverse)
if (alias) {
const normAlias = normalizeDeviceName(alias);
if (normAlias.includes(q) || q.includes(normAlias)) {
if (normAlias.includes(q)) {
candidates.push({ deviceId, name: device.name, score: 1 });
continue;
}
Expand All @@ -52,8 +52,9 @@ function resolveDeviceByName(query: string): NameResolveResult {
}
}

// name substring
if (rawName.includes(q) || q.includes(rawName)) {
// name substring (only query ⊂ name, not the reverse — avoids long queries
// collapsing onto short device names)
if (rawName.includes(q)) {
candidates.push({ deviceId, name: device.name, score: 1 });
continue;
}
Expand Down
Loading
Loading