From 1dbfb88b942e257d724eb4c0f83fbb23da0e98c4 Mon Sep 17 00:00:00 2001 From: Jeroen Wienk Date: Mon, 7 Sep 2026 08:17:05 +0200 Subject: [PATCH 1/7] fix: cache account data to tolerate cloud rate limits --- README.md | 15 ++ bin/cmds/list.mjs | 14 +- bin/cmds/whoami.mjs | 14 +- lib/AthomApi.js | 67 ++++++++- tests/lib/athom-api.cache.test.mjs | 224 +++++++++++++++++++++++++++++ tests/lib/athom-api.fetch.test.mjs | 30 ++-- 6 files changed, 346 insertions(+), 18 deletions(-) create mode 100644 tests/lib/athom-api.cache.test.mjs diff --git a/README.md b/README.md index 59fe4587..7f26183d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,21 @@ source ~/.zshrc 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. 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 the profile cache; cached profiles are never reused with a different PAT. + +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/list.mjs b/bin/cmds/list.mjs index 46f299a8..c1f4a1fa 100644 --- a/bin/cmds/list.mjs +++ b/bin/cmds/list.mjs @@ -70,6 +70,11 @@ function printHomeysTable(homeys) { 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 list --json', 'Output Homeys as JSON') .example("$0 list --jq '.[].name'", 'Print all Homey names using jq') .help(); @@ -77,12 +82,15 @@ export const builder = (yargs) => { export const handler = async (argv = {}) => { try { - const homeys = sortHomeys(await AthomApi.getHomeys()).map(toHomeyOutput); + const homeys = await AthomApi.getHomeys({ cache: !argv.refresh }); + 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/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/AthomApi.js b/lib/AthomApi.js index c2b79647..a4acae29 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'); @@ -15,6 +16,10 @@ 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]; @@ -36,6 +41,12 @@ class AthomApi { } _createApi() { + this._user = null; + this._homeys = null; + this._activeHomey = null; + this._profileAuthKey = process.env.HOMEY_PAT + ? createHash('sha256').update(process.env.HOMEY_PAT).digest('hex') + : 'oauth'; this._store = new AthomApiStorage(); this._api = new AthomCloudAPI({ clientId: ATHOM_API_CLIENT_ID, @@ -144,6 +155,7 @@ class AthomApi { listener.close(); await this._api.authenticateWithAuthorizationCode({ code }); + await this._clearProfileCache(); try { const profile = await this.getProfile(); @@ -163,9 +175,58 @@ class AthomApi { await this.unsetActiveHomey(); } - async getProfile() { + async _clearProfileCache() { + const store = await this._store.get(); + delete store.user; + delete store.profileCache; + + await this._store.set(store); + } + + async getProfile({ cache = true } = {}) { await this._initApi(); - return this._api.getAuthenticatedUser(); + + const store = await this._store.get(); + const metadata = store.profileCache; + const hasCachedProfile = Boolean(store.user && metadata?.authKey === this._profileAuthKey); + const now = Date.now(); + const isFresh = hasCachedProfile && now - metadata.updatedAt < PROFILE_CACHE_TTL; + const isCoolingDown = hasCachedProfile && now < metadata.retryAfter; + + if ((cache && isFresh) || isCoolingDown) { + return await this._api.getAuthenticatedUserFromStore(); + } + + // A PAT can belong to a different account than the stored OAuth session. + if (!hasCachedProfile) { + await this._clearProfileCache(); + } + + let profile; + + try { + profile = await this._api.getAuthenticatedUserFromStore({ $cache: false }); + } catch (err) { + if (err.statusCode !== 429 || !hasCachedProfile) { + throw err; + } + + const currentStore = await this._store.get(); + await this._store.set({ + ...currentStore, + profileCache: { ...metadata, retryAfter: Date.now() + PROFILE_RATE_LIMIT_COOLDOWN }, + }); + + return await this._api.getAuthenticatedUserFromStore(); + } + + const currentStore = await this._store.get(); + await this._store.set({ + ...currentStore, + profileCache: { authKey: this._profileAuthKey, updatedAt: Date.now() }, + }); + + return profile; } async getHomey(homeyId) { @@ -182,7 +243,7 @@ class AthomApi { await this._initApi(); - this._user = this._user || (await this.getProfile()); + this._user = await this.getProfile({ cache }); this._homeys = await this._user.getHomeys(); // find USB connected Homeys diff --git a/tests/lib/athom-api.cache.test.mjs b/tests/lib/athom-api.cache.test.mjs new file mode 100644 index 00000000..0409c9fc --- /dev/null +++ b/tests/lib/athom-api.cache.test.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import { APIError, AthomCloudAPI, HomeyAPIV3Local } from 'homey-api'; + +import AthomApi from '../../lib/AthomApi.js'; +import SettingsStore from '../../lib/Settings.js'; +import Settings from '../../services/Settings.js'; + +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(os, 'networkInterfaces', () => { + return {}; + }); +}); + +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({ local: false }); + + const next = createClient(new Error('Cloud must not be called')); + const cached = await next.client.getProfile(); + const homeys = await next.client.getHomeys({ local: 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 settings.get('homeyApi')).profileCache.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 settings.get('homeyApi')).profileCache.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({ local: false }); + request.mock.mockImplementation(async () => { + return { ...structuredClone(profile), homeys: [] }; + }); + + assert.deepEqual(await client.getHomeys({ cache: false, local: 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(settings._settingsPath, '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('clears persistent and in-memory profiles on logout', async () => { + const { client } = createClient(); + await client.getHomeys({ local: false }); + await client.logout(); + + assert.deepEqual(await settings.get('homeyApi'), {}); + assert.equal(client._user, null); + assert.equal(client._homeys, null); + 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..fe7337ea 100644 --- a/tests/lib/athom-api.fetch.test.mjs +++ b/tests/lib/athom-api.fetch.test.mjs @@ -13,9 +13,13 @@ describe('AthomApi local discovery fetch behavior', () => { const athomApi = new AthomApi(); const homeys = [{ id: 'homey-1', name: 'Homey One' }]; - athomApi._user = { - getHomeys: async () => homeys, - }; + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); mock.method(athomApi, '_initApi', async () => {}); mock.method(os, 'networkInterfaces', () => ({ @@ -41,9 +45,13 @@ describe('AthomApi local discovery fetch behavior', () => { const athomApi = new AthomApi(); const homeys = [{ id: 'homey-1', name: 'Homey One' }]; - athomApi._user = { - getHomeys: async () => homeys, - }; + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); mock.method(athomApi, '_initApi', async () => {}); mock.method(os, 'networkInterfaces', () => ({ @@ -64,9 +72,13 @@ describe('AthomApi local discovery fetch behavior', () => { const athomApi = new AthomApi(); const homeys = [{ id: 'homey-1', name: 'Homey One' }]; - athomApi._user = { - getHomeys: async () => homeys, - }; + mock.method(athomApi, 'getProfile', async () => { + return { + getHomeys: async () => { + return homeys; + }, + }; + }); mock.method(athomApi, '_initApi', async () => {}); mock.method(os, 'networkInterfaces', () => ({ From 8b5440d3b2058849bebc57894e9e40b392e7d79e Mon Sep 17 00:00:00 2001 From: Jeroen Wienk Date: Mon, 7 Sep 2026 17:43:31 +0200 Subject: [PATCH 2/7] fix: isolate profile cache writes and OAuth identity --- README.md | 4 +- lib/AthomApi.js | 58 +++++++--------- lib/AthomApiProfileCache.js | 44 ++++++++++++ tests/lib/athom-api.cache.test.mjs | 105 +++++++++++++++++++++++++++-- 4 files changed, 174 insertions(+), 37 deletions(-) create mode 100644 lib/AthomApiProfileCache.js diff --git a/README.md b/README.md index 7f26183d..fbb1b050 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ 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. Once the cache expires, the next command refreshes it. +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. diff --git a/lib/AthomApi.js b/lib/AthomApi.js index a4acae29..965a870a 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -12,6 +12,7 @@ const { AthomCloudAPI, APIErrorHomeyOffline, HomeyAPI } = require('homey-api'); const AthomCloudAPIToken = require('homey-api/lib/AthomCloudAPI/Token'); const AthomApiStorage = require('./AthomApiStorage'); +const { AthomApiProfileCache } = require('./AthomApiProfileCache'); const Log = require('./Log'); const Settings = require('../services/Settings'); const { ATHOM_API_CLIENT_ID, ATHOM_API_CLIENT_SECRET, ATHOM_API_LOGIN_URL } = require('../config'); @@ -48,6 +49,7 @@ class AthomApi { ? createHash('sha256').update(process.env.HOMEY_PAT).digest('hex') : 'oauth'; this._store = new AthomApiStorage(); + this._profileCache = new AthomApiProfileCache(); this._api = new AthomCloudAPI({ clientId: ATHOM_API_CLIENT_ID, clientSecret: ATHOM_API_CLIENT_SECRET, @@ -154,8 +156,7 @@ class AthomApi { listener.close(); - await this._api.authenticateWithAuthorizationCode({ code }); - await this._clearProfileCache(); + await this._authenticateWithAuthorizationCode({ code }); try { const profile = await this.getProfile(); @@ -168,62 +169,55 @@ class AthomApi { } } + async _authenticateWithAuthorizationCode({ code }) { + await this._api.authenticateWithAuthorizationCode({ code }); + this._profileAuthKey = 'oauth'; + await this._profileCache.clear(); + } + async logout() { Log.success('You are now logged out'); await this._createApi(); await this._api.logout(); + await this._profileCache.clear(); await this.unsetActiveHomey(); } - async _clearProfileCache() { - const store = await this._store.get(); - delete store.user; - delete store.profileCache; - - await this._store.set(store); - } - async getProfile({ cache = true } = {}) { await this._initApi(); - const store = await this._store.get(); - const metadata = store.profileCache; - const hasCachedProfile = Boolean(store.user && metadata?.authKey === this._profileAuthKey); + const stored = await this._profileCache.get(); + const hasCachedProfile = Boolean(stored?.user && stored.authKey === this._profileAuthKey); const now = Date.now(); - const isFresh = hasCachedProfile && now - metadata.updatedAt < PROFILE_CACHE_TTL; - const isCoolingDown = hasCachedProfile && now < metadata.retryAfter; + const isFresh = hasCachedProfile && now - stored.updatedAt < PROFILE_CACHE_TTL; + const isCoolingDown = hasCachedProfile && now < stored.retryAfter; if ((cache && isFresh) || isCoolingDown) { - return await this._api.getAuthenticatedUserFromStore(); - } - - // A PAT can belong to a different account than the stored OAuth session. - if (!hasCachedProfile) { - await this._clearProfileCache(); + return new AthomCloudAPI.User({ api: this._api, properties: stored.user }); } - let profile; + let properties; try { - profile = await this._api.getAuthenticatedUserFromStore({ $cache: false }); + properties = await this._api.call({ method: 'get', path: '/user/me' }); } catch (err) { if (err.statusCode !== 429 || !hasCachedProfile) { throw err; } - const currentStore = await this._store.get(); - await this._store.set({ - ...currentStore, - profileCache: { ...metadata, retryAfter: Date.now() + PROFILE_RATE_LIMIT_COOLDOWN }, + await this._profileCache.set({ + ...stored, + retryAfter: Date.now() + PROFILE_RATE_LIMIT_COOLDOWN, }); - return await this._api.getAuthenticatedUserFromStore(); + return new AthomCloudAPI.User({ api: this._api, properties: stored.user }); } - const currentStore = await this._store.get(); - await this._store.set({ - ...currentStore, - profileCache: { authKey: this._profileAuthKey, updatedAt: Date.now() }, + const profile = new AthomCloudAPI.User({ api: this._api, properties }); + await this._profileCache.set({ + user: properties, + authKey: this._profileAuthKey, + updatedAt: Date.now(), }); return profile; diff --git a/lib/AthomApiProfileCache.js b/lib/AthomApiProfileCache.js new file mode 100644 index 00000000..9d98e4d1 --- /dev/null +++ b/lib/AthomApiProfileCache.js @@ -0,0 +1,44 @@ +'use strict'; + +const { randomUUID } = require('crypto'); +const { mkdir, readFile, rename, rm, writeFile } = require('fs/promises'); +const path = require('path'); + +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) { + await mkdir(path.dirname(this._path), { recursive: true }); + const temporaryPath = `${this._path}.${randomUUID()}.tmp`; + + // Publish the profile and its authentication metadata together, even with concurrent writers. + try { + await writeFile(temporaryPath, JSON.stringify(value), { mode: 0o600 }); + await rename(temporaryPath, this._path); + } finally { + await rm(temporaryPath, { force: true }); + } + } + + async clear() { + await rm(this._path, { force: true }); + } +} + +module.exports = { AthomApiProfileCache }; diff --git a/tests/lib/athom-api.cache.test.mjs b/tests/lib/athom-api.cache.test.mjs index 0409c9fc..264585e9 100644 --- a/tests/lib/athom-api.cache.test.mjs +++ b/tests/lib/athom-api.cache.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, it, mock } from 'node:test'; @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, it, mock } from 'node:test'; 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'; @@ -51,6 +52,9 @@ beforeEach(async () => { mock.method(Settings, 'unset', async (key) => { return await settings.unset(key); }); + mock.method(Settings, 'getSettingsDirectory', () => { + return directory; + }); mock.method(os, 'networkInterfaces', () => { return {}; }); @@ -116,7 +120,7 @@ describe('AthomApi persistent profile cache', () => { assert.equal(next.request.mock.callCount(), 1); assert.equal(homey.localUrl, updated.homeys[0].localUrl); - assert.equal((await settings.get('homeyApi')).profileCache.updatedAt, now); + assert.equal((await next.client._profileCache.get()).updatedAt, now); }); it('continues Homey execution on 429 and persists a cooldown across invocations', async () => { @@ -151,7 +155,7 @@ describe('AthomApi persistent profile cache', () => { const recovered = createClient(); await recovered.client.getProfile(); assert.equal(recovered.request.mock.callCount(), 1); - assert.equal((await settings.get('homeyApi')).profileCache.retryAfter, undefined); + assert.equal((await recovered.client._profileCache.get()).retryAfter, undefined); }); it('propagates 429 when there is no cached profile', async () => { @@ -200,7 +204,7 @@ describe('AthomApi persistent profile cache', () => { await createClient().client.getProfile(); assert.equal((await createClient(error).client.getProfile()).id, 'user-1'); - const persisted = await readFile(settings._settingsPath, 'utf8'); + 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); @@ -210,6 +214,98 @@ describe('AthomApi persistent profile cache', () => { 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'); + }); + + await client._authenticateWithAuthorizationCode({ code: 'test-authorization-code' }); + assert.equal(await client._profileCache.get(), null); + 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), ['profile-cache.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' } }); + }); + it('clears persistent and in-memory profiles on logout', async () => { const { client } = createClient(); await client.getHomeys({ local: false }); @@ -218,6 +314,7 @@ describe('AthomApi persistent profile cache', () => { assert.deepEqual(await settings.get('homeyApi'), {}); assert.equal(client._user, null); assert.equal(client._homeys, null); + assert.equal(await client._profileCache.get(), null); const error = new APIError('Too Many Requests', 429); await assert.rejects(createClient(error).client.getProfile(), error); }); From 7fe0795dd8aeb9f757e89a6705790c2dc8cc7206 Mon Sep 17 00:00:00 2001 From: Robin Bolscher Date: Thu, 10 Sep 2026 10:14:21 +0200 Subject: [PATCH 3/7] perf(cli): Probe USB Homey candidates in parallel Local discovery probed one candidate address per network interface sequentially, each with a one second timeout, so every 10.x interface that is not a Homey added a full second to getHomeys. Virtualization adapters commonly add several of those. Interfaces sharing a subnet also produced a duplicate probe for the same address. Collect the unique candidate addresses first, then probe them concurrently. With four such interfaces, two of them in one subnet, this drops from 4006ms to 1004ms; with eight it drops from 9012ms to 1005ms. The total is now bounded by the timeout instead of scaling with the interface count. --- lib/AthomApi.js | 22 +++++++++------ tests/lib/athom-api.fetch.test.mjs | 43 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/lib/AthomApi.js b/lib/AthomApi.js index c2b79647..6400ff21 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -188,29 +188,35 @@ class AthomApi { // find USB connected Homeys if (local) { 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 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. + await Promise.all( + [...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) continue; + if (!homeyId) return; const homey = this._homeys.find((candidate) => candidate.id === homeyId); if (homey) { homey.usb = ip; } } catch (err) {} - } - } + }), + ); } return this._homeys; diff --git a/tests/lib/athom-api.fetch.test.mjs b/tests/lib/athom-api.fetch.test.mjs index 9ada5c7f..7ee02502 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'; @@ -60,6 +61,48 @@ describe('AthomApi local discovery fetch behavior', () => { assert.strictEqual(result[0].usb, undefined); }); + it('probes one address per subnet, all at the same time', async () => { + const athomApi = new AthomApi(); + const homeys = [{ id: 'homey-1', name: 'Homey One' }]; + + athomApi._user = { + getHomeys: async () => homeys, + }; + + 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, local: 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' }]; From bdcd14e548a8c947f82ed2a6fb5d4ffba3251fc9 Mon Sep 17 00:00:00 2001 From: Jeroen Wienk Date: Mon, 14 Sep 2026 16:23:49 +0200 Subject: [PATCH 4/7] fix: coordinate profile cache updates and isolate OAuth credentials --- README.md | 5 +- lib/AthomApi.js | 86 ++++++++--- lib/AthomApiProfileCache.js | 73 ++++++++- package-lock.json | 21 +++ package.json | 1 + tests/lib/athom-api.cache.test.mjs | 240 ++++++++++++++++++++++++++++- 6 files changed, 397 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index fbb1b050..159c93bd 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,10 @@ one minute before attempting another profile refresh. Live Homey API responses a 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 the profile cache; cached profiles are never reused with a different PAT. +Logging in or out clears the profile cache. 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, diff --git a/lib/AthomApi.js b/lib/AthomApi.js index 965a870a..cba64359 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -46,8 +46,8 @@ class AthomApi { this._homeys = null; this._activeHomey = null; this._profileAuthKey = process.env.HOMEY_PAT - ? createHash('sha256').update(process.env.HOMEY_PAT).digest('hex') - : 'oauth'; + ? `pat:${createHash('sha256').update(process.env.HOMEY_PAT).digest('hex')}` + : null; this._store = new AthomApiStorage(); this._profileCache = new AthomApiProfileCache(); this._api = new AthomCloudAPI({ @@ -170,59 +170,103 @@ class AthomApi { } async _authenticateWithAuthorizationCode({ code }) { - await this._api.authenticateWithAuthorizationCode({ code }); - this._profileAuthKey = 'oauth'; - await this._profileCache.clear(); + 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(); + await this._profileCache.clear().catch((err) => { + Log.warning('Could not clear the account profile cache:', err); + }); await this.unsetActiveHomey(); } async getProfile({ cache = true } = {}) { await this._initApi(); - const stored = await this._profileCache.get(); - const hasCachedProfile = Boolean(stored?.user && stored.authKey === this._profileAuthKey); + // 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 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: this._api, properties: stored.user }); + return new AthomCloudAPI.User({ api, properties: stored.user }); } let properties; try { - properties = await this._api.call({ method: 'get', path: '/user/me' }); + properties = await api.call({ method: 'get', path: '/user/me' }); } catch (err) { if (err.statusCode !== 429 || !hasCachedProfile) { throw err; } - await this._profileCache.set({ - ...stored, - retryAfter: Date.now() + PROFILE_RATE_LIMIT_COOLDOWN, - }); + try { + const updated = await this._profileCache.setRetryAfter({ + authKey, + 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: this._api, properties: stored.user }); + return new AthomCloudAPI.User({ api, properties: stored.user }); } - const profile = new AthomCloudAPI.User({ api: this._api, properties }); - await this._profileCache.set({ - user: properties, - authKey: this._profileAuthKey, - updatedAt: Date.now(), - }); + const profile = new AthomCloudAPI.User({ api, properties }); + + if (authKey) { + await this._profileCache + .set({ + user: properties, + authKey, + updatedAt: Date.now(), + }) + .catch((err) => { + Log.warning('Could not save the account profile cache:', err); + }); + } return profile; } + 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) { const homeys = await this.getHomeys(); for (let i = 0; i < homeys.length; i++) { diff --git a/lib/AthomApiProfileCache.js b/lib/AthomApiProfileCache.js index 9d98e4d1..16828565 100644 --- a/lib/AthomApiProfileCache.js +++ b/lib/AthomApiProfileCache.js @@ -3,6 +3,7 @@ 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'); @@ -24,10 +25,76 @@ class AthomApiProfileCache { } async set(value) { + return await this._update((stored) => { + 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 }) { + return await this._update((stored) => { + // 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(() => { + return null; + }); + } + + 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 === null) { + await rm(this._path, { force: true }); + } else if (value) { + await this._write(value); + } + + return value; + } finally { + await release(); + } + } + + async _write(value) { const temporaryPath = `${this._path}.${randomUUID()}.tmp`; - // Publish the profile and its authentication metadata together, even with concurrent writers. + // 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); @@ -35,10 +102,6 @@ class AthomApiProfileCache { await rm(temporaryPath, { force: true }); } } - - async clear() { - await rm(this._path, { force: true }); - } } module.exports = { AthomApiProfileCache }; diff --git a/package-lock.json b/package-lock.json index 5ec3daf8..6620d412 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", @@ -4660,6 +4661,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", @@ -4845,6 +4857,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 0faa9ad0..ff25e2a7 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/lib/athom-api.cache.test.mjs b/tests/lib/athom-api.cache.test.mjs index 264585e9..ec6f20d3 100644 --- a/tests/lib/athom-api.cache.test.mjs +++ b/tests/lib/athom-api.cache.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +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'; @@ -10,6 +12,9 @@ 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; @@ -58,6 +63,7 @@ beforeEach(async () => { mock.method(os, 'networkInterfaces', () => { return {}; }); + await settings.set('homeyApi', { token: { access_token: 'oauth-token-one' } }); }); afterEach(async () => { @@ -220,6 +226,9 @@ describe('AthomApi persistent profile cache', () => { 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' }); @@ -293,7 +302,7 @@ describe('AthomApi persistent profile cache', () => { return entry.authKey === persisted.authKey; }); assert.deepEqual(persisted, expected); - assert.deepEqual(await readdir(directory), ['profile-cache.json']); + assert.deepEqual((await readdir(directory)).sort(), ['profile-cache.json', 'settings.json']); }); it('refreshes a corrupt cache without touching account settings', async () => { @@ -306,6 +315,233 @@ describe('AthomApi persistent profile cache', () => { 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); + assert.equal((await createClient(otherProfile).client.getProfile()).id, 'other-account'); + 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')); + }); + } + + it('keeps the request credential when login changes the same client', async () => { + const { client, request } = createClient(); + await client.getProfile(); + const previousKey = client._profileAuthKey; + 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()).authKey, previousKey); + request.mock.mockImplementation(async () => { + return { ...structuredClone(profile), _id: 'new-account' }; + }); + assert.equal((await client.getProfile()).id, 'new-account'); + }); + + 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); + }); + it('clears persistent and in-memory profiles on logout', async () => { const { client } = createClient(); await client.getHomeys({ local: false }); From b24a12ec524ac1a07804e06dfd7c6d0ea00317b1 Mon Sep 17 00:00:00 2001 From: Jeroen Wienk Date: Mon, 14 Sep 2026 17:11:11 +0200 Subject: [PATCH 5/7] feat: make USB connections opt-in --- README.md | 36 ++++ bin/cmds/api/diagnose.mjs | 5 +- bin/cmds/api/raw.mjs | 1 + bin/cmds/app/install.mjs | 5 +- bin/cmds/app/run.mjs | 4 +- bin/cmds/list.mjs | 6 +- bin/cmds/select.mjs | 5 +- lib/App.js | 6 +- lib/AppPython.js | 4 +- lib/AthomApi.js | 154 ++++++++++----- lib/HomeyUsb.js | 51 +++++ lib/UsbOption.mjs | 8 + lib/api/ApiCommandOptions.mjs | 3 +- lib/api/ApiCommandRuntime.mjs | 72 ++++--- lib/api/ApiManagerCommand.mjs | 1 + tests/app/run-install.test.mjs | 26 +++ tests/cli/app-handlers.test.mjs | 7 +- tests/cli/select.test.mjs | 2 + tests/cli/usb.test.mjs | 202 ++++++++++++++++++++ tests/lib/api-command-runtime.test.mjs | 8 +- tests/lib/athom-api.cache.test.mjs | 12 +- tests/lib/athom-api.fetch.test.mjs | 21 +-- tests/lib/athom-api.selection.test.mjs | 2 +- tests/lib/athom-api.usb.test.mjs | 252 +++++++++++++++++++++++++ 24 files changed, 783 insertions(+), 110 deletions(-) create mode 100644 lib/HomeyUsb.js create mode 100644 lib/UsbOption.mjs create mode 100644 tests/cli/usb.test.mjs create mode 100644 tests/lib/athom-api.usb.test.mjs diff --git a/README.md b/README.md index 159c93bd..0aa23c4a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,42 @@ 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. 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. The `api raw` aliases `call` and `request` also accept `--usb`. + ## Homey API CLI Use `homey api` for direct Homey API access. 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 c1f4a1fa..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,21 @@ 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 = await AthomApi.getHomeys({ cache: !argv.refresh }); + const homeys = await AthomApi.getHomeys({ cache: !argv.refresh, usb: argv.usb }); const output = sortHomeys(homeys).map(toHomeyOutput); printStructuredOutput({ 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/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 7ce1f2d4..0f189b70 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 f522ef4b..b6673b97 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -13,6 +13,7 @@ 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'); @@ -37,14 +38,14 @@ class AthomApi { constructor() { this._api = null; this._user = null; - this._homeys = null; - this._activeHomey = null; + this._homeys = new Map(); + this._activeHomey = new Map(); } _createApi() { this._user = null; - this._homeys = null; - this._activeHomey = null; + this._homeys.clear(); + this._activeHomey.clear(); this._profileAuthKey = process.env.HOMEY_PAT ? `pat:${createHash('sha256').update(process.env.HOMEY_PAT).digest('hex')}` : null; @@ -267,25 +268,59 @@ class AthomApi { return this._profileAuthKey; } - async getHomey(homeyId) { + 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 = await this.getProfile({ cache }); - this._homeys = await this._user.getHomeys(); + 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(); @@ -299,61 +334,75 @@ class AthomApi { } // Probe concurrently: every candidate that is not a Homey costs the full timeout. - await Promise.all( - [...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 = this._homeys.find((candidate) => candidate.id === homeyId); - if (homey) { - homey.usb = ip; - } - } catch (err) {} - }), - ); + 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 = 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) { - 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() { @@ -361,22 +410,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 { @@ -387,7 +444,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) => ({ @@ -405,7 +463,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/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 37954cc7..0ca7dde4 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.', @@ -74,11 +81,6 @@ function createTokenHomeyApi({ token, address, homeyId = 'token-homey' }) { } 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; } @@ -123,9 +125,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(); @@ -134,7 +136,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) { @@ -143,10 +145,16 @@ 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, + strategy: getPreferredAuthenticateStrategy(homey), }); return applyResolvedHomeyMetadata(homeyApi, homey); } catch (err) { @@ -167,10 +175,6 @@ function normalizeDiagnosticError(err, homey) { } function getTokenModeAddressForHomey(homey) { - if (homey.usb) { - return `http://${homey.usb}:80`; - } - if (homey.localUrlSecure) { return homey.localUrlSecure; } @@ -183,8 +187,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), @@ -194,25 +203,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 } = {}) { + const homey = await resolveRequestedHomey(homeyId, { usb }); + const preferredStrategyIds = usb ? ['usb'] : getPreferredAuthenticateStrategy(homey); + const attemptedStrategyIds = usb ? ['usb'] : getDiagnoseStrategyOrder(homey); const results = []; for (const strategyId of attemptedStrategyIds) { @@ -232,9 +241,14 @@ export async function diagnoseHomeyStrategies({ homeyId } = {}) { } try { - api = await homey.authenticate({ - strategy: [strategyId], - }); + if (usb) { + 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/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..2c15c43b --- /dev/null +++ b/tests/cli/usb.test.mjs @@ -0,0 +1,202 @@ +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') { + 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', '--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('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 2a26cad4..9e536e46 100644 --- a/tests/lib/api-command-runtime.test.mjs +++ b/tests/lib/api-command-runtime.test.mjs @@ -46,7 +46,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'); }); @@ -65,7 +65,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'); @@ -74,6 +74,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', }; @@ -82,10 +84,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 index ec6f20d3..6fae1163 100644 --- a/tests/lib/athom-api.cache.test.mjs +++ b/tests/lib/athom-api.cache.test.mjs @@ -100,11 +100,11 @@ 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({ local: false }); + 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({ local: false }); + const homeys = await next.client.getHomeys({ usb: false }); assert.equal(first.request.mock.callCount(), 1); assert.equal(next.request.mock.callCount(), 0); @@ -184,12 +184,12 @@ describe('AthomApi persistent profile cache', () => { it('refreshes both the SDK user and Homey list when cache is false', async () => { const { client, request } = createClient(); - await client.getHomeys({ local: false }); + await client.getHomeys({ usb: false }); request.mock.mockImplementation(async () => { return { ...structuredClone(profile), homeys: [] }; }); - assert.deepEqual(await client.getHomeys({ cache: false, local: false }), []); + assert.deepEqual(await client.getHomeys({ cache: false, usb: false }), []); assert.equal(request.mock.callCount(), 2); }); @@ -544,12 +544,12 @@ describe('AthomApi persistent profile cache', () => { it('clears persistent and in-memory profiles on logout', async () => { const { client } = createClient(); - await client.getHomeys({ local: false }); + await client.getHomeys({ usb: false }); await client.logout(); assert.deepEqual(await settings.get('homeyApi'), {}); assert.equal(client._user, null); - assert.equal(client._homeys, null); + assert.equal(client._homeys.size, 0); assert.equal(await client._profileCache.get(), null); 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 171b912c..c568fbad 100644 --- a/tests/lib/athom-api.fetch.test.mjs +++ b/tests/lib/athom-api.fetch.test.mjs @@ -12,7 +12,7 @@ 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 }]; mock.method(athomApi, 'getProfile', async () => { return { @@ -37,14 +37,14 @@ 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 }]; mock.method(athomApi, 'getProfile', async () => { return { @@ -64,14 +64,14 @@ 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, undefined); + 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' }]; + const homeys = [{ id: 'homey-1', name: 'Homey One', platform: 'local', apiVersion: 3 }]; mock.method(athomApi, 'getProfile', async () => { return { @@ -97,7 +97,7 @@ describe('AthomApi local discovery fetch behavior', () => { (url) => new Promise((resolve) => inflight.push({ url, resolve })), ); - const pending = athomApi.getHomeys({ cache: false, local: true }); + const pending = athomApi.getHomeys({ cache: false, usb: true }); await setImmediate(); try { @@ -117,7 +117,7 @@ describe('AthomApi local discovery fetch behavior', () => { 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 }]; mock.method(athomApi, 'getProfile', async () => { return { @@ -135,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 84c34e21..67f73f50 100644 --- a/tests/lib/athom-api.selection.test.mjs +++ b/tests/lib/athom-api.selection.test.mjs @@ -107,7 +107,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..234e7739 --- /dev/null +++ b/tests/lib/athom-api.usb.test.mjs @@ -0,0 +1,252 @@ +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(); +}); + +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(); + 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(); + mockProbe(); + mockUsbRequests(client._api); + mock.method(AthomApiService, 'getHomey', async (id, options) => { + assert.deepEqual(options, { usb: true }); + 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(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(); + }); +}); From 42c728bfc4c2ac5a45dd9334f640a922b82022f4 Mon Sep 17 00:00:00 2001 From: Jeroen Wienk Date: Tue, 15 Sep 2026 09:11:06 +0200 Subject: [PATCH 6/7] fix: preserve newer profiles when older requests finish last --- lib/AthomApi.js | 4 +++- tests/lib/athom-api.cache.test.mjs | 33 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/AthomApi.js b/lib/AthomApi.js index 226de837..2e2c2a37 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -212,6 +212,8 @@ class AthomApi { 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 { @@ -244,7 +246,7 @@ class AthomApi { .set({ user: properties, authKey, - updatedAt: Date.now(), + updatedAt: requestStartedAt, }) .catch((err) => { Log.warning('Could not save the account profile cache:', err); diff --git a/tests/lib/athom-api.cache.test.mjs b/tests/lib/athom-api.cache.test.mjs index 6fae1163..c812985f 100644 --- a/tests/lib/athom-api.cache.test.mjs +++ b/tests/lib/athom-api.cache.test.mjs @@ -378,6 +378,39 @@ describe('AthomApi persistent profile cache', () => { 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(); From f2d5ca78ae4ef8c1d6821b6d817ccaa002230342 Mon Sep 17 00:00:00 2001 From: Jeroen Wienk Date: Tue, 15 Sep 2026 14:19:17 +0200 Subject: [PATCH 7/7] fix: preserve cache invalidation and report USB discovery failures --- README.md | 6 +- lib/AthomApi.js | 3 + lib/AthomApiProfileCache.js | 17 ++++-- lib/api/ApiCommandRuntime.mjs | 3 +- tests/cli/usb.test.mjs | 24 ++++++++ tests/lib/athom-api.cache.test.mjs | 97 ++++++++++++++++++++++++++++-- tests/lib/athom-api.usb.test.mjs | 10 ++- 7 files changed, 144 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 96da2a96..057a40f2 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ 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. The `api raw` aliases `call` and `request` also accept `--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 @@ -133,7 +134,8 @@ one minute before attempting another profile refresh. Live Homey API responses a 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 the profile cache. Cached profiles are bound to the OAuth access token +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. diff --git a/lib/AthomApi.js b/lib/AthomApi.js index 2e2c2a37..00d7a176 100644 --- a/lib/AthomApi.js +++ b/lib/AthomApi.js @@ -203,6 +203,7 @@ class AthomApi { 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; @@ -226,6 +227,7 @@ class AthomApi { try { const updated = await this._profileCache.setRetryAfter({ authKey, + generation, retryAfter: Date.now() + PROFILE_RATE_LIMIT_COOLDOWN, }); @@ -246,6 +248,7 @@ class AthomApi { .set({ user: properties, authKey, + generation, updatedAt: requestStartedAt, }) .catch((err) => { diff --git a/lib/AthomApiProfileCache.js b/lib/AthomApiProfileCache.js index 16828565..6a4f87e1 100644 --- a/lib/AthomApiProfileCache.js +++ b/lib/AthomApiProfileCache.js @@ -26,6 +26,10 @@ class AthomApiProfileCache { async set(value) { return await this._update((stored) => { + if ((stored?.generation ?? null) !== (value.generation ?? null)) { + return undefined; + } + if (stored?.authKey !== value.authKey) { return value; } @@ -43,8 +47,12 @@ class AthomApiProfileCache { }); } - async setRetryAfter({ authKey, retryAfter }) { + 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; @@ -56,7 +64,8 @@ class AthomApiProfileCache { async clear() { await this._update(() => { - return null; + // Retain only an invalidation marker so in-flight requests cannot restore account data. + return { generation: randomUUID() }; }); } @@ -79,9 +88,7 @@ class AthomApiProfileCache { throw lockError; } - if (value === null) { - await rm(this._path, { force: true }); - } else if (value) { + if (value) { await this._write(value); } diff --git a/lib/api/ApiCommandRuntime.mjs b/lib/api/ApiCommandRuntime.mjs index 7ad4dd9b..7fbb2e7c 100644 --- a/lib/api/ApiCommandRuntime.mjs +++ b/lib/api/ApiCommandRuntime.mjs @@ -223,7 +223,7 @@ export async function createHomeyApiClient({ token, address, homeyId, usb = fals } export async function diagnoseHomeyStrategies({ homeyId, usb = false } = {}) { - const homey = await resolveRequestedHomey(homeyId, { usb }); + let homey = await resolveRequestedHomey(homeyId); const preferredStrategyIds = usb ? ['usb'] : getPreferredAuthenticateStrategy(homey); const attemptedStrategyIds = usb ? ['usb'] : getDiagnoseStrategyOrder(homey); const results = []; @@ -246,6 +246,7 @@ export async function diagnoseHomeyStrategies({ homeyId, usb = false } = {}) { try { if (usb) { + homey = await AthomApi.getHomey(homey.id, { usb: true }); api = await authenticateHomey(homey, { usb: true }); await api.sessions.getSessionMe({ $socket: false }); } else { diff --git a/tests/cli/usb.test.mjs b/tests/cli/usb.test.mjs index 3dd2229b..cb4c3d43 100644 --- a/tests/cli/usb.test.mjs +++ b/tests/cli/usb.test.mjs @@ -58,6 +58,7 @@ function createUsbFixture(t) { 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); @@ -157,6 +158,29 @@ describe('CLI USB mode', () => { }); } + 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( diff --git a/tests/lib/athom-api.cache.test.mjs b/tests/lib/athom-api.cache.test.mjs index c812985f..faddf1e5 100644 --- a/tests/lib/athom-api.cache.test.mjs +++ b/tests/lib/athom-api.cache.test.mjs @@ -232,7 +232,7 @@ describe('AthomApi persistent profile cache', () => { }); await client._authenticateWithAuthorizationCode({ code: 'test-authorization-code' }); - assert.equal(await client._profileCache.get(), null); + assert.equal((await client._profileCache.get()).user, undefined); request.mock.mockImplementation(async () => { return { ...structuredClone(profile), _id: 'oauth-user' }; }); @@ -345,17 +345,54 @@ describe('AthomApi persistent profile cache', () => { } assert.equal((await pending).id, profile._id); - assert.equal((await createClient(otherProfile).client.getProfile()).id, 'other-account'); + 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')); }); } - it('keeps the request credential when login changes the same client', async () => { + 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 previousKey = client._profileAuthKey; const started = Promise.withResolvers(); const response = Promise.withResolvers(); request.mock.mockImplementation(async () => { @@ -371,7 +408,8 @@ describe('AthomApi persistent profile cache', () => { response.resolve(structuredClone(profile)); await pending; - assert.equal((await client._profileCache.get()).authKey, previousKey); + 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' }; }); @@ -575,6 +613,53 @@ describe('AthomApi persistent profile cache', () => { 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 }); @@ -583,7 +668,7 @@ describe('AthomApi persistent profile cache', () => { assert.deepEqual(await settings.get('homeyApi'), {}); assert.equal(client._user, null); assert.equal(client._homeys.size, 0); - assert.equal(await client._profileCache.get(), null); + 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.usb.test.mjs b/tests/lib/athom-api.usb.test.mjs index f9c95420..b1f60897 100644 --- a/tests/lib/athom-api.usb.test.mjs +++ b/tests/lib/athom-api.usb.test.mjs @@ -204,8 +204,7 @@ describe('USB opt-in discovery and connections', () => { AthomApiService.discoveryStrategies = ['cloud']; mockProbe(); mockUsbRequests(client._api); - mock.method(AthomApiService, 'getHomey', async (id, options) => { - assert.deepEqual(options, { usb: true }); + const resolve = mock.method(AthomApiService, 'getHomey', async (id, options) => { return await client.getHomey(id, options); }); mock.method(AthomApiService, '_initApi', async () => { @@ -220,6 +219,13 @@ describe('USB opt-in discovery and connections', () => { } 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');