diff --git a/README.md b/README.md index 8ceecd1..8e5ca75 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/bun.lock b/bun.lock index 27c1c72..afc2681 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "dependencies": { "@bitbard/core": "workspace:*", "@bitbard/spotify": "workspace:*", + "@bitbard/unifi": "workspace:*", "@clack/prompts": "catalog:", "chalk": "catalog:", "citty": "catalog:", @@ -66,6 +67,19 @@ "packages/typescript-config": { "name": "@bitbard/typescript-config", }, + "packages/unifi": { + "name": "@bitbard/unifi", + "dependencies": { + "@bitbard/core": "workspace:*", + }, + "devDependencies": { + "@bitbard/typescript-config": "workspace:*", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, }, "catalog": { "@clack/prompts": "1.3.0", @@ -102,6 +116,8 @@ "@bitbard/typescript-config": ["@bitbard/typescript-config@workspace:packages/typescript-config"], + "@bitbard/unifi": ["@bitbard/unifi@workspace:packages/unifi"], + "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], "@clack/prompts": ["@clack/prompts@1.3.0", "", { "dependencies": { "@clack/core": "1.3.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw=="], diff --git a/docs/commands/unifi.md b/docs/commands/unifi.md new file mode 100644 index 0000000..7ba77c0 --- /dev/null +++ b/docs/commands/unifi.md @@ -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 ] [--ringtone-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`. diff --git a/packages/cli/package.json b/packages/cli/package.json index ea61466..5812a8d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,6 +13,7 @@ "dependencies": { "@bitbard/core": "workspace:*", "@bitbard/spotify": "workspace:*", + "@bitbard/unifi": "workspace:*", "@clack/prompts": "catalog:", "chalk": "catalog:", "citty": "catalog:" diff --git a/packages/cli/src/bitbard.ts b/packages/cli/src/bitbard.ts index 6105374..ab0e7b4 100644 --- a/packages/cli/src/bitbard.ts +++ b/packages/cli/src/bitbard.ts @@ -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'; @@ -18,6 +19,7 @@ const main = defineCommand({ display, spotify, system, + unifi, vpn, upgrade, }, diff --git a/packages/cli/src/commands/unifi/chimes/index.ts b/packages/cli/src/commands/unifi/chimes/index.ts new file mode 100644 index 0000000..66c0d8e --- /dev/null +++ b/packages/cli/src/commands/unifi/chimes/index.ts @@ -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, + }, +}); diff --git a/packages/cli/src/commands/unifi/chimes/list.ts b/packages/cli/src/commands/unifi/chimes/list.ts new file mode 100644 index 0000000..3065cc2 --- /dev/null +++ b/packages/cli/src/commands/unifi/chimes/list.ts @@ -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})`); + } + }, +}); diff --git a/packages/cli/src/commands/unifi/chimes/play-speaker.ts b/packages/cli/src/commands/unifi/chimes/play-speaker.ts new file mode 100644 index 0000000..b64dea6 --- /dev/null +++ b/packages/cli/src/commands/unifi/chimes/play-speaker.ts @@ -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.'); + }, +}); diff --git a/packages/cli/src/commands/unifi/index.ts b/packages/cli/src/commands/unifi/index.ts new file mode 100644 index 0000000..ce35725 --- /dev/null +++ b/packages/cli/src/commands/unifi/index.ts @@ -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, + }, +}); diff --git a/packages/cli/src/commands/unifi/login.ts b/packages/cli/src/commands/unifi/login.ts new file mode 100644 index 0000000..9111031 --- /dev/null +++ b/packages/cli/src/commands/unifi/login.ts @@ -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'); + }, +}); diff --git a/packages/cli/src/commands/unifi/logout.ts b/packages/cli/src/commands/unifi/logout.ts new file mode 100644 index 0000000..249be7a --- /dev/null +++ b/packages/cli/src/commands/unifi/logout.ts @@ -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'); + }, +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 1cda663..ce868f6 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -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"] diff --git a/packages/unifi/package.json b/packages/unifi/package.json new file mode 100644 index 0000000..a6fd5af --- /dev/null +++ b/packages/unifi/package.json @@ -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:" + } +} diff --git a/packages/unifi/src/auth.ts b/packages/unifi/src/auth.ts new file mode 100644 index 0000000..de66b70 --- /dev/null +++ b/packages/unifi/src/auth.ts @@ -0,0 +1,106 @@ +import { get, set, del } from '@bitbard/core/security/keychain.js'; + +const SERVICE = 'bitbard-unifi'; +const ACCOUNT = 'credentials'; +const SESSION_ACCOUNT = 'session'; +const SESSION_EXPIRY_BUFFER_MS = 60 * 1000; + +export interface UnifiCredentials { + shared: { host: string }; + public: { apiKey: string }; + private: { username: string; password: string }; +} + +export interface TlsOptions { + rejectUnauthorized?: boolean; +} + +export interface FetchOptions { + tls?: TlsOptions; +} + +export type RequestInitWithTls = RequestInit & FetchOptions; + +export interface PrivateSession { + token: string; + csrf: string; + expiresAt: number; +} + +function decodeTokenExpiry(token: string): number { + const payload = token.split('.')[1]; + if (!payload) throw new Error('Invalid token: missing payload'); + const json = Buffer.from(payload, 'base64url').toString('utf8'); + const { exp } = JSON.parse(json) as { exp?: number }; + if (typeof exp !== 'number') throw new Error('Invalid token: missing exp claim'); + return exp * 1000; +} + +export async function saveCredentials(creds: UnifiCredentials): Promise { + await set(SERVICE, ACCOUNT, JSON.stringify(creds)); +} + +export async function getCredentials(): Promise { + const raw = await get(SERVICE, ACCOUNT); + return JSON.parse(raw) as UnifiCredentials; +} + +export async function isLoggedIn(): Promise { + try { + await getCredentials(); + return true; + } catch { + return false; + } +} + +export async function deleteCredentials(): Promise { + await Promise.allSettled([del(SERVICE, ACCOUNT), del(SERVICE, SESSION_ACCOUNT)]); +} + +async function saveSession(session: PrivateSession): Promise { + await set(SERVICE, SESSION_ACCOUNT, JSON.stringify(session)); +} + +async function loadSession(): Promise { + try { + const raw = await get(SERVICE, SESSION_ACCOUNT); + return JSON.parse(raw) as PrivateSession; + } catch { + return null; + } +} + +export async function getPrivateSession(host: string, username: string, password: string): Promise { + const cached = await loadSession(); + if (cached && Date.now() + SESSION_EXPIRY_BUFFER_MS < cached.expiresAt) { + return { token: cached.token, csrf: cached.csrf, expiresAt: cached.expiresAt }; + } + + const requestInit: RequestInitWithTls = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + rememberMe: false, + }), + tls: { + rejectUnauthorized: false, + }, + }; + const res = await fetch(`${host}/api/auth/login`, requestInit); + + if (!res.ok) throw new Error(`Login failed: ${res.status}`); + + const csrf = res.headers.get('x-csrf-token'); + const cookie = res.headers.get('set-cookie'); + const token = cookie?.match(/TOKEN=([^;]+)/)?.[1]; + + if (!token) throw new Error('No TOKEN cookie in login response'); + if (!csrf) throw new Error('No x-csrf-token in login response'); + + const session: PrivateSession = { token, csrf, expiresAt: decodeTokenExpiry(token) }; + await saveSession(session); + return session; +} diff --git a/packages/unifi/src/protect/chime.ts b/packages/unifi/src/protect/chime.ts new file mode 100644 index 0000000..8764c04 --- /dev/null +++ b/packages/unifi/src/protect/chime.ts @@ -0,0 +1,60 @@ +import { RequestInitWithTls, PrivateSession } from '../auth.js'; + +export interface RingSettings { + cameraId: string; + repeatTimes: number; + ringtoneId: string; + volume: number; +} + +export interface Chime { + id: string; + modelKey: string; + state: string; + name: string; + mac: string; + cameraIds: string[]; + ringSettings: RingSettings[]; +} + +export async function getChimes(host: string, apiKey: string): Promise { + const requestInit: RequestInitWithTls = { + method: 'GET', + headers: { + 'X-API-KEY': apiKey, + Accept: 'application/json', + }, + tls: { rejectUnauthorized: false }, + }; + const res = await fetch(`${host}/proxy/protect/integration/v1/chimes`, requestInit); + if (!res.ok) throw new Error(`Failed to fetch chimes: ${res.status} ${await res.text()}`); + return res.json() as Promise; +} + +export async function playSpeaker( + host: string, + authentication: PrivateSession, + chimeId: string, + options?: { + volume?: number; + ringtoneId?: string; + repeatTimes?: number; + }, +): Promise { + const requestInit: RequestInitWithTls = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `TOKEN=${authentication.token}`, + 'x-csrf-token': authentication.csrf, + }, + body: JSON.stringify({ + volume: options?.volume ?? 5, + repeatTimes: options?.repeatTimes ?? 1, + ringtoneId: options?.ringtoneId, + }), + tls: { rejectUnauthorized: false }, + }; + const res = await fetch(`${host}/proxy/protect/api/chimes/${chimeId}/play-speaker`, requestInit); + if (!res.ok) throw new Error(`Play failed: ${res.status} ${await res.text()}`); +} diff --git a/packages/unifi/tsconfig.json b/packages/unifi/tsconfig.json new file mode 100644 index 0000000..a5287d8 --- /dev/null +++ b/packages/unifi/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@bitbard/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@bitbard/core/*.js": ["../core/src/*.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/unifi/vitest.config.ts b/packages/unifi/vitest.config.ts new file mode 100644 index 0000000..6b7abc5 --- /dev/null +++ b/packages/unifi/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + passWithNoTests: true, + }, +});