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.
146 lines • 9.05 kB
TypeScript
import { EventEmitter } from "node:events";
/**
* Stable categorical contract for the controller's current operating condition. Internal reducer transitions can change without breaking consumers because the
* external surface stays a small ordered set: `healthy` < `degraded` < `stressed`.
*
* - `healthy`: nominal. The controller is responsive; no recent symptom rate concerns.
* - `degraded`: elevated symptom rate. Reconnect-aggressive behavior should soften (e.g., defer non-urgent reconnects); critical paths still proceed.
* - `stressed`: sustained or library-acknowledged stress. Non-critical work should back off; only urgent paths (in-flight HKSV recordings, active live streams)
* should drive new requests at the controller.
*/
export type NvrHealthState = "degraded" | "healthy" | "stressed";
/**
* Symptoms feed the reducer. Each variant carries the timestamp when the symptom occurred (stamped by the caller, so the reducer remains pure with respect to
* wall time), and a discriminating `kind`. A `cameraId` is included on livestream variants so future policies can distinguish per-camera vs. correlated stress
* without changing the symptom shape.
*
* - `apiError`: an API request returned a failure (timeout, 4xx/5xx, network error, etc.).
* - `apiSuccess`: an API request returned a successful response. Counted as evidence of recovery; reduces the apparent symptom rate.
* - `livestreamStall`: a camera's livestream session detected a stall (the stall timer fired during streaming).
* - `livestreamRecovery`: a camera's livestream session resumed segment delivery after a stall.
* - `libraryThrottleEntered`: the unifi-protect library entered its internal throttle. A strong stress signal: the library has unilaterally paused
* communication with the controller for the duration of its cooldown.
* - `libraryThrottleReleased`: the unifi-protect library released its internal throttle. Evidence of recovery, but does not by itself reset the state...we wait
* for organic improvement (success events accumulating in the window) rather than auto-resetting on release.
*/
export type HealthSymptom = {
at: number;
kind: "apiError";
} | {
at: number;
kind: "apiSuccess";
} | {
at: number;
kind: "libraryThrottleEntered";
} | {
at: number;
kind: "libraryThrottleReleased";
} | {
at: number;
cameraId: string;
kind: "livestreamRecovery";
} | {
at: number;
cameraId: string;
kind: "livestreamStall";
};
/**
* The reducer's persistent state. Exposed (via `NvrHealth.snapshot()`) for diagnostics and tests; not directly mutable.
*
* - `state`: the current derived state, the externally-meaningful output.
* - `recentSymptoms`: the sliding-window buffer of symptoms within HEALTH_WINDOW_MS of the most recent observation. Old symptoms are evicted on each reduce.
* - `libraryThrottled`: latched flag for the library's throttle. While true, the state is forced to `stressed` regardless of symptom counts; releases on
* `libraryThrottleReleased`. Modeled as latched state rather than derived from buffer contents because the library throttle is a longer-lived condition than
* the symptom window.
*/
export interface HealthState {
readonly libraryThrottled: boolean;
readonly recentSymptoms: readonly HealthSymptom[];
readonly state: NvrHealthState;
}
/**
* Initial state. Exported so callers can construct a fresh state for tests or for replay scenarios without depending on the class.
*/
export declare function createInitialHealthState(): HealthState;
/**
* Pure reducer. Given the previous state, a symptom, and the current wall time, returns the new state. Total: every symptom variant is handled, every transition
* is explicit, and the function does not read external state. Callers that need to drive eviction without a new symptom can pass a synthetic `apiSuccess` (the
* reducer evicts old entries on every call regardless of symptom kind) - in practice this is unnecessary because the consumer-facing `state` getter is correct
* the moment the reducer has been called with any symptom.
*
* Stress evaluation prioritises the latched library-throttle flag: as long as the library has paused communication, we are stressed regardless of what the
* symptom window says. Otherwise we count weighted symptoms and apply hysteresis against the previous state.
*/
export declare function reduceHealth(prev: HealthState, symptom: HealthSymptom, now: number): HealthState;
/**
* Injectable clock for testability. Production uses {@link systemClock}; tests can substitute a deterministic clock to drive eviction without real time.
*/
export interface Clock {
now(): number;
}
export declare const systemClock: Clock;
/**
* Event surface for {@link NvrHealth}. Listeners receive the new state on every transition; the previous state is provided for context (so listeners can
* render directional log lines like "healthy -> degraded" without tracking it themselves).
*/
export interface NvrHealthEvents {
stateChange: [next: NvrHealthState, previous: NvrHealthState];
}
/**
* Thin event-sink wrapper around the reducer. Consumers call `observe()` with a symptom; the class drives the reducer with the injected clock, replaces its
* internal state, and emits `stateChange` on transitions. The class exists so consumers do not have to thread the previous state through reduce calls themselves;
* the reducer remains pure and independently testable.
*
* Lifecycle integration notes for future maintainers:
*
* - `suspend()` / `resume()` are normally driven from `ProtectNvr.transition()` rather than called directly. The NVR's phase is the single source of truth for
* "induced vs organic disruption"; this class follows along. Calling `suspend()` directly is fine for tests but in production code should go through phase
* transitions so all derived effects (logApiErrors, future signals) stay aligned.
* - `reset()` clears the symptom buffer and forces the state back to `healthy` without emitting `stateChange`. Called after a successful reconnect, whether
* following a disconnect (`ProtectNvr.connect()`) or an induced reboot (`ProtectNvr.resumeFromInducedReboot()`); pre-disruption symptoms are no longer
* relevant. We deliberately do not emit on reset because that would surface a "responsive again" message at every reset boundary, which is
* misleading...the reset is internal bookkeeping, not a recovery event.
* - There is no "force healthy" method. Use `reset()` if you need to clear state. A force-healthy that emits a recovery event would be a footgun.
* - Initial state is `healthy`; the plugin assumes normalcy until proven otherwise. The NVR suspends observation in `connecting` phase so initial-connect
* errors do not bias the baseline.
*/
export declare class NvrHealth extends EventEmitter<NvrHealthEvents> {
private current;
private suspended;
private readonly clock;
constructor(clock?: Clock);
/**
* Record a symptom. Drives the reducer with the current clock and emits a `stateChange` event if the derived state crossed a transition.
*
* The caller stamps the symptom's `at` field at the call site (typically `Date.now()`, but a different value is acceptable for replayed or queued symptoms).
* The clock injected into NvrHealth is used as the eviction-window reference time on each call; it is not a fallback for `symptom.at`.
*
* No-op while suspended: symptoms observed during operations the plugin is intentionally driving are not signal we want to surface to consumers.
*/
observe(symptom: HealthSymptom): void;
/**
* Stop observing symptoms until {@link resume} is called. Used to bracket operations the plugin is intentionally driving (controlled disconnects, scheduled
* reboots) so the induced API errors and stalls do not get surfaced as if they were organic controller stress. Idempotent.
*/
suspend(): void;
/**
* Resume observing symptoms. Idempotent. Does not change the current state...if the parent wants a clean slate after the suspend window, it should call
* {@link reset} explicitly.
*/
resume(): void;
/**
* Clear the symptom buffer and force the state back to `healthy`. Does NOT emit `stateChange`...this is bookkeeping for "forget what came before this point,"
* not a recovery event the user should see surfaced. Typical use: after a successful reconnect following a controller reboot or disconnect, where the
* pre-disruption history is no longer relevant.
*/
reset(): void;
/**
* The current state. O(1) read; the reducer maintains it on every observation.
*/
get state(): NvrHealthState;
/**
* Diagnostic snapshot of the full reducer state. Intended for debug feature flags and for tests; not part of the steady-state read path.
*/
snapshot(): HealthState;
}
//# sourceMappingURL=nvr-health.d.ts.map