文件预览

homeycli.js

查看 Homey 技能包中的文件内容。

文件内容

bin/homeycli.js

#!/usr/bin/env node

const { program } = require('commander');
const readline = require('readline');

const commands = require('../lib/commands');
const { cliError } = require('../lib/errors');

const pkg = require('../package.json');

program
  .name('homeycli')
  .description('Control Athom Homey smart home devices via local (LAN/VPN) or cloud APIs')
  .version(pkg.version);

// Global options
program
  .option('--json', 'Output JSON (stdout) instead of formatted text')
  .option('--raw', 'Include raw Homey API objects in JSON output (very verbose)')
  .option('--threshold <n>', 'Fuzzy match threshold', (v) => parseInt(v, 10), 5);

function exitCodeForError(err) {
  switch (err?.code) {
    case 'NO_TOKEN':
      return 2;
    case 'NOT_FOUND':
      return 3;
    case 'AMBIGUOUS':
      return 4;
    case 'CAPABILITY_NOT_SUPPORTED':
    case 'INVALID_VALUE':
      return 5;
    default:
      return 1;
  }
}

function printError(err, opts) {
  const message = err?.message || String(err);
  const code = err?.code || 'ERROR';
  const details = err?.details;

  if (opts?.json) {
    const payload = { error: { code, message } };
    if (details !== undefined) payload.error.details = details;
    console.log(JSON.stringify(payload, null, 2));
    return;
  }

  console.error(`error[${code}]: ${message}`);

  if (details?.help) {
    console.error(`help: ${details.help}`);
  }

  const candidates = details?.candidates;
  if (Array.isArray(candidates) && candidates.length) {
    console.error('candidates:');
    for (const c of candidates) {
      if (c.id && c.name) {
        console.error(`  ${c.id}  ${c.name}`);
      } else if (c.name) {
        console.error(`  ${c.name}`);
      } else {
        console.error(`  ${JSON.stringify(c)}`);
      }
    }
  }
}

async function runOrExit(fn) {
  const opts = program.opts();
  try {
    await fn(opts);
  } catch (err) {
    printError(err, opts);
    process.exit(exitCodeForError(err));
  }
}

function commandOpts(maybeCommandOrOpts) {
  if (maybeCommandOrOpts && typeof maybeCommandOrOpts.opts === 'function') {
    return maybeCommandOrOpts.opts();
  }
  // Commander may pass plain options object in some cases.
  if (maybeCommandOrOpts && typeof maybeCommandOrOpts === 'object') {
    return maybeCommandOrOpts;
  }
  return {};
}

async function readAllStdin() {
  return await new Promise((resolve, reject) => {
    let data = '';
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', (chunk) => {
      data += chunk;
    });
    process.stdin.on('end', () => resolve(data));
    process.stdin.on('error', reject);
  });
}

async function promptHidden(question) {
  if (!process.stdin.isTTY) {
    throw cliError('INVALID_VALUE', 'cannot prompt for token (stdin is not a TTY). Use --stdin instead.');
  }

  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stderr,
    terminal: true,
  });

  // Print the prompt, then hide typed characters (print nothing while user types).
  rl.output.write(question);
  rl._writeToOutput = function _writeToOutput() {};

  return await new Promise((resolve) => {
    rl.question('', (answer) => {
      rl.close();
      rl.output.write('\n');
      resolve(answer);
    });
  });
}

// Devices command
program
  .command('devices')
  .description('List devices (latest state)')
  .option('--match <query>', 'Filter devices by name (returns multiple matches)')
  .action((maybeCmd) => runOrExit((opts) => commands.listDevices({ ...opts, ...commandOpts(maybeCmd) })));

