diff --git a/README.md b/README.md index 59fe4587..057a40f2 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,69 @@ Then restart your shell, or run: source ~/.zshrc ``` +## USB connections + +USB discovery is now opt-in. Existing USB workflows must add `--usb` or set `HOMEY_USB=1`. +Normal commands use their network connection strategies and do not probe USB candidate addresses. + +```bash +homey list --usb +homey select --usb +homey app run --usb +homey app install --usb +homey api system get-info --usb +homey api raw --usb --path /api/manager/system/ +homey api diagnose --usb +``` + +`list --usb` and `select --usb` show only USB-connected Homeys, including devices whose cached +Cloud status is offline. Selection saves the Homey, not USB mode. For subsequent commands, +pass `--usb` again or enable it for your development shell: + +```bash +export HOMEY_USB=1 +homey app run +homey list --no-usb +``` + +Explicit `--no-usb` overrides the shell setting. USB mode requires a local Homey using API v3 +and fails if the selected or requested Homey is not found over USB; it does not fall back to LAN +or Cloud transport. Enabled USB mode takes precedence over `--discovery-strategies`. +Account lookup and authentication or session renewal can still require +Athom Cloud. USB discovery probes unique candidate addresses concurrently with a one-second +timeout and does not persist discovery results in the account cache. + +`homey app run --remote --usb` runs the app on Homey over USB. API token mode supports +`--token --homey-id --usb`. An explicit `--address` cannot be combined with +enabled USB mode; add `--no-usb` if your shell enables it. `api diagnose --usb` checks only USB +connectivity and reports a failed `usb` attempt when the device is disconnected. +The `api raw` aliases `call` and `request` also accept `--usb`. + ## Homey API CLI Use `homey api` for direct Homey API access. +### Account caching and rate limits + +The CLI caches your account profile and Homey connection details on disk for five minutes, +so successive commands can reuse them. The cache is stored separately in `profile-cache.json` +alongside `settings.json`, so refreshing it does not rewrite account or active Homey settings. +Once the cache expires, the next command refreshes it. +If that refresh receives HTTP 429, the CLI continues with the cached data and waits at least +one minute before attempting another profile refresh. Live Homey API responses are not cached. + +Use `homey list --refresh` or `homey whoami --refresh` to refresh account data before the cache +expires. These options still respect the rate-limit cooldown and fall back to cached data on 429. +Logging in or out clears account data from the profile cache, retaining only a random generation +marker so pending requests cannot restore it. Cached profiles are bound to the OAuth access token +or PAT that fetched them, so a different credential (including a rotated OAuth token) starts a new +cache. Concurrent updates preserve newer profile data and active cooldowns. Cache I/O failures +produce a warning without discarding a fetched profile or an available rate-limit fallback. + +A first login or an expired Homey session can still require Cloud API access. Without cached +account data, a profile request that receives HTTP 429 still fails. For direct local API access, +`homey api` also supports `--token --address ` without an account lookup. + ### Raw requests ```bash diff --git a/bin/cmds/api/diagnose.mjs b/bin/cmds/api/diagnose.mjs index 55c17897..8b90f6be 100644 --- a/bin/cmds/api/diagnose.mjs +++ b/bin/cmds/api/diagnose.mjs @@ -1,5 +1,6 @@ import { logJsonError, printStructuredOutput } from '../../../lib/CliOutput.mjs'; import Log from '../../../lib/Log.js'; +import { applyUsbOption } from '../../../lib/UsbOption.mjs'; import { applyHomeyIdOption, applyJqOutputOption, @@ -56,8 +57,9 @@ function printHumanReport(report) { } export const builder = (yargs) => { - return applyHomeyIdOption(applyJqOutputOption(applyJsonOutputOption(yargs))) + return applyUsbOption(applyHomeyIdOption(applyJqOutputOption(applyJsonOutputOption(yargs)))) .example('$0 api diagnose', 'Diagnose discovery strategies for the selected Homey') + .example('$0 api diagnose --usb', 'Diagnose only the USB connection') .example( '$0 api diagnose --homey-id --json', 'Diagnose discovery strategies for a cached Homey and print JSON output', @@ -69,6 +71,7 @@ export const handler = async (argv = {}) => { try { const report = await diagnoseHomeyStrategies({ homeyId: argv.homeyId, + usb: argv.usb, }); printStructuredOutput({ diff --git a/bin/cmds/api/raw.mjs b/bin/cmds/api/raw.mjs index f5387d41..7c259a67 100644 --- a/bin/cmds/api/raw.mjs +++ b/bin/cmds/api/raw.mjs @@ -263,6 +263,7 @@ export const handler = async (argv) => { const headers = parseHeaders(argv.header, '--header'); const body = parseRequestBody(argv, method); const api = await createHomeyApiClient({ + usb: argv.usb, token: argv.token, address: argv.address, homeyId: argv.homeyId, diff --git a/bin/cmds/app/install.mjs b/bin/cmds/app/install.mjs index 11b74ad8..2eca1bee 100644 --- a/bin/cmds/app/install.mjs +++ b/bin/cmds/app/install.mjs @@ -1,10 +1,11 @@ import Log from '../../../lib/Log.js'; import AppFactory from '../../../lib/AppFactory.js'; import AthomApi from '../../../services/AthomApi.js'; +import { applyUsbOption } from '../../../lib/UsbOption.mjs'; export const desc = 'Install a Homey App'; export const builder = (yargs) => { - return yargs + return applyUsbOption(yargs) .option('clean', { alias: 'c', type: 'boolean', @@ -18,7 +19,7 @@ export const builder = (yargs) => { }; export const handler = async (yargs) => { try { - const homey = await AthomApi.getActiveHomey(); + const homey = await AthomApi.getActiveHomey({ usb: yargs.usb }); const app = AppFactory.getAppInstance(yargs.path); await app.install({ homey, diff --git a/bin/cmds/app/run.mjs b/bin/cmds/app/run.mjs index fd6cb838..8e08c241 100644 --- a/bin/cmds/app/run.mjs +++ b/bin/cmds/app/run.mjs @@ -1,9 +1,10 @@ import Log from '../../../lib/Log.js'; import AppFactory from '../../../lib/AppFactory.js'; +import { applyUsbOption } from '../../../lib/UsbOption.mjs'; export const desc = 'Run a Homey App in development mode'; export const builder = (yargs) => { - return yargs + return applyUsbOption(yargs) .option('clean', { alias: 'c', type: 'boolean', @@ -56,6 +57,7 @@ export const handler = async (yargs) => { try { const app = AppFactory.getAppInstance(yargs.path); await app.run({ + usb: yargs.usb, clean: yargs.clean, remote: yargs.remote, skipBuild: yargs.skipBuild, diff --git a/bin/cmds/list.mjs b/bin/cmds/list.mjs index 46f299a8..54612fb2 100644 --- a/bin/cmds/list.mjs +++ b/bin/cmds/list.mjs @@ -4,6 +4,7 @@ import { printStructuredOutput, logJsonError } from '../../lib/CliOutput.mjs'; import { applyJqOutputOption, applyJsonOutputOption } from '../../lib/api/ApiCommandOptions.mjs'; import Log from '../../lib/Log.js'; import AthomApi from '../../services/AthomApi.js'; +import { applyUsbOption } from '../../lib/UsbOption.mjs'; export const desc = 'List all Homeys'; @@ -69,20 +70,29 @@ function printHomeysTable(homeys) { } export const builder = (yargs) => { - return applyJqOutputOption(applyJsonOutputOption(yargs)) + return applyUsbOption(applyJqOutputOption(applyJsonOutputOption(yargs))) + .option('refresh', { + type: 'boolean', + default: false, + desc: 'Refresh cached account data unless the Cloud API is rate limited', + }) .example('$0 list --json', 'Output Homeys as JSON') + .example('$0 list --usb', 'List only USB-connected Homeys') .example("$0 list --jq '.[].name'", 'Print all Homey names using jq') .help(); }; export const handler = async (argv = {}) => { try { - const homeys = sortHomeys(await AthomApi.getHomeys()).map(toHomeyOutput); + const homeys = await AthomApi.getHomeys({ cache: !argv.refresh, usb: argv.usb }); + const output = sortHomeys(homeys).map(toHomeyOutput); printStructuredOutput({ - value: homeys, + value: output, argv, - printHuman: () => printHomeysTable(homeys), + printHuman: () => { + return printHomeysTable(output); + }, }); process.exit(0); diff --git a/bin/cmds/select.mjs b/bin/cmds/select.mjs index 63c19536..8aa5ff81 100644 --- a/bin/cmds/select.mjs +++ b/bin/cmds/select.mjs @@ -1,9 +1,10 @@ import Log from '../../lib/Log.js'; import AthomApi from '../../services/AthomApi.js'; +import { applyUsbOption } from '../../lib/UsbOption.mjs'; export const desc = 'Select a Homey as active'; export const builder = (yargs) => { - return yargs + return applyUsbOption(yargs) .commandDir('select', { extensions: ['.mjs'], }) @@ -18,6 +19,7 @@ export const builder = (yargs) => { type: 'string', }) .example('$0 select --id ', 'Select a Homey by id') + .example('$0 select --usb', 'Select a USB-connected Homey; USB mode is not saved') .example('$0 select current --json', 'Show the currently selected Homey as JSON') .help(); }; @@ -27,6 +29,7 @@ export const handler = async (argv) => { await AthomApi.selectActiveHomey({ id: argv.id, name: argv.name, + usb: argv.usb, }); process.exit(0); } catch (err) { diff --git a/bin/cmds/whoami.mjs b/bin/cmds/whoami.mjs index 610cc6a9..4b1f62ec 100644 --- a/bin/cmds/whoami.mjs +++ b/bin/cmds/whoami.mjs @@ -26,6 +26,11 @@ function printProfile(profile) { export const builder = (yargs) => { return applyJqOutputOption(applyJsonOutputOption(yargs)) + .option('refresh', { + type: 'boolean', + default: false, + desc: 'Refresh cached account data unless the Cloud API is rate limited', + }) .example('$0 whoami --json', 'Output the current user as JSON') .example("$0 whoami --jq '.email'", 'Print the current user email using jq') .help(); @@ -33,12 +38,15 @@ export const builder = (yargs) => { export const handler = async (argv = {}) => { try { - const profile = toProfileOutput(await AthomApi.getProfile()); + const profile = await AthomApi.getProfile({ cache: !argv.refresh }); + const output = toProfileOutput(profile); printStructuredOutput({ - value: profile, + value: output, argv, - printHuman: () => printProfile(profile), + printHuman: () => { + return printProfile(output); + }, }); process.exit(0); diff --git a/lib/App.js b/lib/App.js index f17ed508..a3a5fdbd 100644 --- a/lib/App.js +++ b/lib/App.js @@ -191,6 +191,7 @@ class App { } async run({ + usb = false, clean = false, remote = false, skipBuild = false, @@ -199,7 +200,8 @@ class App { dockerSocketPath, dockerExposedPorts = [], } = {}) { - const homey = await AthomApi.getActiveHomey(); + this._usb = usb; + const homey = await AthomApi.getActiveHomey({ usb }); // Homey Cloud does not support running apps remotely. if (homey.platform === 'cloud' && remote === true) { @@ -1687,7 +1689,7 @@ $ sudo systemctl restart docker Log.success(`Uninstalling \`${this._session.appId}\`...`); try { - const homey = await AthomApi.getActiveHomey(); + const homey = await AthomApi.getActiveHomey({ usb: this._usb ?? false }); await homey.devkit.stopApp({ session: this._session.session }); Log.success(`Homey App \`${this._session.appId}\` successfully uninstalled`); } catch (err) { diff --git a/lib/AppPython.js b/lib/AppPython.js index 15d1d243..f1ab4401 100644 --- a/lib/AppPython.js +++ b/lib/AppPython.js @@ -69,6 +69,7 @@ class AppPython extends App { } async run({ + usb = false, clean = false, remote = false, skipBuild = false, @@ -78,7 +79,8 @@ class AppPython extends App { findLinks, dockerExposedPorts = [], } = {}) { - const homey = await AthomApi.getActiveHomey(); + this._usb = usb; + const homey = await AthomApi.getActiveHomey({ usb }); await AppPython.checkHomeyCompatibility(homey); diff --git a/lib/AthomApi.js b/lib/AthomApi.js index 9acdbe17..00d7a176 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -2,6 +2,7 @@ const path = require('path'); const os = require('os'); +const { createHash } = require('crypto'); const inquirer = require('inquirer'); const colors = require('colors'); @@ -11,10 +12,16 @@ const { AthomCloudAPI, APIErrorHomeyOffline, HomeyAPI } = require('homey-api'); const AthomCloudAPIToken = require('homey-api/lib/AthomCloudAPI/Token'); const AthomApiStorage = require('./AthomApiStorage'); +const { AthomApiProfileCache } = require('./AthomApiProfileCache'); +const { HomeyUsb } = require('./HomeyUsb'); const Log = require('./Log'); const Settings = require('../services/Settings'); const { ATHOM_API_CLIENT_ID, ATHOM_API_CLIENT_SECRET, ATHOM_API_LOGIN_URL } = require('../config'); +const PROFILE_CACHE_TTL = 5 * 60 * 1000; +// homey-api errors expose statusCode, but do not expose Retry-After headers. +const PROFILE_RATE_LIMIT_COOLDOWN = 60 * 1000; + function getPreferredActiveHomeyStrategy(homey) { if (homey.platform === HomeyAPI.PLATFORMS.CLOUD) { return [HomeyAPI.DISCOVERY_STRATEGIES.CLOUD]; @@ -31,13 +38,20 @@ class AthomApi { constructor() { this._api = null; this._user = null; - this._homeys = null; - this._activeHomey = null; + this._homeys = new Map(); + this._activeHomey = new Map(); this.discoveryStrategies = undefined; } _createApi() { + this._user = null; + this._homeys.clear(); + this._activeHomey.clear(); + this._profileAuthKey = process.env.HOMEY_PAT + ? `pat:${createHash('sha256').update(process.env.HOMEY_PAT).digest('hex')}` + : null; this._store = new AthomApiStorage(); + this._profileCache = new AthomApiProfileCache(); this._api = new AthomCloudAPI({ clientId: ATHOM_API_CLIENT_ID, clientSecret: ATHOM_API_CLIENT_SECRET, @@ -144,7 +158,7 @@ class AthomApi { listener.close(); - await this._api.authenticateWithAuthorizationCode({ code }); + await this._authenticateWithAuthorizationCode({ code }); try { const profile = await this.getProfile(); @@ -157,99 +171,244 @@ class AthomApi { } } + async _authenticateWithAuthorizationCode({ code }) { + const token = await this._api.authenticateWithAuthorizationCode({ code }); + this._profileAuthKey = `oauth:${createHash('sha256').update(token.access_token).digest('hex')}`; + await this._profileCache.clear().catch((err) => { + Log.warning('Could not clear the account profile cache:', err); + }); + } + async logout() { Log.success('You are now logged out'); await this._createApi(); await this._api.logout(); + await this._profileCache.clear().catch((err) => { + Log.warning('Could not clear the account profile cache:', err); + }); await this.unsetActiveHomey(); } - async getProfile() { + async getProfile({ cache = true } = {}) { await this._initApi(); - return this._api.getAuthenticatedUser(); + + // Capture the credential before the request; login may change it while a response is pending. + const api = this._api; + const authKey = await this._getProfileAuthKey(); + let stored; + + try { + stored = await this._profileCache.get(); + } catch (err) { + Log.warning('Could not read the account profile cache:', err); + } + + const generation = stored?.generation ?? null; + const hasCachedProfile = Boolean(authKey && stored?.user && stored.authKey === authKey); + const now = Date.now(); + const isFresh = hasCachedProfile && now - stored.updatedAt < PROFILE_CACHE_TTL; + const isCoolingDown = hasCachedProfile && now < stored.retryAfter; + + if ((cache && isFresh) || isCoolingDown) { + return new AthomCloudAPI.User({ api, properties: stored.user }); + } + + // Order cache entries by request start so a delayed response cannot replace newer data. + const requestStartedAt = Date.now(); + let properties; + + try { + properties = await api.call({ method: 'get', path: '/user/me' }); + } catch (err) { + if (err.statusCode !== 429 || !hasCachedProfile) { + throw err; + } + + try { + const updated = await this._profileCache.setRetryAfter({ + authKey, + generation, + retryAfter: Date.now() + PROFILE_RATE_LIMIT_COOLDOWN, + }); + + if (updated) { + stored = updated; + } + } catch (writeError) { + Log.warning('Could not save the account profile cooldown:', writeError); + } + + return new AthomCloudAPI.User({ api, properties: stored.user }); + } + + const profile = new AthomCloudAPI.User({ api, properties }); + + if (authKey) { + await this._profileCache + .set({ + user: properties, + authKey, + generation, + updatedAt: requestStartedAt, + }) + .catch((err) => { + Log.warning('Could not save the account profile cache:', err); + }); + } + + return profile; } - async getHomey(homeyId) { + async _getProfileAuthKey() { + if (this._profileAuthKey) { + return this._profileAuthKey; + } + + const { token } = await this._store.get(); + + if (!token?.access_token) { + return null; + } + + this._profileAuthKey = `oauth:${createHash('sha256').update(token.access_token).digest('hex')}`; + return this._profileAuthKey; + } + + async getHomey(homeyId, { usb = false } = {}) { const homeys = await this.getHomeys(); - for (let i = 0; i < homeys.length; i++) { - const homey = homeys[i]; - if (homey.id === homeyId) return homey; + const homey = homeys.find((candidate) => { + return candidate.id === homeyId; + }); + + if (!homey) { + throw new Error(`Homey Not Found: ${homeyId}`); } - throw new Error(`Homey Not Found: ${homeyId}`); + + if (!usb) { + return homey; + } + + HomeyUsb.assertSupported(homey); + const usbHomeys = await this.getHomeys({ usb: true }); + const usbHomey = usbHomeys.find((candidate) => { + return candidate.id === homeyId; + }); + + if (!usbHomey) { + throw new Error(`Homey ${homey.name} (${homey.id}) was not found over USB.`); + } + + return usbHomey; } - async getHomeys({ cache = true, local = true } = {}) { - if (cache && this._homeys) return this._homeys; + async getHomeys({ cache = true, usb = false } = {}) { + if (!cache) { + this._homeys.clear(); + } + + if (this._homeys.has(usb)) { + return this._homeys.get(usb); + } await this._initApi(); - this._user = this._user || (await this.getProfile()); - this._homeys = await this._user.getHomeys(); + this._user = await this.getProfile({ cache }); + const profileHomeys = await this._user.getHomeys(); + // USB metadata belongs to this process and mode, never to the cached account profile. + let homeys = profileHomeys.map((homey) => { + // The SDK keeps its authentication context in non-enumerable properties. + const copy = Object.create( + Object.getPrototypeOf(homey), + Object.getOwnPropertyDescriptors(homey), + ); + delete copy.usb; + return copy; + }); // find USB connected Homeys - if (local) { + if (usb) { const ifaces = os.networkInterfaces(); + const candidateIps = new Set(); for (const adapters of Object.values(ifaces)) { for (const adapter of Object.values(adapters)) { - try { - let ip = adapter.address.split('.'); - if (ip[0] !== '10') continue; - ip[3] = '1'; - ip = ip.join('.'); - - const res = await fetch(`http://${ip}/api/manager/webserver/ping`, { - signal: AbortSignal.timeout(1000), - }); - - const homeyId = res.headers.get('x-homey-id'); - if (!homeyId) continue; - - const homey = this._homeys.find((candidate) => candidate.id === homeyId); - if (homey) { - homey.usb = ip; - } - } catch (err) {} + const octets = adapter.address?.split('.') ?? []; + if (octets.length !== 4 || octets[0] !== '10') continue; + octets[3] = '1'; + candidateIps.add(octets.join('.')); } } + + // Probe concurrently: every candidate that is not a Homey costs the full timeout. + const probePromises = [...candidateIps].map(async (ip) => { + try { + const res = await fetch(`http://${ip}/api/manager/webserver/ping`, { + signal: AbortSignal.timeout(1000), + }); + + const homeyId = res.headers.get('x-homey-id'); + if (!homeyId) return; + + const homey = homeys.find((candidate) => { + return ( + candidate.id === homeyId && + candidate.platform === 'local' && + candidate.apiVersion === 3 + ); + }); + if (homey) { + homey.usb = ip; + } + } catch (err) {} + }); + await Promise.all(probePromises); + homeys = homeys.filter((homey) => { + return Boolean(homey.usb); + }); } - return this._homeys; + this._homeys.set(usb, homeys); + return homeys; } - async getActiveHomey() { - if (!this._activeHomey) { + async getActiveHomey({ usb = false } = {}) { + if (!this._activeHomey.has(usb)) { let activeHomey = await Settings.get('activeHomey'); if (activeHomey === null) { - activeHomey = await this.selectActiveHomey(); + activeHomey = await this.selectActiveHomey({ usb }); } - const homey = await this.getHomey(activeHomey.id); + const homey = await this.getHomey(activeHomey.id, { usb }); const strategy = this.discoveryStrategies ?? getPreferredActiveHomeyStrategy(homey); - const homeyApi = await homey.authenticate({ strategy }).catch((err) => { + let homeyApi; + + try { + if (usb) { + homeyApi = await HomeyUsb.authenticate(homey, { api: this._api }); + } else { + homeyApi = await homey.authenticate({ strategy }); + } + } catch (err) { if (err instanceof APIErrorHomeyOffline) { throw new Error( `${homey.name} (${homey.id}) seems to be offline. Are you sure you're in the same local network?`, ); } throw err; - }); - - if (homey.usb && !this.discoveryStrategies) { - homeyApi.__baseUrlPromise = Promise.resolve(`http://${homey.usb}:80`); } // Required when creating SDK client in App.js homeyApi.model = homey.model; - this._activeHomey = homeyApi; + this._activeHomey.set(usb, homeyApi); } - return this._activeHomey; + return this._activeHomey.get(usb); } async setActiveHomey({ id, name, platform }) { - return Settings.set('activeHomey', { id, name, platform }); + this._activeHomey.clear(); + return await Settings.set('activeHomey', { id, name, platform }); } async getSelectedHomey() { @@ -257,22 +416,30 @@ class AthomApi { } async unsetActiveHomey() { - return Settings.unset('activeHomey'); + this._activeHomey.clear(); + return await Settings.unset('activeHomey'); } async selectActiveHomey({ id, name, + usb = false, filter = { online: true, local: true, }, } = {}) { - const homeys = await this.getHomeys(); + const homeys = await this.getHomeys({ usb }); let activeHomey; + if (usb && homeys.length === 0) { + throw new Error('No USB-connected Homey found. Check the USB connection.'); + } + if (typeof id === 'string') { - activeHomey = homeys.find((homey) => homey._id === id); + activeHomey = homeys.find((homey) => { + return homey.id === id; + }); } else if (typeof name === 'string') { activeHomey = homeys.find((homey) => homey.name === name); } else { @@ -283,7 +450,8 @@ class AthomApi { message: 'Choose an active Homey:', choices: homeys .filter((homey) => { - if (filter.online && homey.state && homey.state.indexOf('online') !== 0) return false; + if (!usb && filter.online && homey.state && homey.state.indexOf('online') !== 0) + return false; return true; }) .map((homey) => ({ @@ -301,7 +469,7 @@ class AthomApi { } if (!activeHomey) { - throw new Error('No Homey found'); + throw new Error(usb ? 'No matching USB-connected Homey found.' : 'No Homey found'); } const result = await this.setActiveHomey(activeHomey); diff --git a/lib/AthomApiProfileCache.js b/lib/AthomApiProfileCache.js new file mode 100644 index 00000000..6a4f87e1 --- /dev/null +++ b/lib/AthomApiProfileCache.js @@ -0,0 +1,114 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const { mkdir, readFile, rename, rm, writeFile } = require('fs/promises'); +const path = require('path'); +const lockfile = require('proper-lockfile'); + +const Settings = require('../services/Settings'); + +class AthomApiProfileCache { + constructor() { + this._path = path.join(Settings.getSettingsDirectory(), 'profile-cache.json'); + } + + async get() { + try { + return JSON.parse(await readFile(this._path, 'utf8')); + } catch (err) { + if (err.code === 'ENOENT' || err instanceof SyntaxError) { + return null; + } + + throw err; + } + } + + async set(value) { + return await this._update((stored) => { + if ((stored?.generation ?? null) !== (value.generation ?? null)) { + return undefined; + } + + if (stored?.authKey !== value.authKey) { + return value; + } + + const profile = stored.updatedAt > value.updatedAt ? stored : value; + const merged = { ...profile }; + + if (stored.retryAfter > Date.now()) { + merged.retryAfter = stored.retryAfter; + } else { + delete merged.retryAfter; + } + + return merged; + }); + } + + async setRetryAfter({ authKey, retryAfter, generation = null }) { + return await this._update((stored) => { + if ((stored?.generation ?? null) !== generation) { + return undefined; + } + + // Never restore the pre-request snapshot after a refresh, login, or logout. + if (!stored?.user || stored.authKey !== authKey) { + return undefined; + } + + return { ...stored, retryAfter: Math.max(stored.retryAfter || 0, retryAfter) }; + }); + } + + async clear() { + await this._update(() => { + // Retain only an invalidation marker so in-flight requests cannot restore account data. + return { generation: randomUUID() }; + }); + } + + async _update(update) { + await mkdir(path.dirname(this._path), { recursive: true }); + let lockError; + const release = await lockfile.lock(this._path, { + realpath: false, + retries: { retries: 20, minTimeout: 10, maxTimeout: 100, randomize: true }, + onCompromised: (err) => { + lockError = err; + }, + }); + + try { + const stored = await this.get(); + const value = update(stored); + + if (lockError) { + throw lockError; + } + + if (value) { + await this._write(value); + } + + return value; + } finally { + await release(); + } + } + + async _write(value) { + const temporaryPath = `${this._path}.${randomUUID()}.tmp`; + + // Readers always see a complete entry while writers hold the cross-process lock. + try { + await writeFile(temporaryPath, JSON.stringify(value), { mode: 0o600 }); + await rename(temporaryPath, this._path); + } finally { + await rm(temporaryPath, { force: true }); + } + } +} + +module.exports = { AthomApiProfileCache }; diff --git a/lib/HomeyUsb.js b/lib/HomeyUsb.js new file mode 100644 index 00000000..fa3ff424 --- /dev/null +++ b/lib/HomeyUsb.js @@ -0,0 +1,51 @@ +'use strict'; + +const { HomeyAPIV3Local } = require('homey-api'); + +class HomeyUsb { + static assertSupported(homey) { + if (homey.platform !== 'local' || homey.apiVersion !== 3) { + throw new Error(`USB requires a local API-v3 Homey: ${homey.name} (${homey.id}).`); + } + } + + static getAddress(homey) { + HomeyUsb.assertSupported(homey); + + if (!homey.usb) { + throw new Error(`Homey ${homey.name} (${homey.id}) was not found over USB.`); + } + + return `http://${homey.usb}:80`; + } + + static createClient(homey, { api = null, token = null } = {}) { + const baseUrl = HomeyUsb.getAddress(homey); + const client = new HomeyAPIV3Local({ + properties: { ...homey, id: homey.id }, + api, + token, + baseUrl, + strategy: [], + }); + client.model = homey.model; + + return client; + } + + static async authenticate(homey, { api }) { + const client = HomeyUsb.createClient(homey, { api }); + + try { + await client.login(); + return client; + } catch (err) { + client.destroy(); + throw new Error(`Could not authenticate ${homey.name} (${homey.id}) over USB.`, { + cause: err, + }); + } + } +} + +module.exports = { HomeyUsb }; diff --git a/lib/UsbOption.mjs b/lib/UsbOption.mjs new file mode 100644 index 00000000..9ccada32 --- /dev/null +++ b/lib/UsbOption.mjs @@ -0,0 +1,8 @@ +export function applyUsbOption(yargs) { + return yargs.option('usb', { + type: 'boolean', + default: process.env.HOMEY_USB === '1', + global: false, + description: 'Require USB (or set HOMEY_USB=1); --no-usb uses normal network connections', + }); +} diff --git a/lib/api/ApiCommandOptions.mjs b/lib/api/ApiCommandOptions.mjs index 01724520..95b82cf1 100644 --- a/lib/api/ApiCommandOptions.mjs +++ b/lib/api/ApiCommandOptions.mjs @@ -1,4 +1,5 @@ import { DEFAULT_TIMEOUT } from './ApiCommandConstants.mjs'; +import { applyUsbOption } from '../UsbOption.mjs'; export function applyJsonOutputOption(yargs) { return yargs.option('json', { @@ -45,7 +46,7 @@ export function applyHomeyIdOption(yargs) { } export function applyHomeyApiTargetOptions(yargs) { - return applyHomeyIdOption(applyHomeyAddressOption(applyHomeyTokenOption(yargs))); + return applyUsbOption(applyHomeyIdOption(applyHomeyAddressOption(applyHomeyTokenOption(yargs)))); } export function applyHomeyApiExecutionOptions(yargs) { diff --git a/lib/api/ApiCommandRuntime.mjs b/lib/api/ApiCommandRuntime.mjs index 3d9d0077..7fbb2e7c 100644 --- a/lib/api/ApiCommandRuntime.mjs +++ b/lib/api/ApiCommandRuntime.mjs @@ -3,6 +3,7 @@ import { createRequire } from 'node:module'; import { APIErrorHomeyOffline, HomeyAPI, HomeyAPIV3Local } from 'homey-api'; import AthomApi from '../../services/AthomApi.js'; +import { HomeyUsb } from '../HomeyUsb.js'; import { DEFAULT_TIMEOUT } from './ApiCommandConstants.mjs'; const require = createRequire(import.meta.url); @@ -35,7 +36,13 @@ export function getRequestTimeout(rawTimeout) { return timeout; } -function validateAuthFlags({ token, address, homeyId }) { +function validateAuthFlags({ token, address, homeyId, usb }) { + if (usb && address) { + throw new Error( + 'Cannot combine USB mode with --address. Use --no-usb for an explicit address.', + ); + } + if (token && address && homeyId) { throw new Error( 'Invalid option usage: --address and --homey-id cannot be used together with --token.', @@ -73,12 +80,7 @@ function createTokenHomeyApi({ token, address, homeyId = 'token-homey' }) { }); } -function applyResolvedHomeyMetadata(homeyApi, homey, { useUsb = true } = {}) { - if (homey.usb && useUsb) { - // Keep USB override behavior in sync with existing AthomApi implementation. - homeyApi.__baseUrlPromise = Promise.resolve(`http://${homey.usb}:80`); - } - +function applyResolvedHomeyMetadata(homeyApi, homey) { homeyApi.model = homey.model; return homeyApi; } @@ -127,9 +129,9 @@ function getStrategyTarget(homey, strategyId) { } } -export async function resolveRequestedHomey(homeyId) { +export async function resolveRequestedHomey(homeyId, { usb = false } = {}) { if (typeof homeyId === 'string' && homeyId.length > 0) { - return AthomApi.getHomey(homeyId); + return await AthomApi.getHomey(homeyId, { usb }); } const activeHomey = await AthomApi.getSelectedHomey(); @@ -138,7 +140,7 @@ export async function resolveRequestedHomey(homeyId) { throw new Error('No active Homey selected. Run `homey select` to choose one.'); } - return AthomApi.getHomey(activeHomey.id); + return await AthomApi.getHomey(activeHomey.id, { usb }); } function normalizeOfflineError(homey) { @@ -147,14 +149,18 @@ function normalizeOfflineError(homey) { ); } -async function authenticateHomey(homey, strategy = getPreferredAuthenticateStrategy(homey)) { +async function authenticateHomey(homey, { usb = false } = {}) { try { + if (usb) { + const api = await AthomApi._initApi(); + + return await HomeyUsb.authenticate(homey, { api }); + } + const homeyApi = await homey.authenticate({ - strategy, - }); - return applyResolvedHomeyMetadata(homeyApi, homey, { - useUsb: !AthomApi.discoveryStrategies, + strategy: getPreferredAuthenticateStrategy(homey), }); + return applyResolvedHomeyMetadata(homeyApi, homey); } catch (err) { if (err instanceof APIErrorHomeyOffline) { throw normalizeOfflineError(homey); @@ -173,10 +179,6 @@ function normalizeDiagnosticError(err, homey) { } function getTokenModeAddressForHomey(homey) { - if (homey.usb) { - return `http://${homey.usb}:80`; - } - if (homey.localUrlSecure) { return homey.localUrlSecure; } @@ -189,8 +191,13 @@ function getTokenModeAddressForHomey(homey) { 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); +async function createTokenHomeyApiForHomey({ token, homeyId, usb }) { + const homey = await AthomApi.getHomey(homeyId, { usb }); + + if (usb) { + return HomeyUsb.createClient(homey, { token }); + } + const homeyApi = createTokenHomeyApi({ token, address: getTokenModeAddressForHomey(homey), @@ -200,25 +207,25 @@ async function createTokenHomeyApiForHomey({ token, homeyId }) { return applyResolvedHomeyMetadata(homeyApi, homey); } -export async function createHomeyApiClient({ token, address, homeyId }) { - validateAuthFlags({ token, address, homeyId }); +export async function createHomeyApiClient({ token, address, homeyId, usb = false }) { + validateAuthFlags({ token, address, homeyId, usb }); if (token && address) { return createTokenHomeyApi({ token, address }); } if (token && homeyId) { - return createTokenHomeyApiForHomey({ token, homeyId }); + return await createTokenHomeyApiForHomey({ token, homeyId, usb }); } - const homey = await resolveRequestedHomey(homeyId); - return authenticateHomey(homey); + const homey = await resolveRequestedHomey(homeyId, { usb }); + return await authenticateHomey(homey, { usb }); } -export async function diagnoseHomeyStrategies({ homeyId } = {}) { - const homey = await resolveRequestedHomey(homeyId); - const preferredStrategyIds = getPreferredAuthenticateStrategy(homey); - const attemptedStrategyIds = getDiagnoseStrategyOrder(homey); +export async function diagnoseHomeyStrategies({ homeyId, usb = false } = {}) { + let homey = await resolveRequestedHomey(homeyId); + const preferredStrategyIds = usb ? ['usb'] : getPreferredAuthenticateStrategy(homey); + const attemptedStrategyIds = usb ? ['usb'] : getDiagnoseStrategyOrder(homey); const results = []; for (const strategyId of attemptedStrategyIds) { @@ -238,9 +245,15 @@ export async function diagnoseHomeyStrategies({ homeyId } = {}) { } try { - api = await homey.authenticate({ - strategy: [strategyId], - }); + if (usb) { + homey = await AthomApi.getHomey(homey.id, { usb: true }); + api = await authenticateHomey(homey, { usb: true }); + await api.sessions.getSessionMe({ $socket: false }); + } else { + api = await homey.authenticate({ + strategy: [strategyId], + }); + } results.push({ strategyId, diff --git a/lib/api/ApiManagerCommand.mjs b/lib/api/ApiManagerCommand.mjs index 0db4b4c9..b378c066 100644 --- a/lib/api/ApiManagerCommand.mjs +++ b/lib/api/ApiManagerCommand.mjs @@ -119,6 +119,7 @@ function createExecuteOperation(managerDefinition) { return async function executeOperation(argv, operation) { const timeout = getRequestTimeout(argv.timeout); const api = await createHomeyApiClient({ + usb: argv.usb, token: argv.token, address: argv.address, homeyId: argv.homeyId, diff --git a/package-lock.json b/package-lock.json index 60ccfe7e..e1f6c86e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "open": "^8.4.2", "openai": "^4.52.7", "p-queue": "^6.6.2", + "proper-lockfile": "^4.1.2", "semver": "^7.6.0", "sharp": "^0.33.4", "smol-toml": "^1.6.0", @@ -4761,6 +4762,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -4946,6 +4958,15 @@ "node": ">=8" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", diff --git a/package.json b/package.json index bf0a9e19..8954e6ed 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "open": "^8.4.2", "openai": "^4.52.7", "p-queue": "^6.6.2", + "proper-lockfile": "^4.1.2", "semver": "^7.6.0", "sharp": "^0.33.4", "smol-toml": "^1.6.0", diff --git a/tests/app/run-install.test.mjs b/tests/app/run-install.test.mjs index 92a338f9..b5581189 100644 --- a/tests/app/run-install.test.mjs +++ b/tests/app/run-install.test.mjs @@ -12,6 +12,32 @@ import { copyFixtureApp } from './helpers.mjs'; import { createFakeHomey } from './fakes.mjs'; describe('app run characterization', () => { + for (const AppClass of [App, AppPython]) { + for (const remote of [false, true]) { + it(`keeps USB mode through ${AppClass.name} ${remote ? 'remote' : 'Docker'} execution and cleanup`, async (t) => { + const fixture = AppClass === AppPython ? 'python-basic' : 'node-basic'; + const appPath = await copyFixtureApp(t, fixture); + const app = new AppClass(appPath); + const { homey, calls } = createFakeHomey(); + const resolve = t.mock.method(AthomApi, 'getActiveHomey', async (options) => { + assert.deepStrictEqual(options, { usb: true }); + return homey; + }); + t.mock.method(app, remote ? 'runRemote' : 'runDocker', async (options) => { + assert.strictEqual(options.homey, homey); + app._session = { appId: 'test-app', session: 'session-1' }; + }); + t.mock.method(process, 'exit', () => {}); + + await app.run({ usb: true, remote }); + await app._onCtrlC(); + + assert.strictEqual(resolve.mock.callCount(), 2); + assert.deepStrictEqual(calls.stopApp, [{ session: 'session-1' }]); + }); + } + } + it('routes a local Homey to the Docker runner with all options', async (t) => { const appPath = await copyFixtureApp(t, 'node-basic'); const app = new App(appPath); diff --git a/tests/cli/app-handlers.test.mjs b/tests/cli/app-handlers.test.mjs index 0536765b..f947455f 100644 --- a/tests/cli/app-handlers.test.mjs +++ b/tests/cli/app-handlers.test.mjs @@ -93,6 +93,7 @@ describe('CLI app handler characterization', () => { await runHandler({ path: '/fixture/app', + usb: true, clean: true, remote: true, skipBuild: true, @@ -105,6 +106,7 @@ describe('CLI app handler characterization', () => { assert.deepStrictEqual(calls, [ { + usb: true, clean: true, remote: true, skipBuild: true, @@ -123,7 +125,8 @@ describe('CLI app handler characterization', () => { const calls = []; const exits = captureExit(t); - t.mock.method(AthomApi, 'getActiveHomey', async () => { + t.mock.method(AthomApi, 'getActiveHomey', async (options) => { + assert.deepStrictEqual(options, { usb: true }); return homey; }); t.mock.method(AppFactory, 'getAppInstance', () => { @@ -134,7 +137,7 @@ describe('CLI app handler characterization', () => { }; }); - await installHandler({ path: '/fixture/app', clean: true, skipBuild: true }); + await installHandler({ path: '/fixture/app', clean: true, skipBuild: true, usb: true }); assert.deepStrictEqual(calls, [{ homey, clean: true, skipBuild: true }]); assert.deepStrictEqual(exits, [0]); diff --git a/tests/cli/select.test.mjs b/tests/cli/select.test.mjs index 2ece3221..d49a5f9f 100644 --- a/tests/cli/select.test.mjs +++ b/tests/cli/select.test.mjs @@ -31,6 +31,7 @@ describe('CLI select', () => { assert.deepStrictEqual(selectionArgs, { id: 'homey-1', name: undefined, + usb: undefined, }); }); @@ -51,6 +52,7 @@ describe('CLI select', () => { assert.deepStrictEqual(selectionArgs, { id: undefined, name: 'Living Room', + usb: undefined, }); }); diff --git a/tests/cli/usb.test.mjs b/tests/cli/usb.test.mjs new file mode 100644 index 00000000..cb4c3d43 --- /dev/null +++ b/tests/cli/usb.test.mjs @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, it } from 'node:test'; + +import { createIsolatedHomeyHome, removeHomeyHome, runHomey, assertSuccess } from './helpers.mjs'; + +function createUsbFixture(t) { + const token = 'test-oauth-token'; + const directory = createIsolatedHomeyHome({ + activeHomey: { id: 'homey-1', name: 'USB Homey', platform: 'local' }, + homeyApi: { + token: { access_token: token }, + 'homey-homey-1': { token: 'homey-token', session: { id: 'session-1' } }, + }, + }); + t.after(() => { + removeHomeyHome(directory); + }); + const profile = { + _id: 'user-1', + devices: [], + homeys: ['homey-1', 'homey-2'].map((id) => { + return { + _id: id, + name: id, + platform: 'local', + apiVersion: 3, + softwareVersion: '12.0.0', + state: 'offline', + }; + }), + }; + writeFileSync( + path.join(directory, 'profile-cache.json'), + JSON.stringify({ + user: profile, + authKey: `oauth:${createHash('sha256').update(token).digest('hex')}`, + updatedAt: Date.now(), + }), + ); + const logPath = path.join(directory, 'requests.jsonl'); + writeFileSync(logPath, ''); + const hookPath = path.join(directory, 'network.mjs'); + writeFileSync( + hookPath, + ` + import os from 'node:os'; + import { appendFileSync } from 'node:fs'; + import { createRequire } from 'node:module'; + const require = createRequire(process.cwd() + '/package.json'); + os.networkInterfaces = () => { + return { eth0: [{ address: '10.0.0.2' }, { address: '10.0.0.3' }], vpn: [{ address: '10.1.0.2' }] }; + }; + globalThis.fetch = async (input) => { + const url = new URL(input); + appendFileSync(process.env.USB_TEST_LOG, JSON.stringify(url.href) + '\\n'); + if (url.pathname === '/api/manager/webserver/ping') { + if (process.env.USB_TEST_DISCONNECTED === '1') throw new Error('USB disconnected'); + return new Response(null, { headers: { 'x-homey-id': url.hostname === '10.0.0.1' ? 'homey-1' : 'unknown' } }); + } + if (url.hostname !== '10.0.0.1') throw new Error('Unexpected non-USB request: ' + url.hostname); + if (process.env.USB_TEST_FAIL === '1') throw new Error('USB disconnected'); + return new Response(JSON.stringify({ id: 'session-1', via: 'usb' }), { headers: { 'content-type': 'application/json' } }); + }; + require('homey-api/lib/Util').fetch = globalThis.fetch; + `, + ); + return { + directory, + env: { + HOMEY_PAT: '', + HOMEY_USB: '', + NODE_OPTIONS: `--import=${pathToFileURL(hookPath).href}`, + USB_TEST_LOG: logPath, + }, + requests() { + const data = readFileSync(logPath, 'utf8').trim(); + if (!data) return []; + return data.split('\n').map((line) => { + return JSON.parse(line); + }); + }, + }; +} + +describe('CLI USB mode', () => { + for (const scenario of [ + { name: 'default', env: '', flags: [], count: 2, probes: 0 }, + { name: 'explicit flag', env: '', flags: ['--usb'], count: 1, probes: 2 }, + { name: 'shell default', env: '1', flags: [], count: 1, probes: 2 }, + { name: 'explicit opt-out', env: '1', flags: ['--no-usb'], count: 2, probes: 0 }, + { + name: 'explicit opt-in over disabled shell', + env: '0', + flags: ['--usb'], + count: 1, + probes: 2, + }, + ]) { + it(`applies ${scenario.name} to list`, (t) => { + const fixture = createUsbFixture(t); + const result = runHomey(['list', '--json', ...scenario.flags], fixture.directory, { + env: { ...fixture.env, HOMEY_USB: scenario.env }, + }); + assertSuccess(result, 'list'); + const homeys = JSON.parse(result.stdout); + assert.equal(homeys.length, scenario.count); + assert.equal(fixture.requests().length, scenario.probes); + if (scenario.probes) assert.equal(homeys[0].usbAddress, '10.0.0.1'); + }); + } + + it('selects an offline USB Homey without persisting USB mode', (t) => { + const fixture = createUsbFixture(t); + const result = runHomey(['select', '--usb', '--id', 'homey-1'], fixture.directory, { + env: fixture.env, + }); + assertSuccess(result, 'select --usb'); + const settings = JSON.parse( + readFileSync(path.join(fixture.directory, 'settings.json'), 'utf8'), + ); + assert.deepEqual(settings.activeHomey, { id: 'homey-1', name: 'homey-1', platform: 'local' }); + }); + + for (const command of [ + ['api', 'system', 'get-info'], + ['api', 'system', 'get-info', '--discovery-strategies', 'cloud'], + ['api', 'system', 'get-info', '--token', 'test', '--homey-id', 'homey-1'], + ['api', 'raw', '--path', '/api/manager/system/'], + ['api', 'call', '--path', '/api/manager/system/'], + ['api', 'request', '--path', '/api/manager/system/'], + ]) { + it(`routes ${command.join(' ')} through USB`, (t) => { + const fixture = createUsbFixture(t); + const result = runHomey([...command, '--usb', '--json'], fixture.directory, { + env: fixture.env, + }); + assertSuccess(result, command.join(' ')); + assert.equal(JSON.parse(result.stdout).via, 'usb'); + assert.ok(fixture.requests().includes('http://10.0.0.1/api/manager/system/')); + }); + } + + for (const failed of [false, true]) { + it(`reports USB-only diagnostic ${failed ? 'failure' : 'success'}`, (t) => { + const fixture = createUsbFixture(t); + const result = runHomey(['api', 'diagnose', '--usb', '--json'], fixture.directory, { + env: { ...fixture.env, USB_TEST_FAIL: failed ? '1' : '' }, + }); + assert.equal(result.status, failed ? 1 : 0, result.stdout + result.stderr); + const report = JSON.parse(result.stdout); + assert.deepEqual(report.attemptedStrategyIds, ['usb']); + assert.equal(report.results.length, 1); + assert.equal(report.results[0].status, failed ? 'failed' : 'available'); + }); + } + + it('reports a disconnected USB device as a failed diagnostic attempt', (t) => { + const fixture = createUsbFixture(t); + const result = runHomey(['api', 'diagnose', '--usb', '--json'], fixture.directory, { + env: { ...fixture.env, USB_TEST_DISCONNECTED: '1' }, + }); + + assert.equal(result.status, 1, result.stdout + result.stderr); + const report = JSON.parse(result.stdout); + assert.deepEqual(report.preferredStrategyIds, ['usb']); + assert.deepEqual(report.attemptedStrategyIds, ['usb']); + assert.deepEqual(report.availableStrategyIds, []); + assert.equal(report.selectedStrategyId, null); + assert.equal(report.selectedBaseUrl, null); + assert.equal(report.target.id, 'homey-1'); + assert.equal(report.target.usb, null); + assert.equal(report.results.length, 1); + assert.equal(report.results[0].strategyId, 'usb'); + assert.equal(report.results[0].status, 'failed'); + assert.equal(report.results[0].available, false); + assert.match(report.results[0].error, /not found over USB/); + assert.equal(fixture.requests().length, 2); + }); + + it('rejects USB mode with an explicit address before any network call', (t) => { + const fixture = createUsbFixture(t); + const result = runHomey( + ['api', 'system', 'get-info', '--usb', '--token', 'test', '--address', 'http://localhost'], + fixture.directory, + { env: fixture.env }, + ); + assert.equal(result.status, 1); + assert.match(result.stdout + result.stderr, /Cannot combine USB mode with --address/); + assert.deepEqual(fixture.requests(), []); + }); + + it('allows an explicit address with --no-usb despite the shell default', (t) => { + const fixture = createUsbFixture(t); + const result = runHomey( + [ + 'api', + 'system', + 'get-info', + '--no-usb', + '--token', + 'test', + '--address', + 'http://10.0.0.1', + '--json', + ], + fixture.directory, + { + env: { ...fixture.env, HOMEY_USB: '1' }, + }, + ); + assertSuccess(result, 'api --no-usb --address'); + assert.deepEqual(fixture.requests(), ['http://10.0.0.1/api/manager/system/']); + }); + + for (const command of [['whoami'], ['select', 'current'], ['api', 'schema'], ['app', 'build']]) { + it(`does not expose USB mode on ${command.join(' ')}`, (t) => { + const fixture = createUsbFixture(t); + const result = runHomey([...command, '--help'], fixture.directory, { env: fixture.env }); + assertSuccess(result, command.join(' ')); + assert.doesNotMatch(result.stdout, /--usb/); + }); + } +}); diff --git a/tests/lib/api-command-runtime.test.mjs b/tests/lib/api-command-runtime.test.mjs index 0fd6fc4b..59dcd6fe 100644 --- a/tests/lib/api-command-runtime.test.mjs +++ b/tests/lib/api-command-runtime.test.mjs @@ -64,7 +64,7 @@ describe('ApiCommandRuntime createHomeyApiClient', () => { ], }, ]); - assert.strictEqual(await result.__baseUrlPromise, 'http://10.0.0.1:80'); + assert.strictEqual(result.__baseUrlPromise, undefined); assert.strictEqual(result.model, 'Homey Pro'); }); @@ -83,7 +83,7 @@ describe('ApiCommandRuntime createHomeyApiClient', () => { ); }); - it('prefers the usb address for token mode when resolving by Homey id', async () => { + it('uses USB for token mode only when explicitly requested', async () => { mock.method(AthomApi, 'getHomey', async (homeyId) => { assert.strictEqual(homeyId, 'target-homey'); @@ -92,6 +92,8 @@ describe('ApiCommandRuntime createHomeyApiClient', () => { name: 'Office Homey', model: 'Homey Pro', usb: '10.0.0.1', + platform: 'local', + apiVersion: 3, localUrlSecure: 'https://192.168.1.20', localUrl: 'http://192.168.1.20', }; @@ -100,10 +102,12 @@ describe('ApiCommandRuntime createHomeyApiClient', () => { const result = await createHomeyApiClient({ token: 'abc', homeyId: 'target-homey', + usb: true, }); assert.ok(result instanceof HomeyAPIV3Local); assert.strictEqual(await result.baseUrl, 'http://10.0.0.1:80'); + result.destroy(); assert.strictEqual(result.model, 'Homey Pro'); }); diff --git a/tests/lib/athom-api.cache.test.mjs b/tests/lib/athom-api.cache.test.mjs new file mode 100644 index 00000000..faddf1e5 --- /dev/null +++ b/tests/lib/athom-api.cache.test.mjs @@ -0,0 +1,675 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; +import { promisify } from 'node:util'; + +import { APIError, AthomCloudAPI, HomeyAPIV3Local } from 'homey-api'; + +import AthomApi from '../../lib/AthomApi.js'; +import { AthomApiProfileCache } from '../../lib/AthomApiProfileCache.js'; +import SettingsStore from '../../lib/Settings.js'; +import Settings from '../../services/Settings.js'; +import Log from '../../lib/Log.js'; + +const execFileAsync = promisify(execFile); + +let directory; +let settings; +let now; +let originalPat; + +const profile = { + _id: 'user-1', + firstname: 'Test', + roleIds: ['app_developer_trusted'], + devices: [], + homeys: [ + { + _id: 'homey-1', + name: 'Homey One', + platform: 'local', + apiVersion: 3, + localUrl: 'http://192.168.1.100', + }, + ], +}; + +beforeEach(async () => { + originalPat = process.env.HOMEY_PAT; + delete process.env.HOMEY_PAT; + directory = await mkdtemp(path.join(os.tmpdir(), 'homey-profile-cache-')); + settings = new SettingsStore(); + settings._settingsPath = path.join(directory, 'settings.json'); + now = 1_000_000; + + mock.method(Date, 'now', () => { + return now; + }); + mock.method(Settings, 'get', async (key) => { + return await settings.get(key); + }); + mock.method(Settings, 'set', async (key, value) => { + return await settings.set(key, value); + }); + mock.method(Settings, 'unset', async (key) => { + return await settings.unset(key); + }); + mock.method(Settings, 'getSettingsDirectory', () => { + return directory; + }); + mock.method(os, 'networkInterfaces', () => { + return {}; + }); + await settings.set('homeyApi', { token: { access_token: 'oauth-token-one' } }); +}); + +afterEach(async () => { + mock.restoreAll(); + + if (originalPat === undefined) { + delete process.env.HOMEY_PAT; + } else { + process.env.HOMEY_PAT = originalPat; + } + + await rm(directory, { recursive: true, force: true }); +}); + +function createClient(response = profile) { + // Re-read the real settings file, as a new CLI process would. + settings._settings = null; + const client = new AthomApi(); + client._createApi(); + const request = mock.method(client._api, 'call', async ({ path: requestPath }) => { + assert.equal(requestPath, '/user/me'); + + if (response instanceof Error) { + throw response; + } + + return structuredClone(response); + }); + + return { client, request }; +} + +describe('AthomApi persistent profile cache', () => { + it('reuses SDK profiles and Homeys from disk without another cloud request', async () => { + await settings.set('homeyApi', { token: { access_token: 'stored-token' } }); + const first = createClient(); + await first.client.getHomeys({ usb: false }); + + const next = createClient(new Error('Cloud must not be called')); + const cached = await next.client.getProfile(); + const homeys = await next.client.getHomeys({ usb: false }); + + assert.equal(first.request.mock.callCount(), 1); + assert.equal(next.request.mock.callCount(), 0); + assert.equal(cached.id, 'user-1'); + assert.equal(cached.hasRole('app_developer_trusted'), true); + assert.ok(homeys[0] instanceof AthomCloudAPI.Homey); + assert.equal(homeys[0].localUrl, 'http://192.168.1.100'); + assert.equal((await settings.get('homeyApi')).token.access_token, 'stored-token'); + }); + + it('refreshes expired data and persists the updated Homey details', async () => { + await createClient().client.getProfile(); + now += 5 * 60 * 1000; + + const updated = structuredClone(profile); + updated.homeys[0].localUrl = 'http://192.168.1.101'; + const next = createClient(updated); + const homey = await next.client.getHomey('homey-1'); + + assert.equal(next.request.mock.callCount(), 1); + assert.equal(homey.localUrl, updated.homeys[0].localUrl); + assert.equal((await next.client._profileCache.get()).updatedAt, now); + }); + + it('continues Homey execution on 429 and persists a cooldown across invocations', async () => { + await createClient().client.getProfile(); + await settings.set('activeHomey', { id: 'homey-1' }); + await settings.set('homeyApi', { + ...(await settings.get('homeyApi')), + 'homey-homey-1': { token: 'homey-session-token', session: { id: 'session-1' } }, + }); + now += 5 * 60 * 1000; + + mock.method(HomeyAPIV3Local.prototype, 'discoverBaseUrl', async function () { + assert.equal(this.id, 'homey-1'); + return { baseUrl: 'http://192.168.1.100' }; + }); + mock.method(HomeyAPIV3Local.prototype, 'call', async ({ path: requestPath }) => { + assert.equal(requestPath, '/api/manager/system/'); + return { hostname: 'homey' }; + }); + const limited = createClient(new APIError('Too Many Requests', 429)); + const api = await limited.client.getActiveHomey(); + + assert.ok(api instanceof HomeyAPIV3Local); + assert.deepEqual(await api.system.getInfo({ $socket: false }), { hostname: 'homey' }); + assert.equal(limited.request.mock.callCount(), 1); + + const next = createClient(new Error('Do not retry during cooldown')); + assert.equal((await next.client.getProfile({ cache: false })).id, 'user-1'); + assert.equal(next.request.mock.callCount(), 0); + + now += 60 * 1000; + const recovered = createClient(); + await recovered.client.getProfile(); + assert.equal(recovered.request.mock.callCount(), 1); + assert.equal((await recovered.client._profileCache.get()).retryAfter, undefined); + }); + + it('propagates 429 when there is no cached profile', async () => { + const error = new APIError('Too Many Requests', 429); + const { client, request } = createClient(error); + + await assert.rejects(client.getProfile(), error); + assert.equal(request.mock.callCount(), 1); + }); + + for (const statusCode of [401, 403, 500]) { + it(`does not hide HTTP ${statusCode} behind cached data`, async () => { + await createClient().client.getProfile(); + now += 5 * 60 * 1000; + const error = new APIError('Request failed', statusCode); + + await assert.rejects(createClient(error).client.getProfile(), error); + }); + } + + it('refreshes both the SDK user and Homey list when cache is false', async () => { + const { client, request } = createClient(); + await client.getHomeys({ usb: false }); + request.mock.mockImplementation(async () => { + return { ...structuredClone(profile), homeys: [] }; + }); + + assert.deepEqual(await client.getHomeys({ cache: false, usb: false }), []); + assert.equal(request.mock.callCount(), 2); + }); + + it('falls back on 429 even when a refresh was explicitly requested', async () => { + await createClient().client.getProfile(); + const limited = createClient(new APIError('Too Many Requests', 429)); + + assert.equal((await limited.client.getProfile({ cache: false })).id, 'user-1'); + assert.equal(limited.request.mock.callCount(), 1); + }); + + it('does not reuse an OAuth profile with a PAT, or across different PATs', async () => { + await createClient().client.getProfile(); + process.env.HOMEY_PAT = 'test-pat-one'; + const error = new APIError('Too Many Requests', 429); + + await assert.rejects(createClient(error).client.getProfile(), error); + await createClient().client.getProfile(); + assert.equal((await createClient(error).client.getProfile()).id, 'user-1'); + + const persisted = await readFile(path.join(directory, 'profile-cache.json'), 'utf8'); + assert.ok(!persisted.includes('test-pat-one')); + process.env.HOMEY_PAT = 'test-pat-two'; + await assert.rejects(createClient(error).client.getProfile(), error); + + await createClient().client.getProfile(); + delete process.env.HOMEY_PAT; + await assert.rejects(createClient(error).client.getProfile(), error); + }); + + it('uses the OAuth cache identity after authorization-code login with a PAT configured', async () => { + process.env.HOMEY_PAT = 'test-pat-one'; + const { client, request } = createClient(); + await client.getProfile(); + mock.method(client._api, 'authenticateWithAuthorizationCode', async ({ code }) => { + assert.equal(code, 'test-authorization-code'); + const token = { access_token: 'oauth-token-two' }; + await settings.set('homeyApi', { token }); + return token; + }); + + await client._authenticateWithAuthorizationCode({ code: 'test-authorization-code' }); + assert.equal((await client._profileCache.get()).user, undefined); + request.mock.mockImplementation(async () => { + return { ...structuredClone(profile), _id: 'oauth-user' }; + }); + assert.equal((await client.getProfile()).id, 'oauth-user'); + + delete process.env.HOMEY_PAT; + const oauth = createClient(new Error('OAuth profile should be cached')); + assert.equal((await oauth.client.getProfile()).id, 'oauth-user'); + assert.equal(oauth.request.mock.callCount(), 0); + + process.env.HOMEY_PAT = 'test-pat-one'; + const pat = createClient(); + assert.equal((await pat.client.getProfile()).id, 'user-1'); + assert.equal(pat.request.mock.callCount(), 1); + }); + + for (const rateLimited of [false, true]) { + it(`preserves another process's settings during ${rateLimited ? 'rate-limit fallback' : 'profile refresh'}`, async () => { + await settings.set('homeyApi', { token: { access_token: 'original-token' } }); + await settings.set('activeHomey', { id: 'homey-1' }); + await createClient().client.getProfile(); + now += 5 * 60 * 1000; + const { client, request } = createClient(); + // Load this process's settings snapshot before the other process changes it. + await settings.get('homeyApi'); + const otherSettings = new SettingsStore(); + otherSettings._settingsPath = settings._settingsPath; + const requestStarted = Promise.withResolvers(); + const response = Promise.withResolvers(); + request.mock.mockImplementation(async () => { + requestStarted.resolve(); + return await response.promise; + }); + + const pendingProfile = client.getProfile(); + await requestStarted.promise; + await otherSettings.set('activeHomey', { id: 'homey-2' }); + await otherSettings.set('homeyApi', { token: { access_token: 'new-token' } }); + + if (rateLimited) { + response.reject(new APIError('Too Many Requests', 429)); + } else { + response.resolve(structuredClone(profile)); + } + + assert.equal((await pendingProfile).id, 'user-1'); + const persisted = JSON.parse(await readFile(settings._settingsPath, 'utf8')); + assert.equal(persisted.activeHomey.id, 'homey-2'); + assert.equal(persisted.homeyApi.token.access_token, 'new-token'); + }); + } + + it('publishes complete profile entries when cache writers overlap', async () => { + const entries = Array.from({ length: 20 }, (_, index) => { + return { + authKey: `auth-${index}`, + user: { _id: `user-${index}`, name: 'x'.repeat(index * 1000) }, + }; + }); + const writePromises = entries.map((entry) => { + return new AthomApiProfileCache().set(entry); + }); + await Promise.all(writePromises); + + const persisted = await new AthomApiProfileCache().get(); + const expected = entries.find((entry) => { + return entry.authKey === persisted.authKey; + }); + assert.deepEqual(persisted, expected); + assert.deepEqual((await readdir(directory)).sort(), ['profile-cache.json', 'settings.json']); + }); + + it('refreshes a corrupt cache without touching account settings', async () => { + await settings.set('homeyApi', { token: { access_token: 'stored-token' } }); + await writeFile(path.join(directory, 'profile-cache.json'), '{'); + const { client, request } = createClient(); + + assert.equal((await client.getProfile()).id, 'user-1'); + assert.equal(request.mock.callCount(), 1); + assert.deepEqual(await settings.get('homeyApi'), { token: { access_token: 'stored-token' } }); + }); + + for (const rateLimited of [false, true]) { + it(`isolates a new OAuth login from an old pending ${rateLimited ? '429' : 'success'}`, async () => { + const previous = createClient(); + await previous.client.getProfile(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + previous.request.mock.mockImplementation(async () => { + started.resolve(); + return await response.promise; + }); + + const pending = previous.client.getProfile({ cache: false }); + await started.promise; + const otherProfile = { ...structuredClone(profile), _id: 'other-account' }; + const current = createClient(otherProfile); + mock.method(current.client._api, 'authenticateWithAuthorizationCode', async () => { + const token = { access_token: 'other-account-token' }; + await settings.set('homeyApi', { token }); + return token; + }); + await current.client._authenticateWithAuthorizationCode({ code: 'other-account-code' }); + await current.client.getProfile(); + + if (rateLimited) { + response.reject(new APIError('Too Many Requests', 429)); + } else { + response.resolve(structuredClone(profile)); + } + + assert.equal((await pending).id, profile._id); + const next = createClient(new Error('The new account profile should remain cached')); + assert.equal((await next.client.getProfile()).id, 'other-account'); + assert.equal(next.request.mock.callCount(), 0); + const persisted = await readFile(path.join(directory, 'profile-cache.json'), 'utf8'); + assert.ok(!persisted.includes('other-account-token')); + assert.ok(!persisted.includes('oauth-token-one')); + }); + } + + for (const rateLimited of [false, true]) { + it(`rejects a pending ${rateLimited ? 'cooldown' : 'profile'} update after the same credential starts a new cache generation`, async () => { + const previous = createClient(); + await previous.client.getProfile(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + previous.request.mock.mockImplementation(async () => { + started.resolve(); + return await response.promise; + }); + const pending = previous.client.getProfile({ cache: false }); + await started.promise; + + const updated = structuredClone(profile); + updated.homeys[0].localUrl = 'http://192.168.1.101'; + const current = createClient(updated); + await current.client._profileCache.clear(); + const marker = await current.client._profileCache.get(); + assert.deepEqual(Object.keys(marker), ['generation']); + assert.equal(typeof marker.generation, 'string'); + await current.client.getProfile(); + + if (rateLimited) { + response.reject(new APIError('Too Many Requests', 429)); + } else { + response.resolve(structuredClone(profile)); + } + await pending; + + const stored = await current.client._profileCache.get(); + assert.deepEqual(stored.user, updated); + assert.equal(stored.generation, marker.generation); + assert.equal(stored.retryAfter, undefined); + }); + } + + it('discards a pending cache write when login changes the same client', async () => { + const { client, request } = createClient(); + await client.getProfile(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + request.mock.mockImplementation(async () => { + started.resolve(); + return await response.promise; + }); + const pending = client.getProfile({ cache: false }); + await started.promise; + mock.method(client._api, 'authenticateWithAuthorizationCode', async () => { + return { access_token: 'new-account-token' }; + }); + await client._authenticateWithAuthorizationCode({ code: 'new-account-code' }); + response.resolve(structuredClone(profile)); + await pending; + + assert.equal((await client._profileCache.get()).user, undefined); + assert.equal((await client._profileCache.get()).authKey, undefined); + request.mock.mockImplementation(async () => { + return { ...structuredClone(profile), _id: 'new-account' }; + }); + assert.equal((await client.getProfile()).id, 'new-account'); + }); + + it('keeps newer Homey details when an older profile request finishes last', async () => { + const older = createClient(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + older.request.mock.mockImplementation(async () => { + started.resolve(); + return await response.promise; + }); + + const pending = older.client.getProfile({ cache: false }); + await started.promise; + + now += 1000; + const updated = structuredClone(profile); + updated.homeys[0].localUrl = 'http://192.168.1.101'; + const newer = createClient(updated); + const newerRequestStartedAt = now; + await newer.client.getProfile({ cache: false }); + + now += 1000; + response.resolve(structuredClone(profile)); + await pending; + + const stored = await newer.client._profileCache.get(); + assert.deepEqual(stored.user, updated); + assert.equal(stored.updatedAt, newerRequestStartedAt); + + const next = createClient(new Error('The newer profile should remain cached')); + const homey = await next.client.getHomey('homey-1'); + assert.equal(homey.localUrl, updated.homeys[0].localUrl); + assert.equal(next.request.mock.callCount(), 0); + }); + + for (const successFirst of [true, false]) { + it(`preserves the refreshed profile and cooldown when ${successFirst ? 'success' : '429'} finishes first`, async () => { + await createClient().client.getProfile(); + now += 5 * 60 * 1000; + const fresh = createClient(); + const limited = createClient(); + const freshStarted = Promise.withResolvers(); + const limitedStarted = Promise.withResolvers(); + const freshResponse = Promise.withResolvers(); + const limitedResponse = Promise.withResolvers(); + fresh.request.mock.mockImplementation(async () => { + freshStarted.resolve(); + return await freshResponse.promise; + }); + limited.request.mock.mockImplementation(async () => { + limitedStarted.resolve(); + return await limitedResponse.promise; + }); + const freshPending = fresh.client.getProfile(); + const limitedPending = limited.client.getProfile(); + await Promise.all([freshStarted.promise, limitedStarted.promise]); + const updated = { ...structuredClone(profile), homeys: [] }; + const rateLimitError = new APIError('Too Many Requests', 429); + + if (successFirst) { + freshResponse.resolve(updated); + await freshPending; + limitedResponse.reject(rateLimitError); + const fallback = await limitedPending; + + assert.deepEqual(await fallback.getHomeys(), []); + } else { + limitedResponse.reject(rateLimitError); + await limitedPending; + freshResponse.resolve(updated); + await freshPending; + } + + const stored = await fresh.client._profileCache.get(); + assert.deepEqual(stored.user, updated); + assert.equal(stored.updatedAt, now); + assert.equal(stored.retryAfter, now + 60 * 1000); + const next = createClient(new Error('Must respect the concurrent cooldown')); + const nextProfile = await next.client.getProfile({ cache: false }); + + assert.deepEqual(await nextProfile.getHomeys(), []); + assert.equal(next.request.mock.callCount(), 0); + }); + } + + it('merges updates from separate processes under the same cache lock', async () => { + const cache = new AthomApiProfileCache(); + await cache.set({ user: profile, authKey: 'shared', updatedAt: 0 }); + const retryAfter = Date.now() + 60 * 1000; + const worker = ` + const { AthomApiProfileCache } = require('./lib/AthomApiProfileCache'); + const cache = new AthomApiProfileCache(); + async function update() { + for (let index = 1; index <= 20; index++) { + if (process.argv[1] === 'profile') { + await cache.set({ user: { _id: 'updated' }, authKey: 'shared', updatedAt: index }); + } else { + await cache.setRetryAfter({ authKey: 'shared', retryAfter: Number(process.argv[2]) }); + } + } + } + // Keep the parent test's clock so cooldown expiry is deterministic. + Date.now = () => { return Number(process.argv[3]); }; + update().catch((err) => { console.error(err); process.exitCode = 1; }); + `; + const workers = ['profile', 'cooldown'].map((operation) => { + return execFileAsync( + process.execPath, + ['-e', worker, operation, String(retryAfter), String(now)], + { + cwd: new URL('../../', import.meta.url), + env: { ...process.env, HOMEY_HOME: directory }, + }, + ); + }); + await Promise.all(workers); + + assert.deepEqual(await cache.get(), { + user: { _id: 'updated' }, + authKey: 'shared', + updatedAt: 20, + retryAfter, + }); + assert.deepEqual((await readdir(directory)).sort(), ['profile-cache.json', 'settings.json']); + }); + + for (const rateLimited of [false, true]) { + for (const code of ['ENOSPC', 'EACCES']) { + it(`returns the ${rateLimited ? '429 fallback' : 'fetched profile'} when cache writes fail with ${code}`, async () => { + await createClient().client.getProfile(); + const response = rateLimited ? new APIError('Too Many Requests', 429) : profile; + const { client } = createClient(response); + const error = Object.assign(new Error('Cache write failed'), { code }); + mock.method(client._profileCache, '_write', async () => { + throw error; + }); + const warning = mock.method(Log, 'warning', () => {}); + + assert.equal((await client.getProfile({ cache: false })).id, profile._id); + assert.equal(warning.mock.callCount(), 1); + assert.equal(warning.mock.calls[0].arguments[1], error); + assert.deepEqual((await readdir(directory)).sort(), [ + 'profile-cache.json', + 'settings.json', + ]); + }); + } + } + + it('fetches a profile when the cache cannot be read', async () => { + const { client } = createClient(); + mock.method(client._profileCache, 'get', async () => { + throw Object.assign(new Error('Cache read failed'), { code: 'EACCES' }); + }); + const warning = mock.method(Log, 'warning', () => {}); + + assert.equal((await client.getProfile()).id, profile._id); + assert.ok(warning.mock.callCount() > 0); + }); + + it('recovers a cache lock left by a terminated process', async () => { + const lockPath = path.join(directory, 'profile-cache.json.lock'); + await mkdir(lockPath); + const staleTime = new Date(now - 20 * 1000); + await utimes(lockPath, staleTime, staleTime); + const { client } = createClient(); + + assert.equal((await client.getProfile()).id, profile._id); + assert.equal((await client._profileCache.get()).user._id, profile._id); + assert.deepEqual((await readdir(directory)).sort(), ['profile-cache.json', 'settings.json']); + }); + + it('keeps new OAuth credentials isolated even when clearing the old cache fails', async () => { + const { client, request } = createClient(); + await client.getProfile(); + mock.method(client._api, 'authenticateWithAuthorizationCode', async () => { + return { access_token: 'new-account-token' }; + }); + mock.method(client._profileCache, 'clear', async () => { + throw Object.assign(new Error('Cache clear failed'), { code: 'EACCES' }); + }); + const warning = mock.method(Log, 'warning', () => {}); + + await client._authenticateWithAuthorizationCode({ code: 'new-account-code' }); + request.mock.mockImplementation(async () => { + return { ...structuredClone(profile), _id: 'new-account' }; + }); + assert.equal((await client.getProfile()).id, 'new-account'); + assert.equal(warning.mock.callCount(), 1); + }); + + it('does not reuse the cache without an OAuth credential', async () => { + await createClient().client.getProfile(); + await settings.set('homeyApi', {}); + const error = new APIError('Too Many Requests', 429); + + await assert.rejects(createClient(error).client.getProfile(), error); + }); + + for (const rateLimited of [false, true]) { + it(`keeps account data cleared after another process logs out during a pending ${rateLimited ? '429' : 'success'}`, async () => { + const { client, request } = createClient(); + await client.getProfile(); + const started = Promise.withResolvers(); + const response = Promise.withResolvers(); + request.mock.mockImplementation(async () => { + started.resolve(); + return await response.promise; + }); + const pending = client.getProfile({ cache: false }); + await started.promise; + + await execFileAsync( + process.execPath, + [ + '-e', + ` + const AthomApi = require('./lib/AthomApi'); + new AthomApi().logout().catch((err) => { + console.error(err); + process.exitCode = 1; + }); + `, + ], + { + cwd: new URL('../../', import.meta.url), + env: { ...process.env, HOMEY_HOME: directory, HOMEY_PAT: '' }, + }, + ); + + if (rateLimited) { + response.reject(new APIError('Too Many Requests', 429)); + } else { + response.resolve(structuredClone(profile)); + } + await pending; + + const stored = await client._profileCache.get(); + assert.equal(stored?.user, undefined); + assert.equal(stored?.authKey, undefined); + assert.equal(stored?.retryAfter, undefined); + const persisted = JSON.parse(await readFile(settings._settingsPath, 'utf8')); + assert.deepEqual(persisted.homeyApi, {}); + }); + } + + it('clears persistent and in-memory profiles on logout', async () => { + const { client } = createClient(); + await client.getHomeys({ usb: false }); + await client.logout(); + + assert.deepEqual(await settings.get('homeyApi'), {}); + assert.equal(client._user, null); + assert.equal(client._homeys.size, 0); + assert.equal((await client._profileCache.get()).user, undefined); + const error = new APIError('Too Many Requests', 429); + await assert.rejects(createClient(error).client.getProfile(), error); + }); +}); diff --git a/tests/lib/athom-api.fetch.test.mjs b/tests/lib/athom-api.fetch.test.mjs index 9ada5c7f..c568fbad 100644 --- a/tests/lib/athom-api.fetch.test.mjs +++ b/tests/lib/athom-api.fetch.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert'; import os from 'node:os'; import { afterEach, describe, it, mock } from 'node:test'; +import { setImmediate } from 'node:timers/promises'; import AthomApi from '../../lib/AthomApi.js'; @@ -11,11 +12,15 @@ afterEach(() => { describe('AthomApi local discovery fetch behavior', () => { it('sets usb address when local ping returns a matching Homey id', async () => { const athomApi = new AthomApi(); - const homeys = [{ id: 'homey-1', name: 'Homey One' }]; + const homeys = [{ id: 'homey-1', name: 'Homey One', platform: 'local', apiVersion: 3 }]; - athomApi._user = { - getHomeys: async () => homeys, - }; + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); mock.method(athomApi, '_initApi', async () => {}); mock.method(os, 'networkInterfaces', () => ({ @@ -32,18 +37,22 @@ describe('AthomApi local discovery fetch behavior', () => { }; }); - const result = await athomApi.getHomeys({ cache: false, local: true }); + const result = await athomApi.getHomeys({ cache: false, usb: true }); assert.strictEqual(result[0].usb, '10.0.0.1'); }); it('ignores unmatched Homey ids from local ping responses', async () => { const athomApi = new AthomApi(); - const homeys = [{ id: 'homey-1', name: 'Homey One' }]; + const homeys = [{ id: 'homey-1', name: 'Homey One', platform: 'local', apiVersion: 3 }]; - athomApi._user = { - getHomeys: async () => homeys, - }; + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); mock.method(athomApi, '_initApi', async () => {}); mock.method(os, 'networkInterfaces', () => ({ @@ -55,18 +64,68 @@ describe('AthomApi local discovery fetch behavior', () => { }, })); - const result = await athomApi.getHomeys({ cache: false, local: true }); + const result = await athomApi.getHomeys({ cache: false, usb: true }); + + assert.deepStrictEqual(result, []); + }); + + it('probes one address per subnet, all at the same time', async () => { + const athomApi = new AthomApi(); + const homeys = [{ id: 'homey-1', name: 'Homey One', platform: 'local', apiVersion: 3 }]; + + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); - assert.strictEqual(result[0].usb, undefined); + mock.method(athomApi, '_initApi', async () => {}); + mock.method(os, 'networkInterfaces', () => ({ + en0: [{ address: '10.211.55.2' }, { address: 'fe80::1' }], + vnic1: [{ address: '10.211.55.3' }], + vnic2: [{ address: '10.37.129.2' }], + lo: [{ address: '127.0.0.1' }], + })); + + // Hold every probe open so a sequential implementation cannot reach the second one. + const inflight = []; + mock.method( + global, + 'fetch', + (url) => new Promise((resolve) => inflight.push({ url, resolve })), + ); + + const pending = athomApi.getHomeys({ cache: false, usb: true }); + await setImmediate(); + + try { + assert.deepStrictEqual(inflight.map(({ url }) => url).sort(), [ + 'http://10.211.55.1/api/manager/webserver/ping', + 'http://10.37.129.1/api/manager/webserver/ping', + ]); + } finally { + for (const { resolve } of inflight) { + resolve({ headers: { get: () => null } }); + } + } + + await pending; + assert.strictEqual(inflight.length, 2); }); it('continues when local ping fetch fails', async () => { const athomApi = new AthomApi(); - const homeys = [{ id: 'homey-1', name: 'Homey One' }]; + const homeys = [{ id: 'homey-1', name: 'Homey One', platform: 'local', apiVersion: 3 }]; - athomApi._user = { - getHomeys: async () => homeys, - }; + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); mock.method(athomApi, '_initApi', async () => {}); mock.method(os, 'networkInterfaces', () => ({ @@ -76,9 +135,8 @@ describe('AthomApi local discovery fetch behavior', () => { throw new Error('timeout'); }); - const result = await athomApi.getHomeys({ cache: false, local: true }); + const result = await athomApi.getHomeys({ cache: false, usb: true }); - assert.deepStrictEqual(result, homeys); - assert.strictEqual(result[0].usb, undefined); + assert.deepStrictEqual(result, []); }); }); diff --git a/tests/lib/athom-api.selection.test.mjs b/tests/lib/athom-api.selection.test.mjs index 8964678f..28a61f0a 100644 --- a/tests/lib/athom-api.selection.test.mjs +++ b/tests/lib/athom-api.selection.test.mjs @@ -124,7 +124,7 @@ describe('AthomApi selected Homey persistence', () => { ], }, ]); - assert.strictEqual(await result.__baseUrlPromise, 'http://10.0.0.1:80'); + assert.strictEqual(result.__baseUrlPromise, undefined); assert.strictEqual(result.model, 'Homey Pro'); }); diff --git a/tests/lib/athom-api.usb.test.mjs b/tests/lib/athom-api.usb.test.mjs new file mode 100644 index 00000000..b1f60897 --- /dev/null +++ b/tests/lib/athom-api.usb.test.mjs @@ -0,0 +1,261 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import { afterEach, describe, it, mock } from 'node:test'; +import inquirer from 'inquirer'; +import { AthomCloudAPI, HomeyAPIV3Local } from 'homey-api'; + +import AthomApi from '../../lib/AthomApi.js'; +import Settings from '../../services/Settings.js'; +import AthomApiService from '../../services/AthomApi.js'; +import { createHomeyApiClient, diagnoseHomeyStrategies } from '../../lib/api/ApiCommandRuntime.mjs'; + +const homeyProperties = { + _id: 'homey-1', + name: 'USB Homey', + platform: 'local', + apiVersion: 3, + softwareVersion: '12.0.0', + state: 'offline', + localUrl: 'http://192.168.1.2', + remoteUrl: 'https://homey.example', +}; + +afterEach(() => { + mock.restoreAll(); + AthomApiService.discoveryStrategies = undefined; +}); + +function createClient(homeys = [homeyProperties]) { + const client = new AthomApi(); + client._createApi(); + client._api = new AthomCloudAPI(); + const profile = new AthomCloudAPI.User({ + api: client._api, + properties: { _id: 'user-1', devices: [], homeys: structuredClone(homeys) }, + }); + mock.method(client, 'getProfile', async () => { + return profile; + }); + mock.method(os, 'networkInterfaces', () => { + return { eth0: [{ address: '10.0.0.2' }], vpn: [{ address: '10.1.0.2' }] }; + }); + return { client, profile }; +} + +function mockProbe(homeyId = 'homey-1') { + return mock.method(global, 'fetch', async () => { + return new Response(null, { headers: { 'x-homey-id': homeyId } }); + }); +} + +function mockUsbRequests(cloudApi) { + mock.method(cloudApi, 'createDelegationToken', async () => { + return 'delegation-token'; + }); + return mock.method(HomeyAPIV3Local.prototype, 'call', async function ({ path: requestPath }) { + assert.match(await this.baseUrl, /^http:\/\/10\.[01]\.0\.1:80$/); + assert.deepEqual(this.__strategies, []); + if (requestPath === '/api/manager/users/login') return 'homey-token'; + return { id: 'session-1' }; + }); +} + +describe('USB opt-in discovery and connections', () => { + it('makes no probes by default and keeps the full account list', async () => { + const { client } = createClient([homeyProperties, { ...homeyProperties, _id: 'homey-2' }]); + const fetch = mockProbe(); + const homeys = await client.getHomeys(); + + assert.equal(homeys.length, 2); + assert.equal(fetch.mock.callCount(), 0); + assert.ok(homeys[0] instanceof AthomCloudAPI.Homey); + assert.equal(homeys[0].usb, undefined); + }); + + it('isolates modes, refreshes both lists, and never adds USB metadata to the profile', async () => { + const { client, profile } = createClient([ + homeyProperties, + { ...homeyProperties, _id: 'homey-2' }, + ]); + const fetch = mockProbe(); + const normal = await client.getHomeys(); + const usb = await client.getHomeys({ usb: true }); + + assert.equal(usb.length, 1); + assert.equal(usb[0].id, 'homey-1'); + assert.equal(usb[0].state, 'offline'); + assert.ok(usb[0].usb); + assert.equal(normal[0].usb, undefined); + assert.equal(profile.homeys[0].usb, undefined); + assert.equal(await client.getHomeys(), normal); + assert.equal(await client.getHomeys({ usb: true }), usb); + assert.equal(fetch.mock.callCount(), 2); + + await client.getHomeys({ cache: false }); + await client.getHomeys({ usb: true }); + assert.equal(fetch.mock.callCount(), 4); + }); + + it('rejects a requested Homey when only an unrelated device answers', async () => { + const { client } = createClient(); + mockProbe('another-account-homey'); + + assert.deepEqual(await client.getHomeys({ usb: true }), []); + await assert.rejects(client.getHomey('homey-1', { usb: true }), /not found over USB/); + }); + + for (const target of [ + { platform: 'cloud', apiVersion: 3 }, + { platform: 'local', apiVersion: 2 }, + ]) { + it(`rejects unsupported USB target ${target.platform}/${target.apiVersion} without probing`, async () => { + const { client } = createClient([{ ...homeyProperties, ...target }]); + const fetch = mockProbe(); + + await assert.rejects( + client.getHomey('homey-1', { usb: true }), + /USB requires a local API-v3/, + ); + assert.equal(fetch.mock.callCount(), 0); + }); + } + + it('selects a detected USB Homey even when Cloud says offline, without saving USB mode', async () => { + const { client } = createClient(); + mockProbe(); + mock.method(inquirer, 'prompt', async ([question]) => { + assert.equal(question.choices.length, 1); + return { homey: question.choices[0].value }; + }); + const saved = mock.method(Settings, 'set', async (key, value) => { + return value; + }); + await client.selectActiveHomey({ usb: true }); + + assert.deepEqual(saved.mock.calls[0].arguments, [ + 'activeHomey', + { + id: 'homey-1', + name: 'USB Homey', + platform: 'local', + }, + ]); + }); + + it('fails selection before prompting when no USB Homey is detected', async () => { + const { client } = createClient(); + mockProbe('unknown'); + const prompt = mock.method(inquirer, 'prompt', async () => {}); + + await assert.rejects(client.selectActiveHomey({ usb: true }), /No USB-connected Homey found/); + assert.equal(prompt.mock.callCount(), 0); + }); + + it('uses USB for login and operations while keeping normal clients separate', async () => { + const { client } = createClient(); + client.discoveryStrategies = ['cloud']; + mockProbe(); + mock.method(Settings, 'get', async () => { + return { id: 'homey-1' }; + }); + const requests = mockUsbRequests(client._api); + const normalApi = {}; + const normal = mock.method(AthomCloudAPI.Homey.prototype, 'authenticate', async () => { + return normalApi; + }); + const normalClient = await client.getActiveHomey(); + const usbClient = await client.getActiveHomey({ usb: true }); + try { + assert.equal(normalClient, normalApi); + assert.ok(usbClient instanceof HomeyAPIV3Local); + assert.equal(normal.mock.callCount(), 1); + assert.equal(requests.mock.calls[0].arguments[0].path, '/api/manager/users/login'); + await usbClient.system.getInfo({ $socket: false }); + assert.equal(await client.getActiveHomey(), normalApi); + assert.equal(await client.getActiveHomey({ usb: true }), usbClient); + assert.equal(normal.mock.callCount(), 1); + } finally { + usbClient.destroy(); + } + }); + + it('propagates USB authentication failure without invoking normal authentication', async () => { + const { client } = createClient(); + mockProbe(); + mock.method(Settings, 'get', async () => { + return { id: 'homey-1' }; + }); + const failure = new Error('USB connection closed'); + mock.method(HomeyAPIV3Local.prototype, 'login', async () => { + throw failure; + }); + const normal = mock.method(AthomCloudAPI.Homey.prototype, 'authenticate', async () => {}); + + await assert.rejects(client.getActiveHomey({ usb: true }), (err) => { + assert.match(err.message, /over USB/); + assert.equal(err.cause, failure); + return true; + }); + assert.equal(normal.mock.callCount(), 0); + }); + + it('uses the USB client for API operations and USB-only diagnostics', async () => { + const { client } = createClient(); + AthomApiService.discoveryStrategies = ['cloud']; + mockProbe(); + mockUsbRequests(client._api); + const resolve = mock.method(AthomApiService, 'getHomey', async (id, options) => { + return await client.getHomey(id, options); + }); + mock.method(AthomApiService, '_initApi', async () => { + return client._api; + }); + const api = await createHomeyApiClient({ homeyId: 'homey-1', usb: true }); + try { + assert.ok(api instanceof HomeyAPIV3Local); + await api.system.getInfo({ $socket: false }); + } finally { + api.destroy(); + } + const report = await diagnoseHomeyStrategies({ homeyId: 'homey-1', usb: true }); + + assert.deepEqual( + resolve.mock.calls.map((call) => { + return call.arguments[1]; + }), + [{ usb: true }, { usb: false }, { usb: true }], + ); + + assert.deepEqual(report.attemptedStrategyIds, ['usb']); + assert.deepEqual(report.availableStrategyIds, ['usb']); + assert.equal(report.selectedStrategyId, 'usb'); + assert.match(report.selectedBaseUrl, /^http:\/\/10\.[01]\.0\.1:80$/); + assert.equal(report.results.length, 1); + }); + + it('rejects enabled USB with an explicit address', async () => { + await assert.rejects( + createHomeyApiClient({ token: 'test', address: 'http://localhost', usb: true }), + /Cannot combine USB mode with --address/, + ); + }); + + it('does not fall back to a LAN address in USB token mode', async () => { + mock.method(AthomApiService, 'getHomey', async () => { + return { ...homeyProperties, id: 'homey-1' }; + }); + await assert.rejects( + createHomeyApiClient({ token: 'test', homeyId: 'homey-1', usb: true }), + /not found over USB/, + ); + }); + + it('does not use stale USB metadata in normal token mode', async () => { + mock.method(AthomApiService, 'getHomey', async () => { + return { ...homeyProperties, id: 'homey-1', usb: '10.0.0.1' }; + }); + const api = await createHomeyApiClient({ token: 'test', homeyId: 'homey-1' }); + assert.equal(await api.baseUrl, homeyProperties.localUrl); + api.destroy(); + }); +});