/** * The consumer-facing entry point and composition root. {@link ProtectClient.connect} is the one way in: it performs login and the initial bootstrap as a single atomic * operation, returning a fully-ready client or throwing a typed `FatalError`. There is no half-constructed state - a failure at any stage tears down what was built (no * leaked connection pool) before the error propagates. * * The client *composes* its subsystems rather than inheriting from any of them. It owns a {@link Transport}, an {@link AuthSession}, a {@link StateStore}, a {@link * DeviceRegistry}, and a private {@link EventBus} - and exposes a deliberately small surface over them: * * - `state` is the data layer: synchronous `snapshot()` and live `observe(selector)` over config records. * - `cameras` / `camera(id)` (and the other device accessors) are the device layer: live {@link DeviceProjection} handles built by the registry. They live on the * client, not on `state`, because a projection is Layer 3 - it needs the client - while `state` stays a pure generic engine. * - `transport` is the documented escape hatch for raw API calls the typed surface does not yet cover. * - `on` / `once` / `stream` are the rail-based event surface, delegated to the private bus; the realtime firehose rides the `packet` rail (see `events()`). * * @module ProtectClient */ import type { ProtectNvrLiveviewConfig, ProtectRingtoneConfig } from "../types/index.ts"; import type { Camera } from "../devices/camera.ts"; import type { Chime } from "../devices/chime.ts"; import type { Clock } from "../clock.ts"; import { ConnectionMonitor } from "./connection.ts"; import type { Dispatcher } from "undici"; import type { Fob } from "../devices/fob.ts"; import type { Light } from "../devices/light.ts"; import type { Nvr } from "../devices/nvr.ts"; import type { ProtectLogging } from "../logging.ts"; import type { ProtectWebSocket } from "../transport/ws.ts"; import type { RawPacket } from "../protocol/packet.ts"; import type { RecoveryPolicy } from "./livestream-pool.ts"; import type { Relay } from "../devices/relay.ts"; import type { Sensor } from "../devices/sensor.ts"; import { StateStore } from "../state/store.ts"; import type { StreamOptions } from "../event-bus.ts"; import { Transport } from "../transport/http.ts"; import type { TypedEvent } from "../protocol/events.ts"; import type { Viewer } from "../devices/viewer.ts"; /** * Options for {@link ProtectClient.connect}. `host`, `username`, and `password` are the required controller address and credentials; the rest are optional injected * seams, each defaulted at the composition root: * * - `clock` is the time source threaded into every subsystem (transport, store, event stream, livestream pool); it defaults to the wall clock, and tests inject a * fake to drive timers deterministically. * - `dispatcher` is an undici `Dispatcher` for the transport's connection pool; when omitted the transport builds and owns its own pool. * - `log` is the structured logger; it defaults to a no-op sink. * - `recoveryPolicy` is the per-stream livestream recovery decision authority handed to the livestream pool; it defaults to {@link * defaultLivestreamRecoveryPolicy}. * - `refreshIntervalMs` sets the {@link StateStore}'s bootstrap-refresh failsafe cadence; it defaults to the store's interval, and passing `false` disables the * failsafe entirely so the consumer relies solely on the realtime event stream. * - `signal` cancels the initial login and bootstrap fetch; it is bound only to connect, never to a later recovery relaunch. * - `webSocket` is the receive-direction {@link ProtectWebSocket} factory used by the events stream and livestream pool; it defaults to a real socket and is the * socket-injection seam for tests. It does not apply to the write-direction talkback socket. * * @category Client */ export interface ConnectOptions { clock?: Clock; dispatcher?: Dispatcher; host: string; log?: ProtectLogging; password: string; recoveryPolicy?: RecoveryPolicy; refreshIntervalMs?: number | false; signal?: AbortSignal; username: string; webSocket?: (url: string) => ProtectWebSocket; } /** * The events the {@link ProtectClient} publishes. `packet` is the realtime event firehose - the same `TypedEvent` stream that feeds the reducer, fanned out to consumers. * It is produced by the realtime events WebSocket and bridged here at the composition root: each decoded event is dispatched into the store and then emitted on this rail * from the one bridge subscription, so there is a single decode path for both the state model and the firehose. * * `rawPacket` is the raw observability firehose - the decoded {@link RawPacket} of every frame, including the valid-but-unmodeled ones the classifier drops (so `packet` * never sees them). It is bridged from the same single decode site, one step before classification, and is pure observability: never dispatched into the store, no * state-ordering guarantee. Consumers use it for protocol exploration and capture; the modeled state and event model still flow through `packet`. * * @category Client */ export interface ProtectClientEvents { packet: [event: TypedEvent]; rawPacket: [packet: RawPacket]; } /** * The UniFi Protect client. Construct via {@link ProtectClient.connect}. * * @category Client */ export declare class ProtectClient implements AsyncDisposable { #private; private constructor(); /** * Connect to a Protect controller. Logs in, fetches the initial bootstrap, seeds the reducer, and wires the periodic refresh failsafe - all as one atomic operation. * * @param opts - The controller address, credentials, and optional seams (logger, clock, refresh interval, injected dispatcher, abort signal). * * @returns A fully-ready client. * * @throws {@link ProtectAuthError} on bad credentials, {@link ProtectNetworkError} when the controller is unreachable, {@link ProtectBootstrapError} when the * bootstrap cannot be fetched or parsed - and any other typed `FatalError` the login or fetch surfaces. No partial client escapes a failure. */ static connect(opts: ConnectOptions): Promise; /** * Decode a raw realtime packet's bytes into a {@link RawPacket} - the validated wire header plus the data-frame payload, *before* classification. A static, pure, * connection-independent operation: it is the offline counterpart of the {@link ProtectClient.rawPackets} rail (which is this same decode applied to the live socket), * and it is a `static` rather than an instance method precisely because decoding needs no client - it reads no connection state. Pair it with {@link * ProtectClient.classifyPacket} to reproduce the live pipeline (`classifyPacket(decodePacket(bytes))`) over captured bytes. * * @param buf - The raw packet bytes (a `Buffer` included; read without copying). * * @returns The decoded raw packet. * * @throws {@link ProtectProtocolError} if the bytes are not a well-formed packet. */ static decodePacket(buf: Uint8Array): RawPacket; /** * Classify a decoded {@link RawPacket} into a {@link TypedEvent}, or `null` when the packet is valid but describes something the library does not model. The static, * pure, connection-independent counterpart of the {@link ProtectClient.events} rail (which is this same classification applied to the live socket). Takes the output of * {@link ProtectClient.decodePacket}, so the two compose to turn captured bytes into a typed event exactly as the realtime path does - one decode/classify * implementation, reached the same way live and offline. * * @param packet - The decoded raw packet. * * @returns The classified event, or `null` for a valid-but-unmodeled packet. */ static classifyPacket(packet: RawPacket): TypedEvent | null; /** * The id(s) of the record(s) a {@link TypedEvent} concerns - a camera for a camera activity signal, the access device for an access occurrence, the reduced * record for a state transition, none for the synthetic bootstrap. The static, pure counterpart that attributes any firehose event to its device(s) without a * consumer re-deriving the mapping - the single source of truth the CLI's `--device` filter and event rendering both read, surfaced on the object model exactly * like {@link ProtectClient.classifyPacket}. Total and exhaustive: it cannot silently lag a new event kind. * * @param event - Any {@link TypedEvent}, e.g. from {@link ProtectClient.events} or {@link ProtectClient.classifyPacket}. * * @returns The subject record id(s), possibly empty. */ static eventSubjects(event: TypedEvent): readonly string[]; /** The data layer: synchronous `snapshot()` and live `observe(selector)` over the reduced controller state. */ get state(): StateStore; /** The HTTP transport - the documented escape hatch for raw API calls the typed surface does not cover. */ get transport(): Transport; /** The connection monitor: the observable connection-state FSM, reboot detection, throttle/stall folding, and auto-recovery of the realtime events channel. */ get connection(): ConnectionMonitor; /** * Whether the authenticated session holds Super Admin (camera-write) privileges. Sync sugar over {@link selectIsAdmin} of the current snapshot, for the hot-path read * where awaiting an iterator is overkill; consumers `observe` the selector instead. Re-evaluated from state, so a role change surfaces on the next refresh. */ get isAdmin(): boolean; /** The controller's display name, or `null` before the first bootstrap. Sync sugar over {@link selectControllerName} of the current snapshot. */ get controllerName(): string | null; /** * The NVR configuration as a read-only, observable projection. A singleton sibling of the device projections: `client.nvr` always returns a handle (the NVR is a * singleton, so there is no `undefined` lookup), whose read-through getters (`host`, `rtspPort`, `name`, `marketName`) and `observe()` reflect the live reduced state. * It bears no commands - NVR config writes are out of scope and controller reboot is {@link ProtectClient.reboot}. The getters throw only before the first bootstrap, * which a connected client never reaches (`connect()` awaits it). */ get nvr(): Nvr; /** * The controller's saved liveviews, as read-only config records. Sync sugar over {@link selectLiveviews} of the current snapshot - the common "list them" read; for a * by-id lookup use `selectLiveview(id)`, and for reactivity `client.state.observe(selectLiveviews)`. Liveviews are read-only config (no commands), so they are bare * records rather than a command-bearing projection, and a top-level collection rather than a child of `client.nvr` - they ride the realtime wire exactly as devices do. */ get liveviews(): readonly ProtectNvrLiveviewConfig[]; /** * The controller's ringtone library, as read-only config records. Sync sugar over {@link selectRingtones} of the current snapshot; for a by-id lookup use * `selectRingtone(id)`, and for reactivity `client.state.observe(selectRingtones)`. Surfaced identically to {@link liveviews} - the difference is only in lifecycle * (ringtones advance on the bootstrap refresh, as the controller broadcasts no realtime ringtone delta), which is invisible at this read-through surface. */ get ringtones(): readonly ProtectRingtoneConfig[]; /** Every camera in the current state, as live projections. */ get cameras(): readonly Camera[]; /** Every chime in the current state, as live projections. */ get chimes(): readonly Chime[]; /** Every fob in the current state, as live projections. */ get fobs(): readonly Fob[]; /** Every light in the current state, as live projections. */ get lights(): readonly Light[]; /** Every relay in the current state, as live projections. */ get relays(): readonly Relay[]; /** Every sensor in the current state, as live projections. */ get sensors(): readonly Sensor[]; /** Every viewer in the current state, as live projections. */ get viewers(): readonly Viewer[]; /** Look up a camera by id, or `undefined` when no such camera is in the current state. */ camera(id: string): Camera | undefined; /** Look up a chime by id, or `undefined` when absent. */ chime(id: string): Chime | undefined; /** Look up a fob by id, or `undefined` when absent. */ fob(id: string): Fob | undefined; /** Look up a light by id, or `undefined` when absent. */ light(id: string): Light | undefined; /** Look up a relay by id, or `undefined` when absent. */ relay(id: string): Relay | undefined; /** Look up a sensor by id, or `undefined` when absent. */ sensor(id: string): Sensor | undefined; /** Look up a viewer by id, or `undefined` when absent. */ viewer(id: string): Viewer | undefined; /** * Subscribe to a client event. Returns a `Disposable`; prefer `using sub = client.on(...)` so the listener detaches at scope exit. * * @param event - The event to listen for. * @param handler - Invoked on each emission. * * @returns A `Disposable` that removes the listener when disposed. */ on(event: K, handler: (...args: ProtectClientEvents[K]) => void): Disposable; /** * Wait for the next emission of a client event. * * @param event - The event to await. * @param opts - Optional abort signal. * * @returns A promise resolving to the event's argument tuple. */ once(event: K, opts?: { signal?: AbortSignal; }): Promise; /** * Stream every subsequent emission of a client event until the signal aborts. * * @param event - The event to stream. * @param opts - Stream options, including the abort signal. * * @returns An async iterable of the event's argument tuples. */ stream(event: K, opts?: StreamOptions): AsyncIterable; /** * The realtime event firehose: every {@link TypedEvent} the controller emits, until the signal aborts or the client is disposed. Sugar over `stream("packet")` that * unwraps the single-element argument tuple. Each event has already been dispatched into the store by the time it reaches here, so reading `client.state` inside the * loop observes the post-event state. * * @param opts - Optional abort signal that terminates the iteration. * * @returns An async iterable of typed events. */ events(opts?: { signal?: AbortSignal; }): AsyncGenerator; /** * The raw realtime firehose: every decoded {@link RawPacket} the controller emits - header plus payload, *before* classification - until the signal aborts or the * client is disposed. Unlike {@link ProtectClient.events}, this carries the valid-but-unmodeled frames the classifier drops (an unrecognized model key, an unhandled * action, a non-self-describing `event`), so it is the surface for protocol exploration and capture rather than the modeled state/event model. It is pure * observability: a raw packet is never dispatched into the store, so reading `client.state` inside this loop carries no ordering guarantee relative to the frame. Sugar * over `stream("rawPacket")` that unwraps the single-element argument tuple and smooths the caller's own abort into a clean return. * * @param opts - Optional abort signal that terminates the iteration. * * @returns An async iterable of raw packets. */ rawPackets(opts?: { signal?: AbortSignal; }): AsyncGenerator; /** * Fetch the controller bootstrap on demand and return the untouched parsed JSON. The library does not *retain* the raw bootstrap - it is consumed once at connect (and * on each refresh / recovery re-bootstrap) as a synthetic `bootstrapLoaded` event, and only the reduced state survives - so this is a fresh GET over the single shared * bootstrap-fetch path, returning the controller's response verbatim *without* dispatching it into the store. It is a faithful raw dump for diagnostics, not a cached * blob; the reduced, observable model remains `client.state`. The return type is `unknown` deliberately: this is whatever the controller sent, not the curated model. * * @param opts - Optional abort signal. * * @returns The raw bootstrap JSON as returned by the controller. * * @throws {@link ProtectBootstrapError} when the bootstrap cannot be fetched or parsed, or a transport-level `ProtectError`. */ fetchBootstrap(opts?: { signal?: AbortSignal; }): Promise; /** * Reboot the UniFi OS controller hosting Protect. This is a controller-level operation - a UniFi-OS call (`POST /api/system/reboot`), distinct from a device's {@link * DeviceProjection.reboot}: it restarts the whole console, so every device drops and re-adopts. It lives on the client, not a projection, because it is out of the * Protect device model. The connection monitor observes the resulting outage and recovers automatically, firing `controllerLost` / `controllerRecovered` and - on the * controller's new boot time - `controllerRebooted`. * * @param opts - Optional abort signal. * * @throws The classified `FatalError` on a non-2xx (e.g. {@link ProtectAuthorizationError} when the account lacks reboot rights), or a transport-level `ProtectError`. */ reboot(opts?: { signal?: AbortSignal; }): Promise; /** * Dispose the client: tear down the connection monitor (stopping recovery and closing the events stream), close every livestream session in the pool, stop the refresh * failsafe and terminate observers, end the session, and destroy the connection pool - in that order, so ingestion stops before the state and transport it feeds. * Idempotent and the canonical teardown via `await using client = await ProtectClient.connect(...)`. */ [Symbol.asyncDispose](): Promise; } //# sourceMappingURL=client.d.ts.map