// Device operations
program
  .command('device <nameOrId> <action> [capability] [value]')
  .description('Device operations (on/off/set/get/values/inspect/capabilities)')
  .action((nameOrId, action, capability, value) =>
    runOrExit((opts) => {
      if (action === 'on') return commands.controlDevice(nameOrId, 'on', opts);
      if (action === 'off') return commands.controlDevice(nameOrId, 'off', opts);
      if (action === 'set') {
        if (!capability || value === undefined) {
          throw cliError('INVALID_VALUE', 'usage: homeycli device <nameOrId> set <capability> <value>');
        }
        return commands.setCapability(nameOrId, capability, value, opts);
      }
      if (action === 'get') {
        if (!capability) return commands.getDeviceValues(nameOrId, opts);
        return commands.getCapability(nameOrId, capability, opts);
      }
      if (action === 'values') return commands.getDeviceValues(nameOrId, opts);
      if (action === 'inspect') return commands.inspectDevice(nameOrId, opts);
      if (action === 'capabilities') return commands.getDeviceCapabilities(nameOrId, opts);

      throw cliError(
        'INVALID_VALUE',
        'invalid device action. Use: on, off, set <capability> <value>, get [capability], values, inspect, capabilities'
      );
    })
  );

// Flows command
program
  .command('flows')
  .description('List flows')
  .option('--match <query>', 'Filter flows by name (returns multiple matches)')
  .action((maybeCmd) => runOrExit((opts) => commands.listFlows({ ...opts, ...commandOpts(maybeCmd) })));

program
  .command('flow <action> <nameOrId>')
  .description('Flow operations (trigger)')
  .action((action, nameOrId) =>
    runOrExit((opts) => {
      if (action === 'trigger') return commands.triggerFlow(nameOrId, opts);
      throw cliError('INVALID_VALUE', 'invalid flow action. Use: trigger');
    })
  );

// Snapshot command
program
  .command('snapshot')
  .description('Get a point-in-time snapshot (status + zones + devices)')
  .option('--include-flows', 'Also include flows (can be large)')
  .action((maybeCmd) => runOrExit((opts) => commands.snapshot({ ...opts, ...commandOpts(maybeCmd) })));

// Zones command
program
  .command('zones')
  .description('List all zones/rooms')
  .action(() => runOrExit((opts) => commands.listZones(opts)));

// Status command
program
  .command('status')
  .description('Show Homey connection status and info')
  .action(() => runOrExit((opts) => commands.showStatus(opts)));

// Auth helper commands
program
  .command('auth <action> [value]')
  .description('Authentication helpers (local + cloud)')
  .option('--stdin', 'Read token from stdin (recommended; avoids shell history)')
  .option('--prompt', 'Prompt for token (hidden input)')
  .option('--address <url>', 'Homey local address (e.g. http://192.168.1.50)')
  .option('--save', 'Save discovered local address to config (discover-local)')
  .option('--pick <n>', 'Pick candidate by index (discover-local --save)', (v) => parseInt(v, 10))
  .option('--homey-id <id>', 'Pick candidate by Homey id (discover-local --save)')
  .option('--timeout <ms>', 'Discovery timeout in ms (discover-local)', (v) => parseInt(v, 10))
  .action((action, value, maybeCmd) =>
    runOrExit(async (opts) => {
      const merged = { ...opts, ...commandOpts(maybeCmd) };

      if (merged.stdin && merged.prompt) {
        throw cliError('INVALID_VALUE', 'use either --stdin or --prompt (not both)');
      }

      async function readToken(kindLabel) {
        if (merged.stdin) return (await readAllStdin()).trim();
        if (merged.prompt) return String(await promptHidden(`${kindLabel} token: `)).trim();
        return value;
      }

      if (action === 'discover-local') {
        return commands.authDiscoverLocal(merged);
      }

      if (action === 'set-token') {
        const t = await readToken('Homey cloud');
        return commands.authSetToken(t, merged);
      }

      if (action === 'set-local') {
        const t = await readToken('Homey local');
        return commands.authSetLocal(t, merged);
      }

      if (action === 'set-mode') {
        // value = auto|local|cloud
        return commands.authSetMode(value, merged);
      }

      if (action === 'clear-token') {
        return commands.authClearCloud(merged);
      }

      if (action === 'clear-local') {
        return commands.authClearLocal(merged);
      }

      if (action === 'status') return commands.authStatus(merged);

      throw cliError(
        'INVALID_VALUE',
        'invalid auth action. Use: status, discover-local [--save] [--pick <n>|--homey-id <id>] [--timeout <ms>], set-local [--address <url>] [--stdin|--prompt|<token>], set-token [--stdin|--prompt|<token>], set-mode <auto|local|cloud>, clear-local, clear-token'
      );
    })
  );

program.parse();