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.
593 lines (592 loc) • 107 kB
JavaScript
/* Copyright(C) 2019-2026, HJD (https://github.com/hjdhjd). All rights reserved.
*
* camera.ts: Camera device class for UniFi Protect.
*/
import { AudioRecordingCodecType, AudioRecordingSamplerate, capabilityGate, formatErrorMessage, toStartCase } from "homebridge-plugin-utils";
import { PROTECT_FFMPEG_AUDIO_FILTER_FFTNR, PROTECT_SEGMENT_RESOLUTION, PROTECT_TIMESHIFT_CONSTRAINED_HOST_TARGET } from "../../settings.js";
import { ProtectAuthorizationError, deviceSelectors, livestreamAudioSampleRate } from "unifi-protect";
import { buildAdvertisedProfiles, buildChannelProfile, capByPixels, formatResolution, isPrimaryChannel, rtspUrl, selectChannelProfile } from "../../media/resolution.js";
import { nightVisionActive, nightVisionBrightnessForMode, nightVisionCommandForLevel, nightVisionModeForToggleOn, nightVisionToggleCommand, parseNightVisionMode } from "./night-vision-policy.js";
import { ProtectDevice } from "../device.js";
import { ProtectReservedNames } from "../../types.js";
import { RtspLivestreamSubscription } from "../../media/livestream.js";
import { audioCapabilityAppeared } from "../../media/stream-delegate.js";
import { doorbellReconcileAction } from "./doorbell-reconcile-policy.js";
import { shouldDeliverBareMotion } from "./motion-policy.js";
export class ProtectCamera extends ProtectDevice {
ambientLight;
isDeleted;
isRinging;
// The latched tamper state, set by the firehose router's tamper delivery (a one-way latch, like isRinging is set by the doorbell delivery) and read back by the
// tamper-detection onGet and the availability projection. Public because its single writer is the NVR-level event dispatch, not this class.
isTampered;
detectLicensePlate;
// The composed doorbell capability, attached when (and only when) the controller reports this camera as a doorbell. Initialized to null so the field-init runs BEFORE
// the ctor-body configureDevice that attaches it - declared and set by ProtectCamera itself, so the subclass field-wipe class does not apply.
doorbell = null;
channelProfiles;
// Whether the doorbell ring-trigger MQTT subscription has been registered, so the one registration site (configureMqtt at construction) and the late-promotion attach
// arm never double-register (homebridge-plugin-utils subscribe is not idempotent). Initialized to false through a field initializer, set by ProtectCamera's own
// configureDoorbellRingMqtt after the gate passes - never inside a super constructor - so it carries no ctor-chain-computed state and the subclass field-wipe class
// does not apply.
#ringMqttRegistered = false;
stream;
// Create an instance.
constructor(nvr, accessory, device) {
super(nvr, accessory, device);
this.ambientLight = 0;
this.isDeleted = false;
this.isRinging = false;
this.isTampered = false;
this.detectLicensePlate = [];
this.channelProfiles = [];
this.configureHints();
this.configureDevice();
this.spawnObservers();
}
// Read-through to the camera projection's live STATE, narrowed to drop device identity (id/mac/modelKey). Identity flows through the dedicated non-throwing accessors
// (protectId/modelKey/.id/.mac), never this throwing config getter; this override mirrors the base getter's body and narrows only the surfaced return type.
get ufp() {
return this.device.config;
}
// The package camera, delegated to the doorbell capability that owns its lifecycle. Null when no doorbell capability is attached, or when an attached doorbell has no
// package camera. This is the single seam the external readers (event-dispatch's package-motion branch and the NVR's deviceEndpoints iterator) consume, so the
// package's ownership can live entirely on the capability without touching either caller.
get packageCamera() {
return this.doorbell?.packageCamera ?? null;
}
// The host the camera's RTSP(S) URLs resolve against: the user's address override, else the camera's own controller-reported connection host, else the controller's
// host. Protected so the package camera subclass resolves its own RTSP URLs through the exact same override/connection-host/controller-host chain as the parent's,
// rather than the controller's raw address.
get rtspHost() {
return this.nvr.config.overrideAddress ?? this.ufp.connectionHost ?? this.nvr.ufp.host;
}
// Configure device-specific settings for this device.
configureHints() {
// Configure our parent's hints.
super.configureHints();
this.hints.tsbStreaming = this.hasFeature("Video.Timeshift.Livestream");
this.hints.crop = this.hasFeature("Video.Crop");
this.hints.hardwareDecoding = true;
this.hints.hardwareTranscoding = this.hasFeature("Video.Transcode.Hardware");
this.hints.highResSnapshots = this.hasFeature("Video.HighResSnapshots");
this.hints.hksvRecordingIndicator = this.hasFeature("Video.HKSV.StatusLedIndicator");
this.hints.ledStatus = this.ufp.featureFlags.hasLedStatus && this.hasFeature("Device.StatusLed");
this.hints.logDoorbell = this.hasFeature("Log.Doorbell");
this.hints.logHksv = this.hasFeature("Log.HKSV");
this.hints.nightVision = this.ufp.featureFlags.hasInfrared && this.hasFeature("Device.NightVision");
// A regular camera stream is dense and continuous, so a modest probe is enough for FFmpeg to lock onto its format reliably without adding noticeable startup
// latency; the package camera's sparser stream needs a deeper probe of its own (see its own probesize override).
this.hints.probesize = 16384;
this.hints.smartDetect = this.ufp.featureFlags.hasSmartDetect && this.hasFeature("Motion.SmartDetect");
this.hints.smartDetectSensors = this.hints.smartDetect && this.hasFeature("Motion.SmartDetect.ObjectSensors");
this.hints.transcode = this.hasFeature("Video.Transcode");
this.hints.transcodeBitrate = this.getFeatureNumber("Video.Transcode.Bitrate") ?? -1;
this.hints.transcodeHighLatency = this.hasFeature("Video.Transcode.HighLatency");
this.hints.transcodeHighLatencyBitrate = this.getFeatureNumber("Video.Transcode.HighLatency.Bitrate") ?? -1;
this.hints.twoWayAudio = this.ufp.featureFlags.hasSpeaker && this.hasFeature("Audio") && this.hasFeature("Audio.TwoWay");
this.hints.twoWayAudioDirect = this.ufp.featureFlags.hasSpeaker && this.hasFeature("Audio") && this.hasFeature("Audio.TwoWay.Direct");
// Sanity check our target transcoding bitrates, if defined.
if (!this.hints.transcodeBitrate || (this.hints.transcodeBitrate <= 0)) {
this.hints.transcodeBitrate = -1;
}
if (!this.hints.transcodeHighLatencyBitrate || (this.hints.transcodeHighLatencyBitrate <= 0)) {
this.hints.transcodeHighLatencyBitrate = -1;
}
return true;
}
// Configure a camera accessory for HomeKit.
configureDevice() {
// Preserve the persisted user-state keys across the context reset: motion detection always, plus the HKSV-recording and doorbell-mute switch states when their
// features are enabled. The values here are the resting defaults a fresh accessory starts from; resetAccessoryContext keeps whatever was actually persisted and
// falls back to these only when nothing was, so a user's saved choice survives the restart.
const preserved = { detectMotion: true };
if (this.hasFeature("Video.HKSV.Recording.Switch")) {
preserved.hksvRecordingDisabled = false;
}
if (this.hasFeature("Doorbell.Mute")) {
preserved.doorbellMuted = false;
}
this.resetAccessoryContext(preserved);
// Inform the user that motion detection will suck.
if (this.recordingMode === "never") {
this.log.warn("Motion events will not be generated by the Protect controller when the controller's camera recording options are set to \"never\".");
}
// Check to see if we have smart motion events enabled on a supported camera.
if (this.hints.smartDetect) {
const smartDetectTypes = [...this.ufp.featureFlags.smartDetectAudioTypes, ...this.ufp.featureFlags.smartDetectTypes];
// Inform the user of what smart detection object types we're configured for.
this.log.info("Smart motion detection enabled%s.", smartDetectTypes.length ? ": " + smartDetectTypes.toSorted().join(", ") : "");
}
// Configure accessory information.
this.configureInfo();
// Configure MQTT services.
this.configureMqtt();
// Configure the motion sensor.
this.configureMotionSensor(this.isHksvCapable);
// Configure smart motion contact sensors.
this.configureMotionSmartSensor();
// Configure the occupancy sensor.
this.configureOccupancySensor();
// Configure cropping.
this.configureCrop();
// Configure HomeKit Secure Video suport.
this.configureHksv();
this.configureHksvRecordingSwitch();
// We use an IIFE here since we can't make the enclosing function asynchronous.
(async () => {
// Reconcile the capability-gated services and the video streaming surface in parallel since they are independent operations. The construct source establishes the
// capability resting states (it is the adoption-or-restart path), distinct from the live observe reconciles the capability observers drive later.
await Promise.all([this.reconcileCapabilities("construct"), this.reconcileStreaming()]);
// Configure our camera details.
this.configureCameraDetails();
// Configure our NVR recording switches.
this.configureNvrRecordingSwitch();
// Configure the status indicator light switch.
this.configureStatusLedSwitch();
// Configure the doorbell trigger. It runs BEFORE the mute switch because the trigger is what creates the Doorbell service on a plain camera, and the mute switch's
// gate requires that service to already exist. A camera the controller demoted (a cached Doorbell service, trigger now disabled) takes the trigger's disabled
// branch here, which removes the stale service first, so the mute switch below is correctly absent immediately rather than one session later.
this.configureDoorbellTrigger();
// Configure the doorbell mute switch.
this.configureDoorbellMuteSwitch();
})();
// Attach the doorbell capability when the controller already reports this camera as a doorbell. This runs at the END of configureDevice's synchronous body, after
// the IIFE statement: the IIFE's synchronous prefix has already kicked off reconcileStreaming, while its tail (the trigger and mute switch) runs on later
// microtasks. The capability's configure synchronously stands up the Doorbell service (through configureDoorbellService), so a real doorbell's service exists before
// that microtask tail reaches the mute-switch gate; a plain camera's Doorbell service is stood up by the trigger, which the tail now runs ahead of the mute switch.
// Either way the mute switch sees a present (or correctly absent) service. A camera the controller does not report as a doorbell attaches nothing here.
this.reconcileDoorbellCapability("construct");
return true;
}
/* Reconcile this camera's doorbell capability against the controller's live state - the single chokepoint the construction-time arm and the always-armed isDoorbell
* observer both route through, driven by the pure doorbellReconcileAction over (hasCapability, isDoorbell). A promotion composes the capability onto the running
* instance in place; the one HAP object that cannot change in place (the CameraController) is rebuilt by reconcileStreamingAudioCapabilities, not here, since a late
* doorbell-ness is one of the frozen audio capabilities that reconcile watches for. The source discriminant separates the two attach contexts: at
* construction the normal flow (the pending IIFE building the stream with the now-true flag, the IIFE tail running mute/trigger, configureMqtt registering ring) covers
* the camera-side wiring, so the construct arm does only the capability compose; a live promotion ("observe") must additionally re-run that camera-side wiring because
* the construction flow already ran with the flag false.
*/
reconcileDoorbellCapability(source) {
switch (doorbellReconcileAction({ hasCapability: this.doorbell !== null, isDoorbell: this.ufp.featureFlags.isDoorbell })) {
case "attach":
this.attachDoorbellCapability(source);
break;
case "report-withdrawn":
// Promotion-only: the controller no longer reports this camera as a doorbell, but its doorbell accessories remain until the plugin restarts. We narrate
// the withdrawal once (a settled demotion; a within-drain flap self-collapses because the reconcile re-reads live state) and remove nothing.
this.log.warn("The controller no longer reports this camera as a doorbell; its doorbell accessories remain until UniFi Protect for HomeKit restarts.");
break;
case "sweep-stale":
// A demoted-while-down doorbell reconstructs as a plain camera with no capability: remove the doorbell-only services it left behind (idempotent - a no-op on a
// steady plain camera). The removal routes through the NVR composition root so the camera never value-imports the sibling capability class (the device-layer
// structural-cycle-proof invariant); the Doorbell service itself is left to configureDoorbellTrigger's existing removal arm.
this.nvr.removeStaleDoorbellServices(this.accessory);
break;
case "none":
// Steady state - a doorbell with its capability (the steady plain camera resolves to the no-op "sweep-stale" instead).
break;
}
}
/* Compose the doorbell capability onto this live camera and, for a genuine live promotion, re-run the camera-side doorbell wiring the construction flow would have run
* had the flag been true at adoption. The construct arm does only the capability compose: the normal construction flow covers everything else. The observe arm, a
* promotion of a running plain camera, additionally: re-runs the mute switch and trigger (the construction IIFE tail already ran them while the flag was false, and the
* Doorbell service now exists); registers the ring-trigger MQTT (a late promotion the construction configureMqtt could not have registered); and narrates the promotion
* once. The late-doorbell controller rebuild lives in reconcileStreamingAudioCapabilities, driven by the featureFlags observer, so this method only composes the
* capability: an isDoorbell change also wakes that observer, and reconcileStreamingAudioCapabilities rebuilds the streaming delegate when a frozen audio capability
* has appeared (a late doorbell-ness or a late speaker), single-sourcing that rebuild across both late inputs.
*/
attachDoorbellCapability(source) {
// Construction is graph-assembly, so the camera does not new the capability itself - it asks the NVR composition root to build one (which holds zero policy, just the
// one new) and keeps the lifecycle decision here. The camera reaches the NVR through an inherited field, never a value-import, so this call forms no module import
// edge and the device layer stays structurally cycle-proof. The capability's configure stands up the Doorbell service through configureDoorbellService.
this.doorbell = this.nvr.createDoorbellCapability(this, this.device, this.signal);
this.doorbell.configure();
// At construction the normal flow handles the camera-side wiring; only a live promotion runs the rest.
if (source === "construct") {
return;
}
// Re-run the camera's doorbell-adjacent configures now that the Doorbell service exists (the construction IIFE tail ran them while the flag was false).
// acquireService is idempotent, so a re-run that finds the service in place is harmless.
this.configureDoorbellMuteSwitch();
this.configureDoorbellTrigger();
// Register the ring-trigger MQTT subscription, which the construction configureMqtt could not have registered while the flag was false (the once-guard makes a
// later duplicate registration a no-op).
this.configureDoorbellRingMqtt();
// Narrate the promotion once - today's reclassification is completely silent.
this.log.info("The controller now reports this camera as a doorbell; its doorbell features are now available in HomeKit.");
}
// Publish the per-accessory observer-wake milestone for a slice the attached doorbell capability watches. The capability has no accessory identity of its own (it
// extends ProtectBase, not ProtectDevice), so it delegates its wake attribution here. This is a thin public seam onto the inherited ProtectDevice.onObserverWake,
// which is the single publisher - the hasSubscribers-guarded publish keyed on this camera's accessory UUID - so the capability's wakes and the camera's own wakes
// share one publication idiom, single-sourced. Zero-cost when no diagnostics subscriber is attached.
publishObserverWake(key) {
this.onObserverWake(key);
}
// Cleanup after ourselves if we're being deleted.
cleanup() {
// Tear down the doorbell capability first when one is attached - releasing its package camera, its observers, and exactly its MQTT handlers - then null the handle,
// mirroring today's doorbell.cleanup ordering (package first).
this.doorbell?.cleanup();
this.doorbell = null;
// Tear down the streaming delegate and unregister its controller through the shared extraction.
this.teardownStreamingDelegate();
super.cleanup();
this.isDeleted = true;
}
/* The camera family's observer template, effectively final: super spawns the universal base observers (name sync and device information), then spawnCameraObservers
* spawns the family-specific set. Camera-family leaves extend spawnCameraObservers, never this template - a deliberate asymmetry with the other device families, which
* extend spawnObservers directly. Only the camera family has a leaf-of-a-leaf (the package camera under the doorbell) that must suppress part of its parent's observer
* set, and the seam is what lets it replace exactly the camera reactions while still inheriting the base pair. The package replaces spawnCameraObservers without a
* super call (its own bespoke set); the doorbell's own reactions are not a camera subclass - they spawn through the composed DoorbellCapability's own configure, keyed
* on the capability's signal - so the camera's set is the plain-camera set and the per-class observer count pins in the construction tests are the enforcement.
*/
spawnObservers() {
super.spawnObservers();
this.spawnCameraObservers();
}
// Spawn the camera's narrow-selector state observers. Each loop fires only when its watched slice changes by reference - the store's Object.is dedup is the
// trigger, so there is no hand-diff and no held snapshot. Activity (motion, ring, smart detection, tamper) is delivered by the NVR firehose router and is deliberately
// never re-synthesized here from device-state.
spawnCameraObservers() {
// Bind the by-id camera selector once and read fields off it, so each per-dispatch selector evaluation reuses the same closure rather than re-deriving it. We seed it
// from the projection's non-throwing id rather than the throwing config, so the selector binding never depends on a present record.
const cam = deviceSelectors.camera.byId(this.device.id);
// The RTSP channel set and the negotiated video codec both shape the HomeKit streaming surface, so a change to either re-derives it. Separate observers, not one
// tuple: a fresh tuple would never dedup on Object.is, whereas each field dedups natively as its own slice.
this.observeState({ key: "camera.channels", selector: state => cam(state)?.channels, title: "video streaming" }, () => void this.reconcileStreaming());
this.observeState({ key: "camera.videoCodec", selector: state => cam(state)?.videoCodec, title: "video streaming" }, () => void this.reconcileStreaming());
// The published channel profiles bake their RTSP URLs against the connection host at derivation time (rtspHost feeds buildChannelProfile), so a camera IP change must
// re-derive them. The reconcile chokepoint's derivation half is safe to re-run and its create-once delegate half is already guarded, so routing the host change here
// re-bakes the profile URLs without disturbing the standing streaming delegate.
this.observeState({ key: "camera.connectionHost", selector: state => cam(state)?.connectionHost, title: "video streaming" }, () => void this.reconcileStreaming());
// The lifecycle state enum drives independent reactions, so each gets its own observer on the same slice. We watch state because isOnline - and therefore the
// device-online half of isReachable - derives from it; the controller-health half is pushed by the NVR connection loop, not observed here.
this.observeState({ key: "camera.state", selector: state => cam(state)?.state, title: "availability" }, () => this.updateAvailability());
// The tamper-detection setting governs whether the StatusTampered characteristic exists at all; the tamper occurrence itself is a firehose event the router delivers.
// The setting slice wakes the one capability reconcile, so a user toggling tamper detection and the controller reporting hasTamperDetection late share the same
// chokepoint the featureFlags observer drives, rather than driving the tamper characteristic alone.
this.observeState({ key: "camera.smartDetectSettings", selector: state => cam(state)?.smartDetectSettings, title: "tamper detection" }, () => void this.reconcileCapabilities("observe"));
// The remaining device-detail reactions, decomposed per field so each updates only its own characteristics and wakes only on its own slice.
this.observeState({ key: "camera.ispSettings", selector: state => cam(state)?.ispSettings, title: "night vision" }, () => this.updateNightVision());
this.observeState({ key: "camera.ledSettings", selector: state => cam(state)?.ledSettings, title: "the status light" }, () => this.updateStatusIndicator());
this.observeState({ key: "camera.recordingSettings", selector: state => cam(state)?.recordingSettings, title: "recording" }, () => this.updateRecordingSwitches());
// A camera's doorbell-ness is temporally dynamic: the controller can provision featureFlags.isDoorbell late (a promotion) or withdraw it (a demotion). The observer
// is always armed - it routes every flag change through the live-attach reconcile chokepoint, which composes the capability onto the running instance on a promotion
// and narrates a settled demotion - so a construction-time doorbell and a late flip share ONE code path. The reconcile re-reads live state on each wake, so a
// within-drain flap (true->false->true delivered in one notify) self-collapses to the final value with no churn. The package camera stays un-armed by its no-super
// spawnCameraObservers, never spawning this observer against the shared parent record.
this.observeState({ key: "camera.isDoorbell", selector: state => cam(state)?.featureFlags.isDoorbell, title: "doorbell status" }, () => this.reconcileDoorbellCapability("observe"));
// The controller can finish reporting a camera's hardware capabilities AFTER adoption - a fresh add bootstraps featureFlags incrementally. The whole-featureFlags
// slice drives the one capability reconcile, so a capability the controller reports late is reflected live without a restart. One observer over the object slice, not
// one per flag: the flags complete together in a single bootstrap drain, the reconcile is idempotent, and the store's structural sharing yields a new featureFlags
// reference only on a real change. The value-selecting isDoorbell observer above dedups independently, so a flag change that does not touch isDoorbell wakes
// only this observer.
this.observeState({ key: "camera.featureFlags", selector: state => cam(state)?.featureFlags, title: "device capabilities" }, () => void this.reconcileCapabilities("observe"));
// The paired UniFi Access reader's unlock capability lives in accessDeviceMetadata, a TOP-LEVEL sibling of the camera's own featureFlags, and can complete AFTER
// adoption (the reader pairs, or its capability finishes reporting, later). A VALUE selector over the one gating boolean - mirroring the isDoorbell observer above -
// routes the change through the same capability reconcile chokepoint, so the lock surfaces live without a restart. A value selector, not a whole-accessDeviceMetadata
// selector: the latter would over-fire on every bootstrap refresh that replaces the camera record wholesale, while the value dedups to the one boolean that gates the
// lock.
this.observeState({ key: "camera.supportUnlock", selector: state => cam(state)?.accessDeviceMetadata?.featureFlags.supportUnlock, title: "Access lock support" }, () => void this.reconcileCapabilities("observe"));
// Bare motion is a device-state field, not a firehose occurrence: the controller signals a raw motion start by advancing the camera record's lastMotion timestamp, so
// the camera observes it here exactly like the sensor and light families observe their own motion state. The store seeds this observer's baseline at subscribe and
// yields only on a subsequent advance, so a bootstrap-hydrated value or a reconnect-unchanged value never fires - the truthy guard only screens the 0/never-detected
// case. Whether the advance actually trips the parent's MotionSensor is the bare-motion policy's decision: we fire only when smart detection is not the source of
// truth for this camera (see shouldDeliverBareMotion). The package forward is INDEPENDENT of the parent's bare-motion de-dup above - a recording package camera has
// no motion signal of its own, so the parent's raw motion always trips it whenever the package is recording for HKSV, regardless of whether the parent itself fired.
this.observeState({ key: "camera.lastMotion", selector: state => cam(state)?.lastMotion, title: "motion detection" }, lastMotion => {
if (!lastMotion) {
return;
}
const featureFlags = this.ufp.featureFlags;
const fire = shouldDeliverBareMotion({
hksvRecording: this.stream?.hksv?.isRecording ?? false,
smartCapable: (featureFlags.smartDetectAudioTypes.length > 0) || (featureFlags.smartDetectTypes.length > 0),
smartDetectEnabled: this.hints.smartDetect
});
if (fire) {
this.nvr.events.motionEventHandler(this);
}
if (this.packageCamera?.stream?.hksv?.isRecording) {
this.nvr.events.motionEventHandler(this.packageCamera);
}
});
}
// Configure the ambient light sensor for HomeKit.
async configureAmbientLightSensor() {
// Gate the ambient light sensor on the camera's lux capability (conservative) and the user's Device.AmbientLightSensor toggle (absolute) via capabilityGate; the gate
// sits above the poll registration below, so a hidden sensor registers no interval.
if (!this.validService(this.hap.Service.LightSensor, capabilityGate({ capability: this.ufp.featureFlags.hasLuxCheck, toggle: this.hasFeature("Device.AmbientLightSensor") }))) {
return false;
}
// Acquire the service.
const service = this.acquireService(this.hap.Service.LightSensor, undefined, undefined, (lightSensorService) => {
lightSensorService.addOptionalCharacteristic(this.hap.Characteristic.StatusActive);
});
// Fail gracefully.
if (!service) {
this.log.error("Unable to add ambient light sensor.");
return false;
}
// We wire the sensor on every configure rather than only on first creation. Re-binding an onGet replaces the single handler (they do not stack) and the registry's
// keyed setInterval self-replaces its timer, so re-running the wiring is a no-op on repeat. That is exactly what re-establishes the handlers and the poll after a
// Homebridge restart, which restores the cached LightSensor service but never its runtime wiring - a within-session reconcile re-run simply re-issues one cheap
// controller read.
const getLux = async () => {
// Skip the query when the controller or camera is unreachable; the request would only fail.
if (!this.isReachable) {
return -1;
}
try {
// The library validates the reading and throws on a malformed body, so any failure - unreachable mid-flight, a non-2xx, or a non-numeric reading - means "no
// reading" and we skip the update; a genuine zero reading is floored to HomeKit's 0.0001 minimum.
let lux = await this.device.lux();
lux ||= 0.0001;
return lux;
}
catch {
return -1;
}
};
// Update the ambient light sensor at regular intervals.
const updateAmbientLight = async () => {
// Stop updating if we no longer exist.
if (this.isDeleted) {
this.timers.clear("ambientLight");
return;
}
// Grab the current ambient light level.
const lux = await getLux();
// Nothing to update, we're done.
if ((this.ambientLight === lux) || (lux === -1)) {
return;
}
// Update the sensor.
service.updateCharacteristic(this.hap.Characteristic.CurrentAmbientLightLevel, this.ambientLight = lux);
// Publish the state.
this.publish("ambientlight", this.ambientLight.toString());
};
this.timers.setInterval("ambientLight", () => void updateAmbientLight(), 60 * 1000);
// Retrieve the active state when requested.
service.getCharacteristic(this.hap.Characteristic.StatusActive).onGet(() => this.isReachable);
// Initialize the sensor's reading. We adopt only a genuine reading: a failed read (-1 - the camera unreachable, or the lux capability transiently withdrawn during a
// controller reconnect) leaves the last-known value in place rather than stamping the HomeKit display to the floor, mirroring the 60-second poll's skip-on-(-1) guard
// above. On the first configure there is no prior reading - the constructor seeds 0, which HomeKit cannot represent - so we floor to the minimum only in that case.
const reading = await getLux();
if (reading !== -1) {
this.ambientLight = reading;
}
else if (this.ambientLight === 0) {
this.ambientLight = 0.0001;
}
// Retrieve the current light level when requested.
service.getCharacteristic(this.hap.Characteristic.CurrentAmbientLightLevel).onGet(() => this.ambientLight);
service.updateCharacteristic(this.hap.Characteristic.CurrentAmbientLightLevel, this.ambientLight);
service.updateCharacteristic(this.hap.Characteristic.StatusActive, this.isReachable);
return true;
}
// Capture a JPEG snapshot of this camera from the controller. This is the narrow public seam onto the camera projection's snapshot command - the camera owns "take a
// snapshot of me" while keeping the device projection encapsulated. ProtectSnapshot calls this as the Protect-API source in its multi-source acquisition. The command
// throws on failure (a non-2xx, or a ProtectUnsupportedError when a package snapshot is requested on a camera without a package sensor); the caller treats a throw as
// "this source failed" and falls through to the next source.
async snapshotFromController(opts = {}) {
return this.device.snapshot(opts);
}
// The narrow public seam onto the camera projection's pooled livestream, mirroring snapshotFromController. We map our ChannelProfile to the `source` selector
// (the lens=>channel-0 coercion now lives in the library), default the segment length to our 100 ms resolution (the native pool
// also floors at 100, but the RTSP adapter would otherwise default to 1000), declare the plugin's livestream defaults (a 16384-byte chunk for lower fragmentation and
// per-segment timestamps - both enter the pool's sharing key, so they must be passed explicitly or two plugin subscribers would silently fail to share a session),
// preserve the friendly controller-side request label (the camera name + channel/lens), and pass the consumer's urgency closure and discardOnDispose preference
// straight through to the pool's recovery/detection policy. The RTSP-debug variant is a pure-FFmpeg plugin path that produces the same Segment stream behind the same
// interface; it ignores discardOnDispose by construction, since its own FFmpeg-fed subscription has no pooled queue to discard.
livestream(channelProfile, opts = {}) {
const segmentLength = opts.segmentLength ?? PROTECT_SEGMENT_RESOLUTION;
// The RTSP-debug path (Debug.Video.Timeshift.UseRtsp) transcodes the camera's RTSP stream through FFmpeg to feed the timeshift buffer instead of the controller's
// native livestream. It is gated on the debug feature alone - no HKSV recording precondition - so it engages from startup for the standing buffer. The audio target
// mirrors the livestream API's native delivery, derived from camera facts rather than any HKSV recording configuration.
if (this.hasFeature("Debug.Video.Timeshift.UseRtsp") && this.stream) {
// Mono AAC-LC at the camera's livestream (fMP4) audio rate, which livestreamAudioSampleRate owns.
const samplerate = (livestreamAudioSampleRate(this.ufp) === 48000) ? AudioRecordingSamplerate.KHZ_48 : AudioRecordingSamplerate.KHZ_16;
return new RtspLivestreamSubscription({
audio: { channels: 1, codec: AudioRecordingCodecType.AAC_LC, samplerate: samplerate },
enableAudio: this.ufp.featureFlags.hasMic && this.hasFeature("Audio"),
ffmpegOptions: this.stream.ffmpegOptions,
segmentLength: segmentLength,
signal: opts.signal,
url: channelProfile.url,
videoCodec: this.ufp.videoCodec
});
}
// The native pooled livestream. requestId preserves the friendly label the controller logs (name + channel, or name + "0." + lens for a secondary lens).
const source = (channelProfile.lens !== undefined) ? { lens: channelProfile.lens, type: "lens" } :
{ channel: channelProfile.channel.id, type: "channel" };
const requestId = this.name + ":" + ((channelProfile.lens !== undefined) ? "0." + channelProfile.lens.toString() : channelProfile.channel.id.toString());
return this.device.livestream({ chunkSize: 16384, discardOnDispose: opts.discardOnDispose, requestId: requestId, segmentLength: segmentLength, signal: opts.signal,
source: source, timestamps: true, urgency: opts.urgency });
}
// Reboot this camera through the controller. This is the narrow public seam onto the camera projection's reboot command, mirroring snapshotFromController - the
// camera owns "reboot me" while keeping the device projection encapsulated. The HKSV timeshift's livestream self-heal calls this to reset a wedged camera's
// livestream endpoint after the recovery policy gives up. The command throws on failure; the caller decides how to handle a failed reboot.
async reboot() {
return this.device.reboot();
}
// Open a send-direction two-way-audio (talkback) channel to this camera's speaker. The narrow public seam onto the camera projection's talkback command, mirroring
// snapshotFromController - the camera owns "talk to me" while keeping the device projection encapsulated. The streaming delegate's two-way-audio path opens this, then
// drains the return-audio FFmpeg's stdout into the returned session. The command negotiates the WebSocket and connects atomically (returns a live session or throws),
// and throws a ProtectUnsupportedError for a camera with no speaker; the caller treats a throw as "no talkback" and tears down its return-audio plumbing.
async talkback(opts = {}) {
return this.device.talkback(opts);
}
// Configure UniFi Access specific features for devices that are made available in Protect. The single chokepoint reconcileCapabilities routes the lock through, so a
// paired Access reader the controller finishes reporting only after adoption surfaces the lock live, without a restart. The source separates establishment
// (construct: a fresh adoption or a Homebridge restart) from a live reconcile (observe), which governs the resting-state stamp below.
configureAccessFeatures(source) {
// Read whether the lock already exists BEFORE we touch it. This is the resting-state-stamp decision input only - it does NOT gate the wiring below (the onSet
// re-binds on every configure, which re-establishes the handler after a Homebridge restart restores the cached service but never its runtime wiring).
const existing = this.accessory.getServiceById(this.hap.Service.LockMechanism, ProtectReservedNames.LOCK_ACCESS);
// Whether the paired Access reader reports the unlock capability. A single optional chain: the controller reports accessDeviceMetadata only for a camera with a
// paired reader, and the nested featureFlags is always present when the metadata is.
const supportsUnlock = Boolean(this.ufp.accessDeviceMetadata?.featureFlags.supportUnlock);
// Gate the lock on the paired-reader capability (conservative) and the user toggle (absolute) via capabilityGate.
if (!this.validService(this.hap.Service.LockMechanism, capabilityGate({ capability: supportsUnlock, toggle: this.hasFeature("UniFi.Access.Lock") }), ProtectReservedNames.LOCK_ACCESS)) {
return false;
}
// Acquire the service.
const service = this.acquireService(this.hap.Service.LockMechanism, this.accessoryName, ProtectReservedNames.LOCK_ACCESS);
// Fail gracefully.
if (!service) {
this.log.error("Unable to add lock.");
return false;
}
// Configure the lock current and target state characteristics.
service.getCharacteristic(this.hap.Characteristic.LockTargetState).onSet(async (value) => {
// Protect only supports unlocking. If the user taps lock while we're in the momentary unlock window, revert the optimistic SECURED target back to UNSECURED. We
// guard on the auto re-lock timer being pending so we don't stomp a SECURED state that our own timer just wrote...the registry deletes the keyed entry before
// invoking its callback, so by the time we check, a just-fired timer is already gone and we correctly become a no-op.
if (value === this.hap.Characteristic.LockTargetState.SECURED) {
setTimeout(() => {
if (!this.timers.has("accessUnlock")) {
return;
}
service.updateCharacteristic(this.hap.Characteristic.LockTargetState, this.hap.Characteristic.LockTargetState.UNSECURED);
service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.hap.Characteristic.LockCurrentState.UNSECURED);
}, 50);
return;
}
// Unlock the Access device through the shared command-error helper.
if (!(await this.runDeviceCommand("unlock the Access device", () => this.device.unlock()))) {
// The command failed (the helper already reported it); revert HomeKit to its prior locked state.
setTimeout(() => {
service.updateCharacteristic(this.hap.Characteristic.LockTargetState, this.hap.Characteristic.LockTargetState.SECURED);
service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.hap.Characteristic.LockCurrentState.SECURED);
}, 50);
return;
}
// The unlock succeeded. Protect v7 no longer fires a feedback event for user-directed Access unlocks, so we drive the auto re-lock from here. HomeKit already
// set the lock to UNSECURED as part of the set request that brought us into this handler, so we just need to schedule the re-lock.
this.log.info("Unlocked.");
// Two seconds is long enough for the momentary UNSECURED state to register visibly in the Home app before we revert it back to SECURED.
this.timers.setTimeout("accessUnlock", () => {
service.updateCharacteristic(this.hap.Characteristic.LockTargetState, this.hap.Characteristic.LockTargetState.SECURED);
service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.hap.Characteristic.LockCurrentState.SECURED);
}, 2000);
});
// Establish the SECURED resting state on creation (a fresh adoption or a live self-heal, where no lock existed) or on the construct path (a Homebridge restart
// restoring a cached lock, whose runtime SECURED/UNSECURED state HAP does not serialize). A LIVE reconcile over an existing lock deliberately never re-stamps the
// display - the onSet owns it - so the reconcile can never truncate a momentary user unlock, whose optimistic UNSECURED shows with no relock timer yet armed during
// the command round-trip (the timer arms only after the awaited unlock command resolves).
if (!existing || (source === "construct")) {
service.updateCharacteristic(this.hap.Characteristic.LockTargetState, this.hap.Characteristic.LockTargetState.SECURED);
service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.hap.Characteristic.LockCurrentState.SECURED);
}
return true;
}
// Configure discrete smart motion contact sensors for HomeKit.
configureMotionSmartSensor() {
// Get any license plates the user has configured for detection, if any.
this.detectLicensePlate = this.getFeatureValue("Motion.SmartDetect.ObjectSensors.LicensePlate")?.split("-").filter(x => x.length).map(x => x.toUpperCase()) ?? [];
// Check if we have disabled specific license plate smart motion object contact sensors, and if so, remove them.
for (const objectService of this.accessory.services.filter(x => x.subtype?.startsWith(ProtectReservedNames.CONTACT_MOTION_SMARTDETECT_LICENSE + "."))) {
// Do we have smart motion detection as well as license plate telemetry available to us and is this license plate configured? If so, move on.
if (this.ufp.featureFlags.hasSmartDetect && this.ufp.featureFlags.smartDetectTypes.includes("licensePlate") && objectService.subtype &&
this.detectLicensePlate.includes(objectService.subtype.slice(objectService.subtype.indexOf(".") + 1))) {
continue;
}
// We don't have this contact sensor enabled, remove it.
this.accessory.removeService(objectService);
this.log.info("Disabling smart motion license plate contact sensor: %s.", objectService.subtype?.slice(objectService.subtype.indexOf(".") + 1));
}
// If we don't have smart motion detection available or we have smart motion object contact sensors disabled, let's remove them.
if (!this.hints.smartDetectSensors) {
// Check for object-centric contact sensors that are no longer enabled and remove them.
for (const objectService of this.accessory.services.filter(x => x.subtype?.startsWith(ProtectReservedNames.CONTACT_MOTION_SMARTDETECT + "."))) {
// We don't have this contact sensor enabled, remove it.
this.accessory.removeService(objectService);
this.log.info("Disabling smart motion contact sensor: %s.", objectService.subtype?.slice(objectService.subtype.indexOf(".") + 1));
}
}
// If we don't have smart motion detection, we're done.
if (!this.ufp.featureFlags.hasSmartDetect) {
return false;
}
// A utility for us to add contact sensors.
const addSmartDetectContactSensor = (name, serviceId, errorMessage) => {
// Acquire the service.
const service = this.acquireService(this.hap.Service.ContactSensor, name, serviceId);
// Fail gracefully.
if (!service) {
this.log.error(errorMessage);
return false;
}
// Initialize the sensor.
service.updateCharacteristic(this.hap.Characteristic.ContactSensorState, this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED);
return true;
};
let enabledContactSensors = [];
// Add individual contact sensors for each object detection type, if needed.
if (this.hints.smartDetectSensors) {
for (const smartDetectType of [...this.ufp.featureFlags.smartDetectAudioTypes, ...this.ufp.featureFlags.smartDetectTypes].toSorted()) {
if (addSmartDetectContactSensor(this.accessoryName + " " + toStartCase(smartDetectType), ProtectReservedNames.CONTACT_MOTION_SMARTDETECT + "." + smartDetectType, "Unable to add smart motion contact sensor for " + smartDetectType + " detection.")) {
enabledContactSensors.push(smartDetectType);
}
}
this.log.info("Smart motion contact sensor%s enabled: %s.", enabledContactSensors.length > 1 ? "s" : "", enabledContactSensors.join(", "));
}
enabledContactSensors = [];
// Now process license plate contact sensors for individual detections.
if (this.ufp.featureFlags.smartDetectTypes.includes("licensePlate")) {
// Get the list of plates.
for (const licenseOption of this.detectLicensePlate.filter(plate => plate.length)) {
if (addSmartDetectContactSensor(this.accessoryName + " License Plate " + licenseOption, ProtectReservedNames.CONTACT_MOTION_SMARTDETECT_LICENSE + "." + licenseOption, "Unable to add smart motion license plate contact sensor for " + licenseOption + ".")) {
enabledContactSensors.push(licenseOption);
}
}
if (enabledContactSensors.length) {
this.log.info("Smart motion license plate contact sensor%s enabled: %s.", enabledContactSensors.length > 1 ? "s" : "", enabledContactSensors.join(", "));
}
}
return true;
}
// Reconcile the StatusTampered characteristic on the camera's motion sensor against the controller-reported tamper capability and the user setting. A
// reconcileCapabilities leaf, so a hasTamperDetection the controller reports only after adoption surfaces the characteristic without a restart, and a user toggling
// tamper detection prunes it.
configureTamperDetection() {
const service = this.accessory.getService(this.hap.Service.MotionSensor);
if (!service) {
return false;
}
// Read prior existence side-effect-free; getCharacteristic would lazily materialize StatusTampered, defeating the conservative existence check.
const existing = service.testCharacteristic(this.hap.Characteristic.StatusTampered);
// Gate the characteristic with the shared additive-eager / subtractive-conservative asymmetry: the enableTamperDetection setting is the absolute toggle, the
// hasTamperDetection hardware capability is conservative for an already-present characteristic. capabilityGate is service-agnostic, so we apply it to the
// characteristic's existence.
if (!capabilityGate({ capability: this.ufp.featureFlags.hasTamperDetection, toggle: this.ufp.smartDetectSettings.enableTamperDetection })(existing)) {
if (existing) {
service.removeCharacteristic(service.getCharacteristic(this.hap.Characteristic.StatusTampered));
}
// Clear the one-way tamper latch only on the genuine user toggle-off (the documented clear path); a transient capability-false must never clear an active tamper.
if (!this.ufp.smartDetectSettings.enableTamperDetection) {
this.isTampered = false;
}
return false;