homebridge-unifi-protect
Version:
Homebridge UniFi Protect plugin providing complete HomeKit integration for the entire UniFi Protect ecosystem with full support for most features including HomeKit Secure Video, multiple controllers, blazing fast performance, and much more.
618 lines (617 loc) • 96.3 kB
JavaScript
import { APIEvent, MqttClient, formatErrorMessage, formatSeconds, loopFaultReporter, prefixedLog, retry, sanitizeName, superviseLoop } from "homebridge-plugin-utils";
import { DEVICE_COLLECTION_KEYS, ProtectClient, deviceSelectors, channels as protectChannels } from "unifi-protect";
import { PLATFORM_NAME, PLUGIN_NAME, PROTECT_NVR_CONTROLLER_DISABLED_SETTLE_DELAY, PROTECT_NVR_REBOOT_CONFIRM_GRACE_MS, PROTECT_NVR_REBOOT_DEFERRAL_MAX, PROTECT_NVR_REBOOT_INTERVAL, PROTECT_NVR_REBOOT_MIN_INTERVAL, PROTECT_NVR_REBOOT_RECENCY_MS, PROTECT_NVR_REMOVAL_STABILITY_WINDOW } from "../settings.js";
import { canTransition, computeStableSince, createConnectRetryPolicy, createLivestreamEpisodeLatch, isInducedDisruption, isRequestForController, isStabilityWindowElapsed, isSuccessfulRequest, isWithinRebootRecency, membershipDelta, shouldResumeFromInducedReboot } from "./nvr-policy.js";
import { exhaustiveGuard, isPackageCameraContext } from "../types.js";
import { DoorbellCapability } from "../devices/cameras/doorbell.js";
import { NvrHealth } from "./nvr-health.js";
import { ProtectCamera } from "../devices/cameras/camera.js";
import { ProtectCameraPackage } from "../devices/cameras/camera-package.js";
import { ProtectChime } from "../devices/chime.js";
import { ProtectEventDispatch } from "./event-dispatch.js";
import { ProtectFob } from "../devices/fob.js";
import { ProtectLight } from "../devices/light.js";
import { ProtectLiveviews } from "../liveviews/liveviews.js";
import { ProtectNvrSystemInfo } from "./nvr-systeminfo.js";
import { ProtectRelay } from "../devices/relay.js";
import { ProtectSensor } from "../devices/sensor.js";
import { ProtectViewer } from "../devices/viewer.js";
import { channels } from "../diagnostics.js";
import { describeDevice } from "../devices/device-descriptor.js";
import { livestreamRecoveryDecision } from "../media/livestream-recovery-policy.js";
import { servePlaylist } from "./nvr-playlist.js";
import { setTimeout as sleep } from "node:timers/promises";
export class ProtectNvr {
api;
// The unifi-protect client - the single owner of protocol truth (reduced state, realtime decode, refresh failsafe, connection health). Established by connect() via
// ProtectClient.connect() and torn down through its Symbol.asyncDispose. Definite-assignment typed for the connected lifetime; it is genuinely undefined only in the
// narrow window before the first connect() (guarded where that window is reachable, e.g. disconnect()).
client;
config;
configuredDevices;
// The per-client connection-health subscriptions (throttle rails and controller-lost/recovered lifecycle). Re-wired on every connect() against the live client and
// disposed on disconnect(), so they never outlive the client whose connection monitor they observe.
connectionSubscriptions;
// The instant the controller entered its current continuous good state (running + reachable + healthy), or null when it is not good. Backdated by the controller's
// uptime at the first good-state entry so a long-up controller is trusted immediately; reset on any disruption. The clock the removal stability gate reads.
controllerStableSince;
// Pending delayed device removals, keyed by accessory UUID (the one key that resolves both a membership-leave AND a startup cache-orphan, since an orphan has no live
// device id). Scheduled only when the controller is stable; cleared wholesale on any disruption; the fire re-checks live controller state through the caller-supplied
// predicate (see scheduleDeviceRemoval's own documentation for the full set of checks a predicate can perform).
deviceRemovalTimers;
events;
featureLog;
hap;
// True once the controller has reached good-state at least once. Distinguishes the initial startup entry (uptime-backdated) from a later recovery (counted from now).
hasStabilizedOnce;
health;
// The plugin's own-clock timestamp (Date.now()) of the last controller reboot we observed via the library's jitter-thresholded `controllerRebooted` event, or null
// until one is seen this process. The per-camera livestream-disruption logs read this to recognize a blip that is the tail of a recent reboot - which fires after the
// plugin has left the induced phase - and quiet it, distinct from a genuine single-camera drop. Not a controller clock: it is when WE observed the reboot, on our own
// wall clock.
lastRebootObservedAt;
// Per-episode quiet-classification latch for the per-camera livestream-disruption logs. The interruption edge records whether the episode should be logged quietly
// (the tail of an induced disruption or a recently-observed reboot); the recovery edge - which can no longer read the phase reliably - consumes that classification.
// Reclaimed on the camera's removal.
livestreamEpisodes = createLivestreamEpisodeLatch();
liveviews;
log;
mqtt;
name;
nvrRebootTimer;
// The post-reboot no-op confirmation timer. Armed after a successful reboot command to catch a controller that accepted the command but never actually restarted.
rebootConfirmTimer;
// Lifecycle phase. The SSOT for "what is this NVR doing right now?". Consumed by components that need to distinguish induced from organic disruption.
// Mutated only through `transition()`, never directly.
_phase;
platform;
// The terminal plugin-shutdown abort. Aborted once, in transition("shuttingDown"); every NVR-level observe loop and every per-accessory controller composes against
// it, so plugin shutdown tears the whole tree down as one cascade. Initialized in the constructor so it exists before any device is constructed.
#shutdownController;
// One-shot timer that fires when the stability window first elapses for the current good-state period, triggering the membership + orphan sweep. Cleared on disruption.
stabilityReachedTimer;
systemInfo;
unsupportedDevices;
// The unifi-protect client logger. Shares the controller-log destination but gates error output through `logApiErrors` so induced-disruption noise is suppressed. Typed
// as the unifi-protect library's own ProtectLogging contract, single-sourced with what ProtectClient.connect() expects.
clientLog;
// The one place the device-category vocabulary is wired to its selectors, projections, and constructors. The membership observe loops, the stability sweep, and the
// per-fire stillGone re-check read the content-memoized adopted-id set through `.selectors.adoptedIds`; adoption resolves the live unifi-protect projection through
// `.projection` and builds the HomeKit device through `.construct`. A category added upstream is wired in exactly one place here rather than re-listed at each reader.
deviceDescriptors = {
// The camera row builds a ProtectCamera for both plain cameras and doorbells: a device the controller reports as a doorbell attaches its DoorbellCapability through
// ProtectCamera's own construction-time reconcile, so there is one construction path for both arrival timings (doorbell-at-adoption and a late isDoorbell flip).
camera: { construct: (accessory, camera) => new ProtectCamera(this, accessory, camera), projection: (client, id) => client.camera(id),
selectors: deviceSelectors.camera },
chime: { construct: (accessory, chime) => new ProtectChime(this, accessory, chime), projection: (client, id) => client.chime(id),
selectors: deviceSelectors.chime },
fob: { construct: (accessory, fob) => new ProtectFob(this, accessory, fob), projection: (client, id) => client.fob(id), selectors: deviceSelectors.fob },
light: { construct: (accessory, light) => new ProtectLight(this, accessory, light), projection: (client, id) => client.light(id),
selectors: deviceSelectors.light },
relay: { construct: (accessory, relay) => new ProtectRelay(this, accessory, relay), projection: (client, id) => client.relay(id),
selectors: deviceSelectors.relay },
sensor: { construct: (accessory, sensor) => new ProtectSensor(this, accessory, sensor), projection: (client, id) => client.sensor(id),
selectors: deviceSelectors.sensor },
viewer: { construct: (accessory, viewer) => new ProtectViewer(this, accessory, viewer), projection: (client, id) => client.viewer(id),
selectors: deviceSelectors.viewer }
};
constructor(platform, nvrOptions) {
this.api = platform.api;
this.config = nvrOptions;
this.configuredDevices = new Map();
this.connectionSubscriptions = [];
this.controllerStableSince = null;
this.deviceRemovalTimers = new Map();
this.featureLog = {};
this.hap = this.api.hap;
this.hasStabilizedOnce = false;
this.lastRebootObservedAt = null;
this.liveviews = null;
this.mqtt = null;
this.name = nvrOptions.name ?? nvrOptions.address;
this.nvrRebootTimer = null;
this.rebootConfirmTimer = null;
this._phase = "connecting";
this.platform = platform;
this.#shutdownController = new AbortController();
this.stabilityReachedTimer = null;
this.systemInfo = null;
this.unsupportedDevices = {};
// The unifi-protect client logger: the plugin logging root, with error output gated by logApiErrors so induced-disruption noise stays quiet.
this.clientLog = {
...this.platform.pluginLog,
error: (message, ...parameters) => {
if (this.logApiErrors) {
this.platform.pluginLog.error(message, ...parameters);
}
}
};
// Derive our controller logger from the plugin logging root, prefixing every line with this.name so controller output is attributable at a glance.
this.log = prefixedLog(this.platform.pluginLog, () => this.name);
// Initialize the NVR-health observer. This is the single source of truth across the plugin for the NVR's current operating condition. Every subsystem that
// wants to make a stress-aware decision reads `this.health.state`; every subsystem that observes a symptom calls `this.health.observe(...)`. Its connection
// inputs are wired below (request outcomes) and in connect() (throttle rails), so they flow from the unifi-protect client's observability surface without each
// call site needing its own hook.
this.health = new NvrHealth();
// Apply initial-phase side effects. Phase is `connecting` until the first successful connect(); during connecting, health observation is suspended (we have
// not established a baseline against which to weigh stress, and any errors during initial credential validation are real but should not feed into stress
// metrics). Subsequent phase changes go through `transition()` which keeps health.suspend / health.resume aligned with phase.
this.health.suspend();
// Surface health transitions at the NVR level. Per-component logs (per-camera stalls, per-request errors) are demoted to debug under non-healthy state...the
// user already has the explanatory NVR-level signal and does not need an N-camera fan-out of correlated noise.
//
// Two direction-aware rules keep the narrative coherent for an operator scanning warn-level logs:
//
// 1. Recovery is logged at warn (not info) so the entry warn and the closing warn pair visibly. An operator grepping warn for "is anything currently
// broken?" sees both the alert and its resolution at the same level.
// 2. The hysteresis step from `stressed` back to `degraded` is silent. That transition means recovery is in progress, not that things are getting worse,
// and logging "responding slowly..." on the way down would read as a fresh alert. The next transition (`degraded` -> `healthy`) is the one worth
// surfacing.
this.health.on("stateChange", (next, previous) => {
switch (next) {
case "healthy":
this.log.warn("The Protect controller is responsive again.");
break;
case "degraded":
if (previous === "stressed") {
// Hysteresis step on the way down. The closing warn fires when we hit healthy.
break;
}
this.log.warn("The Protect controller is responding slowly or with intermittent errors. Reducing reconnect attempts until conditions improve.");
break;
case "stressed":
this.log.warn("The Protect controller is under sustained load. Pausing background operations until conditions improve. Active recordings and live " +
"streams continue.");
break;
default:
exhaustiveGuard(next);
}
// Re-evaluate the removal stability clock - health is one of the facts `good` depends on. A drop out of healthy resets the clock and cancels every pending
// removal; a return to healthy (with phase running and the connection healthy) re-stamps it and re-arms the sweep.
this.refreshRemovalStability();
});
// Initialize our UniFi Protect event handler.
this.events = new ProtectEventDispatch(this);
// Validate our Protect address and login information.
if (!nvrOptions.address || !nvrOptions.username || !nvrOptions.password) {
return;
}
// Wire the NVR-health request-outcome inputs from the unifi-protect library's process-global HTTP diagnostics channel. The channel carries every request from every
// client in the process, so we filter on an exact host match - the payload's reported host equals this controller's configured address, both descending from the same
// address this NVR passes to ProtectClient.connect() - to keep each NVR's health scoped to its own controller. A 2xx is recovery evidence; everything else (an error,
// or a non-2xx status) is a stress symptom. Wired here - past the address guard, so a misconfigured controller never subscribes - and detached on the terminal
// shutdown signal (which the SHUTDOWN handler below guarantees fires). Observation is gated by the health observer's suspend/resume, so symptoms during connecting
// or induced disruptions are dropped.
const onRequestEnd = (message) => {
const payload = message;
if (!isRequestForController({ address: this.config.address, host: payload.host })) {
return;
}
this.health.observe({ at: Date.now(), kind: isSuccessfulRequest(payload) ? "apiSuccess" : "apiError" });
};
protectChannels.httpRequestEnd.subscribe(onRequestEnd);
this.signal.addEventListener("abort", () => protectChannels.httpRequestEnd.unsubscribe(onRequestEnd), { once: true });
// Wire the livestream stall/recovery health and log feeds. Same once-per-NVR setup site as the API-health feed above: past the address guard and detached on the
// terminal shutdown signal. The unifi-protect library's livestream-recovery channels are process-global, so this subscribes exactly once and never re-wires per
// reconnect.
this.wireLivestreamHealth();
// Cleanly shut down on Homebridge exit.
this.api.on(APIEvent.SHUTDOWN, () => {
// Clear the scheduled reboot timer if it's running.
if (this.nvrRebootTimer) {
clearTimeout(this.nvrRebootTimer);
this.nvrRebootTimer = null;
}
// Clear the post-reboot no-op confirmation timer if it's running. Like the reboot timer above, it is an induced-lifecycle timer that must not survive teardown.
if (this.rebootConfirmTimer) {
clearTimeout(this.rebootConfirmTimer);
this.rebootConfirmTimer = null;
}
// Tear down the device-removal timers unconditionally. The stability-sweep one-shot and every pending delayed removal are deferred destructive actions that must
// not fire against a disposed client; clearing them here - alongside the reboot timers - makes teardown the removal SSOT rather than leaving it contingent on the
// transition("shuttingDown") below also running refreshRemovalStability. Both clears are idempotent, so the belt-and-suspenders overlap is harmless.
if (this.stabilityReachedTimer) {
clearTimeout(this.stabilityReachedTimer);
this.stabilityReachedTimer = null;
}
this.cancelAllDeviceRemovals();
// Mark the lifecycle phase before tearing down. This is the single chokepoint that aborts the terminal shutdown signal, so every observe loop unwinds as one
// cascade. Components that surface "unexpected teardown" warnings (e.g., the recording delegate) consult `nvr.phase` to suppress noise during induced
// disruptions; without this transition the disconnect below would fan out as if cameras were failing unexpectedly.
this.transition("shuttingDown");
// Disconnect from the controller. This tears down active HomeKit streams, HKSV timeshift buffers, and the unifi-protect client connection.
void this.disconnect();
});
}
/**
* Wire the NVR-health and user-facing log feeds for livestream disruptions, sourced from the unifi-protect library's process-global livestream-recovery diagnostics
* channels. The library owns the recovery protocol and publishes each episode's lifecycle on these channels; the plugin translates them into its own health model and
* its own per-camera logs, scoped to this controller's cameras.
*
* We subscribe the two episode-boundary channels - `recovery:started` (a stream was disrupted; the episode begins) and `recovery:recovered` (it resumed). These are
* the stall/recovery health feed: one stress symptom per disruption episode, one recovery symptom per recovery, so a recovered episode
* nets zero and a failed one (started, never recovered) stays +1 - correct stress accounting with no double count. The other livestream channels are deliberately not
* consumed here: `stall:detected` is a subset of `recovery:started` (every detected stall begins an episode) and consuming both would double-count;
* `recovery:exhausted` is the consumer-side self-heal's concern; `session:closed`/`codec:changed` are neither health nor log signals.
*
* The channels are process-global - shared by every `ProtectNvr` in this process - so each handler filters to this controller's cameras via `getDeviceById(cameraId)`
* and drops the rest (another controller's camera, or one we do not configure). A package-camera stream carries its PARENT camera's device id, so the lookup resolves
* the parent `ProtectCamera` and the log names the parent - correct, since there is no separate package-camera device to name.
*/
wireLivestreamHealth() {
// A livestream was disrupted and an episode begins. Feed the +1 stress symptom (keyed on the episode start, so it also catches socket-close disruptions the narrow
// stall channel misses) and surface it to the user at the level the classification below decides, so a stream in genuine trouble is visible while it is happening.
const onRecoveryStarted = (message) => {
const payload = message;
const camera = this.getDeviceById(payload.cameraId);
if (!camera) {
return;
}
// Classify whether to log this episode quietly, and record that so the recovery edge - which cannot read the phase reliably, the controller having returned by then
// - consults the same classification. Quiet is the SUPERSET of the cases the phase alone cannot cover: an interruption observed while still rebooting/shutting down
// (induced), AND the post-return re-establishment blip of a controller reboot, whose recovery:started fires at episode entry seconds after the controller returned,
// by which point the plugin has already concluded the reboot and resumed running - so isInducedDisruption(this.phase) reads false here and the recency half
// catches it. This sits AFTER the ownership guard above: the unifi-protect library's recovery channels are process-global, so recording before the guard would
// latch every OTHER controller's cameras too, and our own forgetCamera (scoped to our removals) could never reclaim a foreign started-never-recovered entry.
const quiet = isInducedDisruption(this.phase) ||
isWithinRebootRecency({ lastRebootMs: this.lastRebootObservedAt, nowMs: Date.now(), windowMs: PROTECT_NVR_REBOOT_RECENCY_MS });
this.livestreamEpisodes.record(payload.key, payload.cameraId, quiet);
this.health.observe({ at: Date.now(), cameraId: payload.cameraId, kind: "livestreamStall" });
// A reboot blips every camera and is already narrated once at the controller level, so the per-camera flurry drops to debug whether the reboot was induced or
// organic; a genuine single-camera drop on a controller that has not recently rebooted stays at warn - that one is in trouble and the noise is the signal.
if (quiet) {
camera.log.debug("The livestream was interrupted and is recovering.");
}
else {
camera.log.warn("The livestream was interrupted and is recovering.");
}
};
// The disrupted livestream recovered. Feed the -1 recovery symptom and report the resolution and how long media was absent. `Math.max(1, ...)` avoids a nonsensical
// "0 s" on a sub-second recovery.
const onRecoveryRecovered = (message) => {
const payload = message;
const camera = this.getDeviceById(payload.cameraId);
if (!camera) {
return;
}
// The current phase is not a reliable classification proxy here (the controller has returned), so the quiet/loud value latched at the interruption edge - not
// this.phase - decides the level. Consume drains the entry; a never-recovered episode is reclaimed by forgetCamera on the camera's removal.
const quiet = this.livestreamEpisodes.consume(payload.key);
const recoveredAfter = formatSeconds(Math.max(1, Math.round(payload.downtimeMs / 1000)));
this.health.observe({ at: Date.now(), cameraId: payload.cameraId, kind: "livestreamRecovery" });
// Mirror the interruption edge: the per-camera recovery of a reboot's tail (induced or recently observed) is debug, since the library narrates the controller-level
// recovery; a genuine single-camera drop stays warn.
if (quiet) {
camera.log.debug("The livestream has recovered after %s.", recoveredAfter);
}
else {
camera.log.warn("The livestream has recovered after %s.", recoveredAfter);
}
};
protectChannels.livestreamRecoveryStarted.subscribe(onRecoveryStarted);
protectChannels.livestreamRecoveryRecovered.subscribe(onRecoveryRecovered);
// Detach both on the terminal shutdown signal, mirroring the API-health feed. The unifi-protect library's channels are global and outlive any single client, so an
// explicit unsubscribe on shutdown is what keeps these subscriptions leak-free.
this.signal.addEventListener("abort", () => {
protectChannels.livestreamRecoveryStarted.unsubscribe(onRecoveryStarted);
protectChannels.livestreamRecoveryRecovered.unsubscribe(onRecoveryRecovered);
}, { once: true });
}
/**
* Current lifecycle phase. Components that need to distinguish induced disruption (rebooting, shutting down) from organic operation (running) consult this
* property. Pure read - mutation goes through {@link transition} only.
*/
get phase() {
return this._phase;
}
/**
* The terminal plugin-shutdown abort signal. Aborted exactly once, in `transition("shuttingDown")`. Every NVR-level observe loop and every per-accessory abort
* controller composes against it, so plugin shutdown tears the whole tree down as one cascade. Pure read - the controller is private and aborted only through the
* transition chokepoint.
*/
get signal() {
return this.#shutdownController.signal;
}
// Whether the terminal shutdown signal has fired - the plugin is tearing down and no deferred wake may act against a disposed client. The single predicate every
// post-await bail consults (a late-resolving connect, a scheduled-reboot timer firing, the disabled-controller settle sleep), read through a method so each caller
// re-reads the live signal rather than a stale snapshot an earlier bail on the same path could have pinned.
#isShuttingDown() {
return this.signal.aborted;
}
/**
* Read-through NVR configuration. Replaces the held bootstrap snapshot with the live unifi-protect projection, so every `nvr.ufp.<field>` read across the plugin
* reflects the current reduced state with no merge and no reassignment. A read before the first successful connect() throws (the getter dereferences
* `this.client`, which is unset until connect() assigns it) - this is deliberate: a too-early read should fail loudly, not silently return a stale snapshot, which
* is the held-state footgun this read-through design avoids. No code path reaches that throw: the constructor and the only other pre-connect path (`login()`'s
* global enable gate) both avoid `ufp` - the gate consults feature options by global scope, not the controller mac, and `ProtectEventDispatch` construction is
* structural-only and does not read `hasFeature`/`ufp`.
*/
get ufp() {
return this.client.nvr.config;
}
/**
* Whether API error logging is currently surfaced to the user. Derived from phase: errors are visible during `connecting` (so credential or address problems
* reach the user) and `running` (organic errors are real signal), but suppressed during `rebooting` and `shuttingDown` where the errors are induced by our own
* teardown. Read by the unifi-protect client logger callback in this NVR's constructor.
*/
get logApiErrors() {
return (this._phase === "running") || (this._phase === "connecting");
}
// Move the NVR to a new lifecycle phase. The single chokepoint that keeps every derived effect aligned: it updates `_phase`, aborts the terminal shutdown signal on
// entry to `shuttingDown`, then drives `health.suspend()` / `health.resume()` so the symptom observer matches whether we're in an induced disruption or organic
// operation. The `logApiErrors` getter is pure-derived from `_phase` and needs no explicit update here.
//
// A no-op on same-phase transitions, and a no-op on any attempt to leave `shuttingDown` - that phase is terminal, so a stale reboot timer, a late-resolving
// connect, or any other deferred wake that fires after teardown cannot resurrect the lifecycle. `canTransition` owns both rules. `shuttingDown` is observable
// through `nvr.signal`'s abort event; every other phase change is observed by polling `phase` directly.
transition(next) {
if (!canTransition({ from: this._phase, to: next })) {
return;
}
this._phase = next;
// Entering the terminal phase fires the lifecycle telemetry and aborts the shutdown signal - the one place the whole observe/firehose tree is torn down. We
// publish before aborting so the lifecycle event is not lost to the abort cascade that detaches our diagnostics subscriptions.
if (next === "shuttingDown") {
this.publishLifecycle("shuttingDown");
this.#shutdownController.abort();
}
// Health observation is active only in `running`. Every other phase is either initial setup, an induced disruption, or termination - none are organic
// baselines against which stress can be meaningfully measured.
if (this._phase === "running") {
this.health.resume();
}
else {
this.health.suspend();
}
// Re-evaluate the removal stability clock now that phase changed - one of the facts `good` depends on. Unconditional and last so the shuttingDown path always
// runs the cancel-all (good becomes false, every pending removal is cleared) before disconnect() disposes the client. The health.resume() above ran first, so on the
// connect() path into running, health is already healthy when this stamps the startup good-state.
this.refreshRemovalStability();
}
// Publish an NVR-level lifecycle milestone on the forward-only diagnostics channel. Zero-cost when no subscriber is attached (the Node-native sync check).
publishLifecycle(event) {
if (channels.nvrLifecycle.hasSubscribers) {
channels.nvrLifecycle.publish({ event });
}
}
// Establish a connection to the Protect controller. The unifi-protect library's ProtectClient.connect() is atomic - it logs in, fetches the initial bootstrap, seeds
// the reducer, and brings up the realtime events channel as one ready-or-throws operation, owning retry/backoff and the periodic refresh failsafe internally. We wrap
// it in a startup-resilient retry: authentication faults get a small consecutive budget so a controller still sorting out its own auth recovers, but genuinely-wrong
// credentials fail fast rather than looping forever; any non-auth fault resets the budget and retries unbounded until the controller appears or the shutdown signal
// aborts. Safe to call multiple times - each call establishes a fresh client.
async connect() {
const { shouldRetry } = createConnectRetryPolicy();
try {
this.client = await retry((signal) => ProtectClient.connect({ host: this.config.address, log: this.clientLog, password: this.config.password,
recoveryPolicy: (context) => livestreamRecoveryDecision(context, { healthState: this.health.state, isHealthy: this.client.connection.isHealthy, isThrottled: this.client.connection.isThrottled, phase: this.phase }, this.episodeCameraReachable(context.cameraId)), signal,
username: this.config.username }), { attempts: Infinity, shouldRetry, signal: this.signal });
}
catch (error) {
// The shutdown signal aborting the retry is an orderly teardown, not a failure to report. A genuine auth budget exhaustion (wrong credentials) surfaces here.
if (this.#isShuttingDown()) {
return false;
}
this.log.error("Unable to connect to the Protect controller: %s.", formatErrorMessage(error));
return false;
}
// A connect attempt that resolves after shutdown must not resurrect the controller's lifecycle. The SHUTDOWN handler's disconnect() captured and disposed the
// PREVIOUS client before this reassignment, so the freshly-established client is otherwise orphaned - dispose it and bail before the version gate,
// wireConnectionHealth, health.reset, or the transition into running.
if (this.#isShuttingDown()) {
await this.client[Symbol.asyncDispose]();
return false;
}
const version = this.client.nvr.config.version;
// If we are running an unsupported version of UniFi Protect, we're done. The version gate stays plugin-side, reading the live projection post-connect.
if (!["6.", "7."].some(v => version.startsWith(v))) {
this.log.error("This version of HBUP requires running UniFi Protect v6.0 or above using the official Protect release channel only.");
await this.client[Symbol.asyncDispose]();
return false;
}
// Assign our log-prefix name, decorated with the controller model - "Name [Model]" via describeDevice - so every controller-scoped line shows the hardware
// in its prefix. The name resolution follows the established precedence (a user preference wins, then the controller's reported name, then its address);
// describeDevice only appends the bracketed model, and is reached here post-bootstrap where this.ufp is populated. Early, pre-bootstrap logs keep the bare name set
// in the constructor.
this.name = describeDevice(this.ufp, { name: this.config.name ?? this.client.controllerName ?? this.config.address });
// Wire the per-client connection-health inputs (throttle rails, controller-lost/recovered lifecycle) against the freshly-established client.
this.wireConnectionHealth();
// Reset NVR-health state on every successful connect. After a disconnect/reconnect cycle (planned or otherwise), pre-disruption symptoms are no longer
// relevant...the controller we're now talking to is the canonical truth, and starting clean prevents stale state from biasing post-reconnect decisions. The
// first connect at startup is healthy by construction, so this is a no-op there; the reset earns its keep on every reconnect after.
this.health.reset();
// Transition into the `running` phase. This re-enables organic health observation (suspend was applied during `connecting`), keeps `logApiErrors` true, and
// tells every consumer of `nvr.phase` that the plugin is back to normal steady-state operation.
this.transition("running");
this.publishLifecycle("connected");
// We successfully connected.
this.log.info("Connected to %s (UniFi Protect %s running on UniFi OS %s).", this.config.address, version, this.ufp.firmwareVersion);
return true;
}
// Wire the per-client NVR-health connection inputs. The throttle rails feed the library-throttle symptoms; the controller-lost/recovered rails drive lifecycle
// telemetry. We dispose any prior subscriptions first so a reconnect (which builds a fresh client) never leaves a listener bound to a disposed connection monitor.
// Bound to the client's lifetime, not the shutdown signal, because the client - and therefore its connection monitor - is itself replaced on a reconnect.
wireConnectionHealth() {
for (const subscription of this.connectionSubscriptions) {
subscription[Symbol.dispose]();
}
this.connectionSubscriptions = [
this.client.connection.on("throttleEntered", () => this.health.observe({ at: Date.now(), kind: "libraryThrottleEntered" })),
this.client.connection.on("throttleExited", () => this.health.observe({ at: Date.now(), kind: "libraryThrottleReleased" })),
this.client.connection.on("controllerLost", () => this.publishLifecycle("controllerLost")),
this.client.connection.on("controllerRebooted", () => this.onControllerRebooted()),
this.client.connection.on("controllerRecovered", () => this.onControllerRecovered())
];
}
// React to a controller reboot detection, whether we induced it or it happened organically. This handler records the observation (our own-clock recency anchor),
// publishes the lifecycle milestone (the unifi-protect library already logs the detection at warn, so we do not duplicate it), and resets each camera's probesize
// self-tuning; the induced-reboot resume is driven separately by the connection's recovery edge in startConnectionObserver, so it is not this handler's concern.
onControllerRebooted() {
// Record our own-clock observation of this reboot. This is the SSOT moment the plugin learns a reboot happened (induced or organic), so it anchors the recency window
// the per-camera livestream-disruption logs consult to quiet a re-establishment blip that lands after the plugin has already concluded the reboot and
// resumed running.
this.lastRebootObservedAt = Date.now();
this.publishLifecycle("controllerRebooted");
// Every reboot re-adopts and restarts each camera's stream from scratch, so clear each camera's accumulated probesize self-tuning - a per-camera latch that at its
// permanent ceiling arms no auto-reset and otherwise persists for the life of the delegate. We accept one baseline re-tune per reboot on a chronically-flaky camera
// rather than a forever-elevated probesize. The endpoints iterator walks package cameras alongside their parents, so the package's delegate resets here too.
for (const device of this.deviceEndpoints()) {
if (!(device instanceof ProtectCamera)) {
continue;
}
device.stream?.resetProbesizeOverride();
}
}
// The connection returned to healthy after a loss. This handler's sole duty is publishing the controllerRecovered lifecycle milestone; the induced-reboot resume is
// driven by the connection's recovery edge in startConnectionObserver (the same non-healthy -> healthy edge this recovery represents).
onControllerRecovered() {
this.publishLifecycle("controllerRecovered");
}
// Return from our own induced reboot to steady-state operation, called by startConnectionObserver on the connection's recovery edge (a non-healthy -> healthy
// transition while rebooting). The `_phase === "rebooting"` guard is a defensive method-boundary precondition: the recovery-edge predicate is the SSOT decision and
// this guard asserts the contract, so an organic recovery while `running` is a no-op here. The un-strand property is inherent to the recovery edge - a real reboot
// always drops then recovers the connection, so the edge always fires, depending on nothing but the connection's own health journey and no separate detection event.
// We clear the no-op confirmation timer (a real recovery edge proves the reboot took effect) and reset the pre-reboot health history: the controller is freshly
// booted, so any stress or library-throttle state latched before the reboot is no longer relevant. The suspend held across the whole rebooting phase kept induced
// symptoms out of the buffer, but the latched state enum and the throttle flag clear only on a reset or a fresh clearing observation, so we reset explicitly here -
// restoring the clean baseline the connect()-driven reset gives us. The library already narrates the recovery itself, so this method does not log a duplicate
// "back online" line. (The no-op path produces no recovery edge and resumes via the rebootConfirmTimer instead, which deliberately does NOT reset: a controller
// that never actually rebooted keeps its still-relevant health history.)
resumeFromInducedReboot() {
if (this._phase !== "rebooting") {
return;
}
if (this.rebootConfirmTimer) {
clearTimeout(this.rebootConfirmTimer);
this.rebootConfirmTimer = null;
}
this.health.reset();
this.transition("running");
}
// Cleanly disconnect from the Protect controller. This tears down all connection-dependent resources (active HomeKit streams, HKSV timeshift buffers, the
// connection-health subscriptions, and the unifi-protect client itself) while preserving one-time infrastructure (playlist servers, MQTT, event listeners).
async disconnect() {
// Tear down all connection-dependent camera resources. Active HomeKit streaming sessions and HKSV timeshift buffers both depend on the controller connection.
// Shutting them down proactively prevents error noise from livestream self-healing and FFmpeg processes communicating with a disconnected controller. The camera
// does not own its own session manager, so disposing these consumers is what releases their underlying unifi-protect livestream pool subscriptions. This is a HARD
// ORDERING INVARIANT - the connection-dependent consumers (and the pool subscriptions they hold) are torn down in this loop BEFORE the unifi-protect client is
// disposed below, so no subscription outlives the client it draws from. The endpoints iterator walks package cameras alongside their parents.
for (const device of this.deviceEndpoints()) {
if (!(device instanceof ProtectCamera)) {
continue;
}
device.stream?.shutdown();
device.stream?.timeshift?.shutdown();
}
// Detach the connection-health subscriptions so they do not outlive the client whose connection monitor they observe.
for (const subscription of this.connectionSubscriptions) {
subscription[Symbol.dispose]();
}
this.connectionSubscriptions = [];
// Dispose the unifi-protect client - tearing down the connection monitor, livestream pool, state store, session, and transport pool, in that order. The client is
// definite-assignment typed for the connected lifetime, but a shutdown during the initial connect can reach here before connect() assigned it; the cast guards
// that window without widening the field's type for every connected-path read.
const client = this.client;
if (client) {
await client[Symbol.asyncDispose]();
}
}
// Initialize our connection to the UniFi Protect controller. This is the one-time entry point called at startup that establishes the connection, creates all
// infrastructure, performs the initial device population, and spawns the NVR-level observe loops that keep us in sync with the controller.
async login() {
// The plugin has been disabled globally. The controller mac is unknown until we connect, so this pre-connect gate consults global feature-option scope directly
// rather than `hasFeature` (which would read the not-yet-known controller mac); the per-controller gate runs post-connect below.
if (!this.platform.featureOptions.test("Device")) {
this.log.info("Disabling this UniFi Protect controller.");
return;
}
// Establish our connection to the Protect controller.
if (!(await this.connect())) {
return;
}
// Now that we know the NVR configuration, check to see if this Protect controller is disabled.
if (!this.hasFeature("Device")) {
this.log.info("Disabling this UniFi Protect controller in HomeKit.");
// Let's sleep for thirty seconds to give all the accessories a chance to load before disabling everything. Homebridge doesn't have a good mechanism to notify us
// when all the cached accessories are loaded at startup.
await sleep(PROTECT_NVR_CONTROLLER_DISABLED_SETTLE_DELAY);
// A teardown that landed during the settle sleep must not run a removal sweep against a disposed platform - bail before touching any accessory.
if (this.#isShuttingDown()) {
return;
}
// Unregister all the accessories for this controller from Homebridge that may have been restored already. Any additional ones will be automatically caught when
// they are restored.
for (const accessory of this.platform.accessories.filter(x => x.context.nvr === this.ufp.mac)) {
this.removeHomeKitDevice(accessory);
}
// removeHomeKitDevice's ownership guards early-return the system-information, liveview, and security-system accessories, deferring their removal to
// ProtectNvrSystemInfo and ProtectLiveviews. On the disabled path those owners are never constructed, so nothing else will ever reclaim those accessories - this
// sweep is their removal path. The filter yields an independent snapshot to iterate, so the shared tail is free to splice the live platform list as it removes
// each one. The narration is deliberately short: these accessories are always bridged and carry no model key, so removeHomeKitDevice's fuller "Removing <model>
// from HomeKit" line would render malformed here.
for (const accessory of this.platform.accessories.filter(x => x.context.nvr === this.ufp.mac)) {
this.log.info("%s: Removing from HomeKit.", accessory.displayName);
this.removeAccessoryFromHomeKit(accessory);
}
return;
}
// Configure any NVR-specific settings.
this.configureNvr();
// Initialize MQTT before constructing the accessory owners. Their constructor-time MQTT subscriptions (system information, and liveviews on its initial reconcile)
// bind through nvr.mqtt, so the client must exist first or those subscriptions would silently no-op against a not-yet-created client and never be retried. The client
// binds to the NVR's terminal shutdown signal, so it is an AsyncDisposable whose connection ends on plugin shutdown rather than leaking past it.
if (!this.mqtt && this.config.mqttUrl) {
this.mqtt = new MqttClient({ brokerUrl: this.config.mqttUrl, log: this.log, topicPrefix: this.config.mqttTopic }, { signal: this.signal });
}
// Initialize our liveviews.
this.liveviews = new ProtectLiveviews(this);
// Initialize our NVR system information.
this.systemInfo = new ProtectNvrSystemInfo(this);
// Initialize our playlist service, if enabled.
if (this.hasFeature("Nvr.Service.Playlist")) {
servePlaylist(this);
}
// Inform the user about the devices we see, reading the live unifi-protect projections.
this.log.info("Discovered controller: %s.", describeDevice(this.ufp, { includeNetwork: true, name: this.client.controllerName }));
for (const config of this.deviceConfigs) {
// Filter out any devices that aren't adopted by this Protect controller.
if (!config.isAdopted || config.isAdoptedByOther) {
continue;
}
this.log.info("Discovered %s: %s.", config.modelKey, describeDevice(config, { includeNetwork: true }));
}
// Perform the initial device population and spawn the observe loops that keep us in sync. Membership is an observe over the content-memoized adopted-id selectors,
// controller health an observe over the connection monitor, and the controller-scoped accessories (system information, liveviews) each observe their own slice of the
// unifi-protect projection from their own constructors - the subject owns its reactivity, the same model the per-device leaves use. Orphan cleanup does not run
// here: the stability sweep owns it, so a cached accessory is removed only once the controller has been good for the stability window (immediately at startup for a
// controller already up past it, via the uptime backdate).
this.startDeviceObservers();
// Seed the initial liveview population now that the device accessories exist. A liveview switch restores its saved motion-detection state onto its member cameras, so
// this first reconcile must run after startDeviceObservers; every subsequent liveview-collection change is handled by the observer ProtectLiveviews spawns itself.
this.liveviews.configureLiveviews();
this.startConnectionObserver();
// Spawn the typed event-firehose router and the controller telemetry publisher. The router is the one controller-level consumer of the classified activity firehose,
// dispatching each smart-detect / doorbell-ring / access / tamper / auth occurrence to the addressed accessory's HomeKit delivery; the telemetry publisher mirrors
// every raw frame to MQTT and is a no-op unless the user opted in. Both are bound to the terminal shutdown signal and unwind with the rest of the observe tree.
this.spawnLoop("live events", () => this.events.run(this.signal));
this.spawnLoop("controller telemetry", () => this.events.publishTelemetry(this.signal));
}
// Configure NVR-specific settings.
configureNvr() {
// Configure scheduled reboots if enabled.
this.configureScheduledReboot();
return true;
}
// Configure scheduled reboots of the Protect controller.
configureScheduledReboot() {
// Retrieve the reboot interval. A null return means the option is explicitly disabled.
const rebootInterval = this.getFeatureFloat("Nvr.Reboot");
if (rebootInterval === null) {
return;
}
// Apply the reboot interval, defaulting to the configured default if the option is enabled without an explicit value. We enforce a minimum interval to prevent the
// controller from entering a reboot loop.
const intervalHours = Math.max(rebootInterval ?? PROTECT_NVR_REBOOT_INTERVAL, PROTECT_NVR_REBOOT_MIN_INTERVAL);
const intervalMs = intervalHours * 60 * 60 * 1000;
// Anchor the schedule to the controller's actual uptime so plugin restarts don't reset the reboot cadence. If the controller has been up longer than the
// interval, we schedule a reboot shortly after startup to let everything settle first.
const uptimeMs = this.ufp.upSince ? (Date.now() - this.ufp.upSince) : 0;