Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A general-purpose CLI tool. Currently supports audio controls, display controls,
- [display](docs/commands/display.md)
- [spotify](docs/commands/spotify.md)
- [system](docs/commands/system.md)
- [unifi](docs/commands/unifi.md)
- [vpn](docs/commands/vpn.md)

## Installing and Upgrading
Expand Down Expand Up @@ -83,4 +84,5 @@ Then remove the `export PATH` line added by the installer from your shell config
| [display](docs/commands/display.md) | Display-related commands |
| [spotify](docs/commands/spotify.md) | Control the Spotify application |
| [system](docs/commands/system.md) | System-related commands |
| [unifi](docs/commands/unifi.md) | Control UniFi devices |
| [vpn](docs/commands/vpn.md) | VPN management commands |
16 changes: 16 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions docs/commands/unifi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# unifi

Control UniFi Protect devices.

## Authentication

UniFi exposes two distinct APIs that require different credentials:

**Public API (API key)** — The official REST API, authenticated via an `X-API-KEY` header. Generate an API key in the UniFi console under your user profile.

**Local user account (private API)** — Some operations (e.g. triggering chime playback) are only available through the internal session-based API. This requires a local UniFi OS user account with the appropriate permissions. Setting up this account is your responsibility — bitbard will use it to obtain a session token and cache it in the keychain, refreshing it automatically when it expires.

All requests bypass TLS certificate validation to support self-signed certificates on local controllers.

## Setup

```sh
bitbard unifi login
```

Prompts for:

- **Host** — controller URL, e.g. `https://192.168.1.1`
- **API key** — for the public API
- **Username / Password** — local user account for the private API

Credentials are stored in the macOS Keychain under `bitbard-unifi`.

```sh
bitbard unifi logout
```

Removes all stored credentials.

## Commands

### `unifi chimes list`

List all UniFi Protect chimes.

```sh
bitbard unifi chimes list
```

Uses the public API (API key).

### `unifi chimes play-speaker [id]`

Trigger audio playback on a chime's speaker.

```sh
bitbard unifi chimes play-speaker [id] [--volume <n>] [--ringtone-id <id>]
```

| Argument / Flag | Description |
| --------------- | --------------------------------------------------------------------------------- |
| `id` | Chime ID. If omitted, an interactive prompt lets you pick from discovered chimes. |
| `--volume` | Playback volume (integer). Defaults to `5`. |
| `--ringtone-id` | Ringtone ID to play. Defaults to the API default if omitted. |

Uses the private API (local user session). Requires a local user account to be configured via `bitbard unifi login`.
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@bitbard/core": "workspace:*",
"@bitbard/spotify": "workspace:*",
"@bitbard/unifi": "workspace:*",
"@clack/prompts": "catalog:",
"chalk": "catalog:",
"citty": "catalog:"
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/bitbard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import audio from './commands/audio/index.js';
import display from './commands/display/index.js';
import spotify from './commands/spotify/index.js';
import system from './commands/system/index.js';
import unifi from './commands/unifi/index.js';
import vpn from './commands/vpn/index.js';
import upgrade from './commands/upgrade.js';

Expand All @@ -18,6 +19,7 @@ const main = defineCommand({
display,
spotify,
system,
unifi,
vpn,
upgrade,
},
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/unifi/chimes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineCommand } from 'citty';
import list from './list.js';
import playSpeaker from './play-speaker.js';

export default defineCommand({
meta: {
name: 'chimes',
description: 'Manage UniFi Protect chimes',
},
subCommands: {
list,
'play-speaker': playSpeaker,
},
});
28 changes: 28 additions & 0 deletions packages/cli/src/commands/unifi/chimes/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineCommand } from 'citty';
import { isLoggedIn, getCredentials } from '@bitbard/unifi/auth.js';
import { getChimes } from '@bitbard/unifi/protect/chime.js';

export default defineCommand({
meta: {
name: 'list',
description: 'List UniFi Protect chimes',
},
async run() {
if (!(await isLoggedIn())) {
console.log('UniFi login required. Run: bitbard unifi login');
return;
}

const creds = await getCredentials();
const chimes = await getChimes(creds.shared.host, creds.public.apiKey);

if (chimes.length === 0) {
console.log('No chimes found.');
return;
}

for (const chime of chimes) {
console.log(`${chime.name} ${chime.state} (${chime.id})`);
}
},
});
82 changes: 82 additions & 0 deletions packages/cli/src/commands/unifi/chimes/play-speaker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { defineCommand } from 'citty';
import { select, isCancel, spinner } from '@clack/prompts';
import { isLoggedIn, getCredentials, getPrivateSession } from '@bitbard/unifi/auth.js';
import { getChimes, playSpeaker } from '@bitbard/unifi/protect/chime.js';

