Skip to content
Open
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,69 @@ Then restart your shell, or run:
source ~/.zshrc
```

## USB connections

USB discovery is now opt-in. Existing USB workflows must add `--usb` or set `HOMEY_USB=1`.
Normal commands use their network connection strategies and do not probe USB candidate addresses.

```bash
homey list --usb
homey select --usb
homey app run --usb
homey app install --usb
homey api system get-info --usb
homey api raw --usb --path /api/manager/system/
homey api diagnose --usb
```

`list --usb` and `select --usb` show only USB-connected Homeys, including devices whose cached
Cloud status is offline. Selection saves the Homey, not USB mode. For subsequent commands,
pass `--usb` again or enable it for your development shell:

```bash
export HOMEY_USB=1
homey app run
homey list --no-usb
```

Explicit `--no-usb` overrides the shell setting. USB mode requires a local Homey using API v3
and fails if the selected or requested Homey is not found over USB; it does not fall back to LAN
or Cloud transport. Enabled USB mode takes precedence over `--discovery-strategies`.
Account lookup and authentication or session renewal can still require
Athom Cloud. USB discovery probes unique candidate addresses concurrently with a one-second
timeout and does not persist discovery results in the account cache.

`homey app run --remote --usb` runs the app on Homey over USB. API token mode supports
`--token <TOKEN> --homey-id <HOMEY_ID> --usb`. An explicit `--address` cannot be combined with
enabled USB mode; add `--no-usb` if your shell enables it. `api diagnose --usb` checks only USB
connectivity and reports a failed `usb` attempt when the device is disconnected.
The `api raw` aliases `call` and `request` also accept `--usb`.

## Homey API CLI

Use `homey api` for direct Homey API access.

### Account caching and rate limits

The CLI caches your account profile and Homey connection details on disk for five minutes,
so successive commands can reuse them. The cache is stored separately in `profile-cache.json`
alongside `settings.json`, so refreshing it does not rewrite account or active Homey settings.
Once the cache expires, the next command refreshes it.
If that refresh receives HTTP 429, the CLI continues with the cached data and waits at least
one minute before attempting another profile refresh. Live Homey API responses are not cached.

Use `homey list --refresh` or `homey whoami --refresh` to refresh account data before the cache
expires. These options still respect the rate-limit cooldown and fall back to cached data on 429.
Logging in or out clears account data from the profile cache, retaining only a random generation
marker so pending requests cannot restore it. Cached profiles are bound to the OAuth access token
or PAT that fetched them, so a different credential (including a rotated OAuth token) starts a new
cache. Concurrent updates preserve newer profile data and active cooldowns. Cache I/O failures
produce a warning without discarding a fetched profile or an available rate-limit fallback.

A first login or an expired Homey session can still require Cloud API access. Without cached
account data, a profile request that receives HTTP 429 still fails. For direct local API access,
`homey api` also supports `--token <TOKEN> --address <URL>` without an account lookup.

### Raw requests

```bash
Expand Down
5 changes: 4 additions & 1 deletion bin/cmds/api/diagnose.mjs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <id> --json',
'Diagnose discovery strategies for a cached Homey and print JSON output',
Expand All @@ -69,6 +71,7 @@ export const handler = async (argv = {}) => {
try {
const report = await diagnoseHomeyStrategies({
homeyId: argv.homeyId,
usb: argv.usb,
});

printStructuredOutput({
Expand Down
1 change: 1 addition & 0 deletions bin/cmds/api/raw.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions bin/cmds/app/install.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion bin/cmds/app/run.mjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions bin/cmds/list.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -69,20 +70,29 @@ function printHomeysTable(homeys) {
}

export const builder = (yargs) => {
return applyJqOutputOption(applyJsonOutputOption(yargs))
return applyUsbOption(applyJqOutputOption(applyJsonOutputOption(yargs)))
.option('refresh', {
type: 'boolean',
default: false,
desc: 'Refresh cached account data unless the Cloud API is rate limited',
})
.example('$0 list --json', 'Output Homeys as JSON')
.example('$0 list --usb', 'List only USB-connected Homeys')
.example("$0 list --jq '.[].name'", 'Print all Homey names using jq')
.help();
};

export const handler = async (argv = {}) => {
try {
const homeys = sortHomeys(await AthomApi.getHomeys()).map(toHomeyOutput);
const homeys = await AthomApi.getHomeys({ cache: !argv.refresh, usb: argv.usb });
const output = sortHomeys(homeys).map(toHomeyOutput);

printStructuredOutput({
value: homeys,
value: output,
argv,
printHuman: () => printHomeysTable(homeys),
printHuman: () => {
return printHomeysTable(output);
},
});

process.exit(0);
Expand Down
5 changes: 4 additions & 1 deletion bin/cmds/select.mjs
Original file line number Diff line number Diff line change
@@ -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'],
})
Expand All @@ -18,6 +19,7 @@ export const builder = (yargs) => {
type: 'string',
})
.example('$0 select --id <HOMEY_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();
};
Expand All @@ -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) {
Expand Down
14 changes: 11 additions & 3 deletions bin/cmds/whoami.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,27 @@ 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();
};

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);
Expand Down
6 changes: 4 additions & 2 deletions lib/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ class App {
}

async run({
usb = false,
clean = false,
remote = false,
skipBuild = false,
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion lib/AppPython.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class AppPython extends App {
}

async run({
usb = false,
clean = false,
remote = false,
skipBuild = false,
Expand All @@ -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);

Expand Down
Loading
Loading