UNPKG

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.

80 lines 7.54 kB
import { defaultLivestreamRecoveryPolicy } from "unifi-protect"; import { isInducedDisruption } from "../nvr/nvr-policy.js"; // Consecutive failed reconnects on a reachable controller before the camera is rebooted. Exported because the consumer side - the timeshift buffer and // live streaming delegate - reads it to decide when a caught give-up error means "reboot this camera" rather than "surface a terminal failure". export const LIVE_SELF_HEAL_THRESHOLD = 10; // Per-episode bound on the soft ease-off, in milliseconds. Once the episode has run this long, the soft ease-off escalates to a // reconnect rather than easing off forever, so sustained symptoms advance the attempts counter toward the self-heal above. const SOFT_DEFER_CEILING_MS = 8000; // Soft ease-off re-poll granularity AND the active/idle threshold, in milliseconds: a stream that tolerates one step or less is "active" (latency-sensitive) and skips // the defer; a stream that tolerates strictly more than one step is "idle" and eases off. const SOFT_DEFER_STEP_MS = 1000; // Re-poll interval, in milliseconds, while the controller is drowning, an induced disruption (our own reboot/shutdown) is in flight, or this camera is unavailable. We // wait and re-consult rather than reconnect, so the episode does not burn reconnect attempts on a connection that cannot yet succeed - an offline camera returns and we // resume, while a camera whose controller record has been removed has its subscription disposed at the removal grace's end instead. const LIVESTREAM_STRESS_WAIT_MS = 5000; /** * The pure decision core of the plugin's livestream recovery policy. Given the library-observable {@link RecoveryContext} and a snapshot of the plugin's * controller-health and lifecycle-phase reads, it returns the {@link RecoveryDecision} the library's recovery loop will honor. It is pure - no live reads, no side * effects - so it is exhaustively unit-testable with constructed inputs, and the live reads are supplied by the thin closure wired at `ProtectClient.connect`. * * Establishment is delegated wholesale to the library default (it is hardware-bound and health-independent); past establishment the ordered policy is a sequence of * steps, the first match winning: * * 1. An induced disruption (our own reboot/shutdown) waits, so we do not fight our own teardown with reconnects. * 2. A drowning controller (the hard reachability gate) waits indefinitely and never reboots a camera. * 3. This one camera is unavailable on an otherwise-healthy controller, so wait rather than burn reconnect attempts toward the self-heal give-up. * 4. A wedged camera on a reachable controller gives up after the self-heal threshold, so the consumer reboots it. * 5. An idle stream under elevated-but-reachable symptoms eases off for a bounded window. * 6. Otherwise - healthy, latency-sensitive, or soft budget spent - reconnect with the library default's self-tuning timing. * * @param context - The library-observable recovery context (attempts, cameraId, elapsedMs, phase, toleranceMs). * @param nvr - A snapshot of the plugin's controller-health and lifecycle-phase reads at the decision point. * @param cameraReachable - Whether the episode's camera is currently reachable, resolved by the consumer's closure (the unavailable-defer gate's sole input). * * @returns The recovery decision for this step. */ export function livestreamRecoveryDecision(context, nvr, cameraReachable) { // Establishment is hardware-bound and health-independent: delegate to the library's patient default (30s deadline, 5/8/10s backoff) for provisioning. if (context.phase === "establishing") { return defaultLivestreamRecoveryPolicy(context); } // 1. Induced disruption: we are rebooting or shutting down the controller ourselves, so do not fight it with reconnects. Wait for it to clear; never give up here (the // disconnect path disposes the subscription if we are truly tearing down, and a giveUp would needlessly surface a terminal error during our own reboot). if (isInducedDisruption(nvr.phase)) { return { forMs: LIVESTREAM_STRESS_WAIT_MS, kind: "wait" }; } // 2. Hard reachability gate: the CONTROLLER is drowning (its breaker is open or it is unreachable). Wait it out until it recovers, and do NOT reboot the camera - a // camera reboot cannot fix an overloaded controller. This reachability backoff returns before the self-heal check, so a throttled controller // never reboots a camera. It never gives up; recovery resumes when the controller comes back. if (nvr.isThrottled || !nvr.isHealthy) { return { forMs: LIVESTREAM_STRESS_WAIT_MS, kind: "wait" }; } // 3. This camera is unavailable. Steps 1-2 cleared the controller (induced disruption, then drowning), so isReachable's controller half is necessarily true here // - which means !cameraReachable is exactly "this one camera is not reachable", whether it is offline (rebooting, lost power, off the network) or its controller // record has been removed (unadopted, lingering in the removal grace), read through the single availability helper rather than a parallel device-online accessor. // Reconnecting its livestream is futile, and a self-heal reboot cannot help here - the controller reports an offline camera unavailable, and a removed record // has no camera to reboot - so wait rather than burn reconnect attempts toward the self-heal give-up. The attempts counter only advances on a failed reconnect, // never on a wait (the deliberate counter behavior above), so this defer costs zero attempts and the give-up is unreachable for an unavailable camera by // construction. if (!cameraReachable) { return { forMs: LIVESTREAM_STRESS_WAIT_MS, kind: "wait" }; } // 4. Self-heal: the controller is reachable but this camera will not reconnect. After LIVE_SELF_HEAL_THRESHOLD consecutive failed reconnects, give up so the consumer // (a later step) reboots the wedged camera and re-subscribes. The library increments attempts only on a failed reconnect, so this is correctly reached only on a // reachable controller that keeps failing real reconnects, never during a wait. if (context.attempts >= LIVE_SELF_HEAL_THRESHOLD) { return { kind: "giveUp" }; } // 5. Soft ease-off: the controller is reachable but showing elevated symptoms. Ease an idle stream off the controller for a bounded window before reconnecting, rather // than piling reconnects onto a stressed-but-reachable controller. A latency-sensitive consumer (an active recording) declares a tolerance below one defer step and // so skips this, reconnecting immediately. The window is bounded by elapsedMs against the ceiling, so under sustained symptoms it escalates to a reconnect // (advancing the attempts counter toward the self-heal above) rather than easing off forever. if ((nvr.healthState !== "healthy") && (context.elapsedMs < SOFT_DEFER_CEILING_MS) && (context.toleranceMs > SOFT_DEFER_STEP_MS)) { return { forMs: SOFT_DEFER_STEP_MS, kind: "wait" }; } // 6. Reconnect: the controller is healthy, or the consumer is latency-sensitive, or the soft ease-off budget is spent. Delegate the timing to the library default, // whose await window self-tunes from the consumer's reported tolerance. return defaultLivestreamRecoveryPolicy(context); } //# sourceMappingURL=livestream-recovery-policy.js.map