export default defineCommand({
meta: {
name: 'play-speaker',
description: 'Play a sound on a UniFi Protect chime',
},
args: {
id: {
type: 'positional',
description: 'Chime ID (optional — shows list if omitted)',
required: false,
},
'ringtone-id': {
type: 'string',
description: 'Ringtone ID',
required: false,
},
volume: {
type: 'string',
description: 'Volume (integer)',
required: false,
},
},
async run({ args }) {
if (!(await isLoggedIn())) {
console.log('UniFi login required. Run: bitbard unifi login');
return;
}

const creds = await getCredentials();
const { host } = creds.shared;
const { apiKey } = creds.public;
const { username, password } = creds.private;

let chimeId: string;

if (args.id) {
chimeId = args.id;
} else {
const chimes = await getChimes(host, apiKey);

if (chimes.length === 0) {
console.log('No chimes found.');
return;
}

const choice = await select({
message: 'Select a chime',
options: chimes.map((c) => ({ value: c.id, label: c.name })),
});

if (isCancel(choice)) {
return;
}

chimeId = choice as string;
}

const auth = await getPrivateSession(host, username, password);

const parsedVolume = args.volume !== undefined ? parseInt(args.volume, 10) : undefined;
const volume = parsedVolume !== undefined && !Number.isNaN(parsedVolume) ? parsedVolume : undefined;
const ringtoneId = args['ringtone-id'];

const s = spinner();
s.start('Playing chime…');
try {
await playSpeaker(host, auth, chimeId, {
volume,
ringtoneId,
});
} catch (err) {
s.stop('Failed to play chime');
throw err;
}
s.stop('Done.');
},
});
16 changes: 16 additions & 0 deletions packages/cli/src/commands/unifi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineCommand } from 'citty';
import login from './login.js';
import logout from './logout.js';
import chimes from './chimes/index.js';

export default defineCommand({
meta: {
name: 'unifi',
description: 'Control UniFi Devices',
},
subCommands: {
login,
logout,
chimes,
},
});
53 changes: 53 additions & 0 deletions packages/cli/src/commands/unifi/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { defineCommand } from 'citty';
import { text, password, isCancel, log } from '@clack/prompts';
import { isLoggedIn, saveCredentials } from '@bitbard/unifi/auth.js';

export default defineCommand({
meta: {
name: 'login',
description: 'Log in to UniFi',
},
async run() {
if (await isLoggedIn()) {
console.log('Already logged in to UniFi. Run: bitbard unifi logout to switch accounts.');
return;
}

const host = await text({
message: 'UniFi host',
placeholder: 'https://192.168.1.1',
});
if (isCancel(host)) {
return;
}

const apiKey = await text({
message: 'API key (public API)',
});
if (isCancel(apiKey)) {
return;
}

const username = await text({
message: 'Username (local user)',
});
if (isCancel(username)) {
return;
}

const userPassword = await password({
message: 'Password',
});
if (isCancel(userPassword)) {
return;
}

await saveCredentials({
shared: { host: host as string },
public: { apiKey: apiKey as string },
private: { username: username as string, password: userPassword as string },
});

log.success('Logged in to UniFi');
},
});
14 changes: 14 additions & 0 deletions packages/cli/src/commands/unifi/logout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineCommand } from 'citty';
import { log } from '@clack/prompts';
import { deleteCredentials } from '@bitbard/unifi/auth.js';

export default defineCommand({
meta: {
name: 'logout',
description: 'Log out of UniFi',
},
async run() {
await deleteCredentials();
log.success('Logged out of UniFi');
},
});
3 changes: 2 additions & 1 deletion packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"noEmit": true,
"paths": {
"@bitbard/core/*.js": ["../core/src/*.ts"],
"@bitbard/spotify/*.js": ["../spotify/src/*.ts"]
"@bitbard/spotify/*.js": ["../spotify/src/*.ts"],
"@bitbard/unifi/*.js": ["../unifi/src/*.ts"]
}
},
"include": ["src/**/*.ts"]
Expand Down
23 changes: 23 additions & 0 deletions packages/unifi/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@bitbard/unifi",
"private": true,
"type": "module",
"exports": {
"./*.js": "./src/*.ts",
"./*": "./src/*.ts"
},
"scripts": {
"check-types": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@bitbard/core": "workspace:*"
},
"devDependencies": {
"@bitbard/typescript-config": "workspace:*",
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
Loading