UNPKG

homey

Version:

Command-line interface and type declarations for Homey Apps

384 lines (319 loc) 10 kB
import { createRequire } from 'node:module'; import { APIErrorHomeyOffline, HomeyAPI, HomeyAPIV3Local } from 'homey-api'; import AthomApi from '../../services/AthomApi.js'; import { DEFAULT_TIMEOUT } from './ApiCommandConstants.mjs'; const require = createRequire(import.meta.url); const HomeyApiUtil = require('homey-api/lib/Util'); function normalizeAddress(address) { return String(address).replace(/\/+$/, ''); } function parseAddress(address) { try { return new URL(address); } catch { throw new Error('Invalid address. Please provide an absolute URL, e.g. http://192.168.1.100.'); } } export function getRequestTimeout(rawTimeout) { // Keep this guard for non-yargs callers that may not pass a timeout. if (typeof rawTimeout === 'undefined') { return DEFAULT_TIMEOUT; } const timeout = Number(rawTimeout); if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error('Invalid timeout. Please provide a positive number in milliseconds.'); } return timeout; } function validateAuthFlags({ token, address, homeyId }) { if (token && address && homeyId) { throw new Error( 'Invalid option usage: --address and --homey-id cannot be used together with --token.', ); } if (token && !address && !homeyId) { throw new Error('Missing required option: --address or --homey-id (required with --token).'); } if (!token && address) { throw new Error('Invalid option usage: --address can only be used together with --token.'); } } function createTokenHomeyApi({ token, address, homeyId = 'token-homey' }) { const normalizedAddress = normalizeAddress(address); const parsedAddress = parseAddress(normalizedAddress); const properties = { id: homeyId, softwareVersion: '0.0.0', }; if (parsedAddress.protocol === 'https:') { properties.localUrlSecure = normalizedAddress; } else { properties.localUrl = normalizedAddress; } return new HomeyAPIV3Local({ properties, strategy: [], baseUrl: normalizedAddress, token, }); } function applyResolvedHomeyMetadata(homeyApi, homey) { if (homey.usb) { // Keep USB override behavior in sync with existing AthomApi implementation. homeyApi.__baseUrlPromise = Promise.resolve(`http://${homey.usb}:80`); } homeyApi.model = homey.model; return homeyApi; } export function getPreferredAuthenticateStrategy(homey) { if (homey.platform === HomeyAPI.PLATFORMS.CLOUD) { return [HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]; } return [ HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE, HomeyAPI.DISCOVERY_STRATEGIES.LOCAL, HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED, HomeyAPI.DISCOVERY_STRATEGIES.CLOUD, ]; } function getDiagnoseStrategyOrder(homey) { const preferredStrategies = getPreferredAuthenticateStrategy(homey); if (homey.platform === HomeyAPI.PLATFORMS.CLOUD) { return preferredStrategies; } return [...preferredStrategies, HomeyAPI.DISCOVERY_STRATEGIES.MDNS]; } function getStrategyTarget(homey, strategyId) { switch (strategyId) { case HomeyAPI.DISCOVERY_STRATEGIES.LOCAL_SECURE: return homey.localUrlSecure || null; case HomeyAPI.DISCOVERY_STRATEGIES.LOCAL: return homey.localUrl || null; case HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED: return homey.remoteUrlForwarded || null; case HomeyAPI.DISCOVERY_STRATEGIES.CLOUD: return homey.remoteUrl || null; case HomeyAPI.DISCOVERY_STRATEGIES.MDNS: return homey.id ? `http://homey-${homey.id}.local` : null; default: return null; } } export async function resolveRequestedHomey(homeyId) { if (typeof homeyId === 'string' && homeyId.length > 0) { return AthomApi.getHomey(homeyId); } const activeHomey = await AthomApi.getSelectedHomey(); if (!activeHomey) { throw new Error('No active Homey selected. Run `homey select` to choose one.'); } return AthomApi.getHomey(activeHomey.id); } function normalizeOfflineError(homey) { return new Error( `${homey.name} (${homey.id}) seems to be offline. Are you sure you're in the same local network?`, ); } async function authenticateHomey(homey, strategy = getPreferredAuthenticateStrategy(homey)) { try { const homeyApi = await homey.authenticate({ strategy, }); return applyResolvedHomeyMetadata(homeyApi, homey); } catch (err) { if (err instanceof APIErrorHomeyOffline) { throw normalizeOfflineError(homey); } throw err; } } function normalizeDiagnosticError(err, homey) { if (err instanceof APIErrorHomeyOffline) { return normalizeOfflineError(homey).message; } return err?.message ?? String(err); } function getTokenModeAddressForHomey(homey) { if (homey.usb) { return `http://${homey.usb}:80`; } if (homey.localUrlSecure) { return homey.localUrlSecure; } if (homey.localUrl) { return homey.localUrl; } const homeyLabel = homey.name ? `${homey.name} (${homey.id})` : homey.id; throw new Error(`${homeyLabel} does not expose a usable local address for token mode.`); } async function createTokenHomeyApiForHomey({ token, homeyId }) { const homey = await AthomApi.getHomey(homeyId); const homeyApi = createTokenHomeyApi({ token, address: getTokenModeAddressForHomey(homey), homeyId: homey.id, }); return applyResolvedHomeyMetadata(homeyApi, homey); } export async function createHomeyApiClient({ token, address, homeyId }) { validateAuthFlags({ token, address, homeyId }); if (token && address) { return createTokenHomeyApi({ token, address }); } if (token && homeyId) { return createTokenHomeyApiForHomey({ token, homeyId }); } const homey = await resolveRequestedHomey(homeyId); return authenticateHomey(homey); } export async function diagnoseHomeyStrategies({ homeyId } = {}) { const homey = await resolveRequestedHomey(homeyId); const preferredStrategyIds = getPreferredAuthenticateStrategy(homey); const attemptedStrategyIds = getDiagnoseStrategyOrder(homey); const results = []; for (const strategyId of attemptedStrategyIds) { const startedAt = Date.now(); const configuredTarget = getStrategyTarget(homey, strategyId); let api = null; if (strategyId === HomeyAPI.DISCOVERY_STRATEGIES.REMOTE_FORWARDED && !configuredTarget) { results.push({ strategyId, available: false, status: 'not-configured', durationMs: 0, error: 'Not configured for this Homey', }); continue; } try { api = await homey.authenticate({ strategy: [strategyId], }); results.push({ strategyId, available: true, status: 'available', durationMs: Date.now() - startedAt, resolvedStrategyId: api?.strategyId ?? api?.__strategyId ?? strategyId, baseUrl: await api?.baseUrl, }); } catch (err) { results.push({ strategyId, available: false, status: 'failed', durationMs: Date.now() - startedAt, error: normalizeDiagnosticError(err, homey), }); } finally { await disposeHomeyApiClient(api); } } const availableStrategyIds = results .filter((result) => result.available) .map((result) => result.strategyId); const selectedResult = results.find( (result) => result.available && preferredStrategyIds.includes(result.strategyId), ) ?? results.find((result) => result.available) ?? null; return { target: { id: homey.id, name: homey.name, platform: homey.platform, model: homey.model ?? null, usb: homey.usb ?? null, }, preferredStrategyIds, attemptedStrategyIds, availableStrategyIds, selectedStrategyId: selectedResult?.strategyId ?? null, selectedBaseUrl: selectedResult?.baseUrl ?? null, results, }; } export async function disposeHomeyApiClient(api) { if (!api || typeof api !== 'object') { return; } for (const manager of Object.values(api.__managers || {})) { if (typeof manager?.destroy === 'function') { manager.destroy(); } } for (const [key, value] of Object.entries(api.__refreshMap || {})) { if (key.endsWith('timeout')) { clearTimeout(value); delete api.__refreshMap[key]; } } if (typeof api.disconnect === 'function') { await api.disconnect().catch(() => {}); } if (typeof api.destroy === 'function') { api.destroy(); } } export async function callHomeyApi({ api, callOptions, captureMetadata = false }) { if (!captureMetadata) { const startedAt = Date.now(); const result = await api.call(callOptions); return { result, durationMs: Date.now() - startedAt, request: null, response: null, }; } const originalFetch = HomeyApiUtil.fetch; const metadata = { request: null, response: null, }; const startedAt = Date.now(); HomeyApiUtil.fetch = async (url, options, timeoutDuration, timeoutMessage) => { metadata.request = { url, method: options?.method, headers: options?.headers || {}, timeoutDuration, }; const response = await originalFetch.call( HomeyApiUtil, url, options, timeoutDuration, timeoutMessage, ); metadata.response = { url: response.url || url, status: response.status, statusText: response.statusText, headers: Object.fromEntries(response.headers.entries()), contentType: response.headers.get('content-type'), }; return response; }; try { const result = await api.call(callOptions); return { result, durationMs: Date.now() - startedAt, request: metadata.request, response: metadata.response, }; } finally { HomeyApiUtil.fetch = originalFetch; } } export default { callHomeyApi, createHomeyApiClient, diagnoseHomeyStrategies, disposeHomeyApiClient, getPreferredAuthenticateStrategy, getRequestTimeout, resolveRequestedHomey, };