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.

743 lines 53.6 kB
import { PACKAGE_CAMERA_NAME_SUFFIX, ProtectReservedNames, packageCameraId } from "../../types.js";
import { PLATFORM_NAME, PLUGIN_NAME, PROTECT_DOORBELL_CHIME_DURATION_DIGITAL } from "../../settings.js";
import { acquireService, composeSignals, sanitizeName, toStartCase, validService } from "homebridge-plugin-utils";
import { guardedPublish, mqttTopic } from "../../mqtt.js";
import { ProtectBase } from "../device-base.js";
import { chimeVolumeFor } from "./chime-volume.js";
import { deviceSelectors } from "unifi-protect";
// The doorbell-only reserved service subtypes the capability owns and that no other code path removes: the three physical-chime mode switches, the chime-volume
// lightbulb, and the authentication contact sensor. Defined once so the sweep-stale removal (DoorbellCapability.removeServices) and any future reader share one
// definition of "the doorbell-only reserved service set". Deliberately EXCLUDES the Doorbell service (configureDoorbellTrigger owns its removal), the mute switch, the
// HKSV-recording switch, and the UFP-recording switches - those are camera-level or owned elsewhere.
const DOORBELL_RESERVED_SUBTYPES = [ProtectReservedNames.CONTACT_AUTHSENSOR, ProtectReservedNames.LIGHTBULB_DOORBELL_VOLUME,
    ProtectReservedNames.SWITCH_DOORBELL_CHIME_DIGITAL, ProtectReservedNames.SWITCH_DOORBELL_CHIME_MECHANICAL, ProtectReservedNames.SWITCH_DOORBELL_CHIME_NONE];
// The reserved-name lookup the message-switch sweep consults, so a non-reserved Switch subtype on a camera-family accessory (the only such subtype is a message switch,
// shaped "type.text") is the removal target while every reserved subtype is left to its own owner.
const RESERVED_NAMES = new Set(Object.values(ProtectReservedNames).map(x => x.toUpperCase()));
/**
 * The effective LCD message for a doorbell, given the raw slice and the current time: an empty object once the message's resetAt deadline has passed, otherwise the
 * slice unchanged. The controller signals a message's expiry by patching lcdMessage rather than clearing it, and an empty-object patch is a no-op the store cannot
 * observe, so both the switch sync and the MQTT get consult this one predicate to treat a past resetAt as a clear. An absent resetAt (a non-expiring message), a
 * still-future resetAt, and an already-empty slice all pass through unchanged. Exported so its truth-table rows exercise it directly.
 *
 * @param options - The raw `lcdMessage` slice and the current time in milliseconds as `nowMs`.
 *
 * @returns The effective message slice, blanked to `{}` when expired.
 */
export function effectiveLcdMessage(options) {
    const { lcdMessage, nowMs } = options;
    if ((typeof lcdMessage.resetAt === "number") && (lcdMessage.resetAt <= nowMs)) {
        return {};
    }
    return lcdMessage;
}
/* The doorbell capability composed onto a ProtectCamera. It extends ProtectBase - the shared observe / MQTT / command spine - rather than ProtectCamera, because
 * doorbell-ness is temporally dynamic capability state, not a static identity: the camera the controller late-flips to a doorbell stays the same instance, and this
 * capability attaches to it live. The capability owns the doorbell services (LCD message switches, physical chimes, the chime-volume lightbulb, the auth sensor), the
 * read-through settings getters, the doorbell observers, the doorbell MQTT topics, and the package-camera lifecycle. The camera-coupling is localized to a
 * private block of delegating accessors below, so each doorbell body resolves its this.ufp / this.accessory / this.hasFeature reads through the camera. The capability
 * holds its own composed AbortController so a future detach unwinds exactly its observers and MQTT registrations, the owner-lifetime idiom one level up.
 */
export class DoorbellCapability extends ProtectBase {
    #controller = new AbortController();
    camera;
    #device;
    messageSwitches = new Map();
    // The self-re-syncing expiry timer for the active LCD message. The controller signals a message's expiry by patching lcdMessage, an empty-object patch the store
    // cannot observe, so the capability schedules its own re-sync at the message's resetAt deadline. Owned outright here and cleared in cleanup(), where the capability's
    // whole lifecycle ends - the state lives exactly where its owner's teardown runs, so demotion (which has no detach) needs no separate handling.
    #lcdMessageExpiryTimer = null;
    packageCamera = null;
    #signal;
    // Construct the capability against its owning camera. The camera passes itself, its own live projection handle, and its protected per-accessory signal from inside
    // its own class body, so no visibility widening is needed. The capability's signal composes its own controller with the camera's, so aborting either tears the
    // capability down.
    constructor(nvr, init) {
        super(nvr);
        this.camera = init.camera;
        this.#device = init.device;
        this.#signal = composeSignals(this.#controller.signal, init.signal);
    }
    /* The delegating accessors that localize the camera-coupling to one block. Each doorbell body reads this.ufp / this.accessory / this.hasFeature and the rest, which
     * ProtectBase does not provide, so routing those reads through these private accessors over the camera keeps the coupling in one place. The acquireService /
     * validService wrappers are thin re-bindings to the homebridge-plugin-utils free functions over the camera's accessory (the same homebridge-plugin-utils SSOT the
     * camera's own wrappers delegate to), not logic duplication.
     */
    // The owning camera projection's live STATE, narrowed to drop device identity (id/mac/modelKey) - that immutable identity flows through the dedicated accessors, never
    // the throwing config projection.
    get ufp() {
        return this.#device.config;
    }
    // The owning camera's accessory.
    get accessory() {
        return this.camera.accessory;
    }
    // The owning camera's accessory name.
    get accessoryName() {
        return this.camera.accessoryName;
    }
    // Feature-option reads delegate to the camera, which scopes against the camera MAC and the controller MAC.
    hasFeature(option) {
        return this.camera.hasFeature(option);
    }
    getFeatureNumber(option) {
        return this.camera.getFeatureNumber(option);
    }
    // Reserved-name check delegates to the camera's public helper.
    isReservedName(name) {
        return this.camera.isReservedName(name);
    }
    // Acquire a service on the camera's accessory, a thin re-binding to the homebridge-plugin-utils free function (the SSOT the camera's own wrapper also delegates to).
    acquireService(serviceType, name = this.accessoryName, subtype, onServiceCreate) {
        return acquireService(this.accessory, serviceType, name, subtype, onServiceCreate);
    }
    // Validate a service on the camera's accessory, the same thin re-binding to the homebridge-plugin-utils free function. The validate argument accepts either a plain
    // boolean or a presence-aware predicate, mirroring the free function: the predicate receives the service's current presence so a gate can keep an already-present
    // service while refusing to create a new one.
    validService(serviceType, validate, subtype) {
        return validService(this.accessory, serviceType, validate, subtype);
    }
    // The seam overrides, each varying exactly what ProtectBase's shared spine reads by leaf.
    // The log prefix and diagnostics name: delegate to the camera's name. The camera's name getter resolves to the live controller projection name, so log prefixes track
    // the controller's current name for the device. NOT accessoryName (the cached HomeKit Name), which would diverge.
    get name() {
        return this.camera.name;
    }
    // The owner-lifetime signal scoping the capability's observers and MQTT registrations: the composed signal (the capability's own controller plus the camera's), so a
    // capability cleanup or a camera teardown unwinds exactly the capability's loops and releases exactly its MQTT handlers.
    get observeSignal() {
        return this.#signal;
    }
    // The MQTT topic scope: the camera's MAC, so the doorbell topics (chime, message) ride the same wire scope as the camera.
    get mqttId() {
        return this.camera.mac;
    }
    // Whether the backing camera record is present, delegated to the owning camera (the capability extends ProtectBase, not ProtectDevice, so it has no record of its own).
    // This drives the inherited observeState gate, so the doorbell observers no-op during the parent's removal grace exactly as the camera's own do.
    get recordPresent() {
        return this.camera.recordPresent;
    }
    // Wake attribution: the capability has no accessory identity of its own, so it delegates to the camera's single publishObserverWake seam, keeping one publisher keyed
    // on the camera's accessory UUID. The doorbell.* keys remain diagnostics-visible under the camera's accessory.
    onObserverWake(key) {
        this.camera.publishObserverWake(key);
    }
    /* The doorbell settings below are live read-through getters rather than stored fields, deliberately. Deriving each setting on read from its single source of truth -
     * the feature options and the live controller projection - eliminates the staleness class entirely: there is no stored copy to wipe, and a controller-side settings
     * change is reflected on the next read without a restart.
     */
    // The duration of a digital physical chime ring, in milliseconds: the user's feature-option setting, clamped to the range of durations Protect accepts.
    get chimeDigitalDuration() {
        return Math.min(Math.max(this.getFeatureNumber("Doorbell.PhysicalChime.Duration.Digital") ?? PROTECT_DOORBELL_CHIME_DURATION_DIGITAL, 1000), 10000);
    }
    // The default duration of a doorbell message, in milliseconds, read live from the controller's doorbell settings.
    get defaultMessageDuration() {
        return this.nvr.ufp.doorbellSettings?.defaultMessageResetTimeoutMs ?? 60000;
    }
    // Whether the user has enabled doorbell messages on this doorbell.
    get isMessagesEnabled() {
        return this.hasFeature("Doorbell.Messages");
    }
    // Whether messages saved on the doorbell itself are included in the message switches we expose.
    get isMessagesFromControllerEnabled() {
        return this.hasFeature("Doorbell.Messages.FromDoorbell");
    }
    // The doorbell's current physical-chime duration, read non-throwing through the live camera record. An absent record (a doorbell in the removal grace) reports
    // 0 rather than throwing; the physical-chime onGet, the initial switch write, and the reactive push all read this single source.
    get chimeDuration() {
        return this.#device.peek()?.chimeDuration ?? 0;
    }
    // Configure the doorbell capability for HomeKit. The configure order is: the package camera, the Doorbell service (through the camera's seam, at the package-to-service
    // point), the auth sensor, the LCD messages, the physical chimes, the chime volume, then the MQTT topics and the observers. The Doorbell service is stood up
    // through the camera's configureDoorbellService seam here, so the camera does not separately call it on attach.
    configure() {
        // Configure our package camera, if we have one.
        this.configurePackageCamera();
        // Ensure the camera's Doorbell service exists and is primary.
        this.camera.configureDoorbellService();
        // Configure the authentication sensor, if enabled.
        this.configureAuthSensor();
        // Configure the doorbell LCD message capabilities.
        this.configureDoorbellLcdSwitch();
        // Configure physical chime switches, if enabled.
        this.configurePhysicalChimes();
        // Configure volume control, if enabled.
        this.configureProtectChimeLightbulb();
        // Configure the doorbell MQTT topics (chime and message get/set). These ride the capability's observeSignal through the inherited subscribe wrappers; the camera
        // registers its own MQTT separately, so there is no super-MQTT to resolve here.
        this.configureMqtt();
        // Spawn the doorbell observers on the capability's own signal.
        this.spawnObservers();
    }
    // Cleanup the capability: tear down the package camera first, then abort the capability's own controller, which releases its observers and exactly its MQTT
    // handlers on the shared parent-MAC tuple.
    cleanup() {
        if (this.packageCamera) {
            this.packageCamera.cleanup();
            this.packageCamera = null;
        }
        // Clear the message expiry timer alongside the controller abort - the capability's whole lifecycle ends here, so this timer must not fire against a torn-down
        // capability.
        if (this.#lcdMessageExpiryTimer) {
            clearTimeout(this.#lcdMessageExpiryTimer);
            this.#lcdMessageExpiryTimer = null;
        }
        this.#controller.abort();
    }
    // Refresh the doorbell's physical-chime switch states. Composed by the camera's updateDevice, which calls this when a capability is attached.
    updateDevice() {
        this.updatePhysicalChimes();
    }
    // Spawn the doorbell's narrow-selector observers. The capability does not inherit ProtectDevice.spawnObservers (it extends ProtectBase), so there is no base template
    // to extend and no base name / info pair to worry about - the name and information observers belong to the camera. Doorbell-ring delivery and the package camera's
    // motion are firehose occurrences the router handles, not observed here.
    spawnObservers() {
        const cam = deviceSelectors.camera.byId(this.#device.id);
        const id = this.#device.id;
        // Reflect the controller's current LCD message across the doorbell's message switches, re-deriving expiry each time the message slice changes.
        this.observeState({ key: "doorbell.lcdMessage", selector: state => cam(state)?.lcdMessage, title: "the doorbell message" }, () => this.syncLcdMessageState());
        // The package camera capability can be provisioned after adoption (a doorbell that was not fully provisioned when first adopted) - and withdrawn: reconcile the
        // package camera's lifecycle in both directions whenever the controller's capability flag changes. The reconcile cancels any pending detach grace on a true flip
        // (ahead of configurePackageCamera's instance guard, so a flap back actually cancels the timer) and schedules a stability-gated, graced detach on a false flip.
        this.observeState({ key: "doorbell.hasPackageCamera", selector: state => cam(state)?.featureFlags.hasPackageCamera, title: "the package camera" }, () => this.reconcilePackageCamera());
        // Reflect the active physical-chime mode across the chime switches.
        this.observeState({ key: "doorbell.chimeDuration", selector: state => cam(state)?.chimeDuration, title: "the chime" }, () => this.updatePhysicalChimes());
        // Reflect cross-device volume changes: when this doorbell's effective chime volume changes on the controller (a ring-volume edit on any assigned chime), push it to
        // the volume Lightbulb. The selector returns the computed mean volume across the doorbell's assigned chimes, so the store's value dedup wakes this only on a real
        // ring-volume change, not on every unrelated chime patch, and the push keeps the volume Lightbulb consistent with the onGet. The id is hoisted to the plain string
        // here, alongside cam, because a selector runs inside the store's dispatch, where a projection read against a removed record throws.
        this.observeState({ key: "doorbell.chimeVolume", selector: state => chimeVolumeFor(deviceSelectors.chime.all(state), id), title: "the chime volume" }, () => this.updateChimeVolume());
    }
    // Configure our access to the doorbell LCD screen.
    configureDoorbellLcdSwitch() {
        // Make sure we're configuring a doorbell with an LCD screen.
        if (!this.ufp.featureFlags.hasLcdScreen) {
            return false;
        }
        // Walk the consolidated list of messages from the doorbell and the user's configuration, registering a switch in HomeKit for each.
        for (const entry of this.getMessages()) {
            // Truncate anything longer than the character limit that the doorbell will accept.
            if (entry.text.length > 30) {
                entry.text = entry.text.slice(0, 30);
            }
            const switchIndex = entry.type + "." + entry.text;
            // In the unlikely event someone tries to use words we have reserved for our own use.
            if (this.isReservedName(switchIndex)) {
                continue;
            }
            // Check to see if we already have this message switch configured.
            if (this.messageSwitches.has(switchIndex)) {
                continue;
            }
            this.log.info("Enabled doorbell message switch%s: %s.", entry.duration ? " (" + (entry.duration / 1000).toString() + " seconds)" : "", entry.text);
            // Acquire the service. Each message cannot exceed 30 characters, but given that HomeKit allows for strings to be up to 64 characters long, this should be fine.
            const service = this.acquireService(this.hap.Service.Switch, entry.text, switchIndex);
            // Fail gracefully.
            if (!service) {
                this.log.error("Unable to add doorbell message switch: %s.", entry.text);
                return false;
            }
            const duration = "duration" in entry ? entry.duration : this.defaultMessageDuration;
            // Save the message switch in the list we maintain.
            this.messageSwitches.set(switchIndex, { duration: duration, service: service, state: false, text: entry.text, type: entry.type });
            // Configure the message switch.
            service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => {
                // Lookup the message switch.
                const messageSwitch = this.messageSwitches.get(switchIndex);
                // If we're already in the state we want to be in, we're done.
                if (!messageSwitch || (messageSwitch.state === value)) {
                    return;
                }
                // Set the message and sync our states.
                await this.setMessage((value === true) ? { duration: messageSwitch.duration, text: messageSwitch.text, type: messageSwitch.type } : { resetAt: Date.now() });
            });
        }
        // Synchronize the message switch state with the controller's current LCD message, arming the expiry timer if the current message is still active. On a restart after
        // a message expired while the plugin was down, this reads the past resetAt as a clear and latches no switch on.
        this.syncLcdMessageState();
        // Check to see if any of our existing doorbell messages have disappeared.
        this.validateMessageSwitches();
        return true;
    }
    // Configure a package camera, if one exists.
    configurePackageCamera() {
        // First, confirm the device has a package camera.
        if (!this.ufp.featureFlags.hasPackageCamera) {
            return false;
        }
        // If we've already setup the package camera, we're done.
        if (this.packageCamera) {
            return true;
        }
        // Generate a UUID for the package camera, seeded by the package camera's identity - the parent's MAC address plus the persistence-critical identity suffix. We derive
        // the id through the shared leaf function rather than the package class, so the capability consumes the package's identity without value-importing its sibling class.
        // The bare MAC comes from the raw record (present at configure time), not the narrowed live-state projection that no longer carries identity.
        const uuid = this.hap.uuid.generate(packageCameraId(this.#device.config.mac));
        // Let's find it if we've already created it.
        let packageCameraAccessory = this.platform.accessories.find(x => x.UUID === uuid);
        // We can't find the accessory. Let's create it.
        if (!packageCameraAccessory) {
            // The camera's MAC address plus the identity suffix gives our UUID seed the guaranteed uniqueness we need.
            packageCameraAccessory = new this.api.platformAccessory(sanitizeName(this.accessoryName + PACKAGE_CAMERA_NAME_SUFFIX), uuid);
            // Register this accessory with homebridge and add it to the accessory array so we can track it.
            if (this.hasFeature("Device.Standalone")) {
                this.api.publishExternalAccessories(PLUGIN_NAME, [packageCameraAccessory]);
            }
            else {
                this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [packageCameraAccessory]);
            }
            this.platform.accessories.push(packageCameraAccessory);
            this.api.updatePlatformAccessories(this.platform.accessories);
        }
        // Now create the package camera accessory through the NVR composition root. The package camera is a HomeKit sub-view of this same physical device, so it shares our
        // live camera projection rather than holding a synthesized config snapshot. It is self-observing: its display name is the parent's name plus the display suffix,
        // applied through its syncedName seam, and its own name, firmware, and availability observers keep it tracking the shared device live - this capability owns only its
        // lifecycle (the WHEN, the observers, the sweep triggers), never its construction; only the new lives at the composition root, so no sibling-class value-import.
        this.packageCamera = this.nvr.createPackageCamera(packageCameraAccessory, this.#device);
        return true;
    }
    /* Reconcile the package camera's lifecycle against the controller's live capability flag, idempotently in both directions. The hasPackageCamera observer drives
     * this on a flag change, and the NVR's stability sweep drives it at every stability return - the sweep is both the construction-time arm (a capability withdrawn
     * while the plugin was down leaves a cached BRIDGED package accessory that no other removal path can ever reach, since the orphan sweep keys on a mac the package
     * deliberately lacks and the orphan guard refuses parent-alive packages) and the re-arm (a detach grace dropped by a stability loss, or a fire that failed its
     * stability re-check, is re-scheduled once the controller settles). STANDALONE EXCLUSION: homebridge never restores external accessories at startup, so a
     * standalone package ghost after a restart is invisible here - we cannot know one existed, its HomeKit-side pairing cleanup is inherently the user's, and no
     * speculative guidance is emitted.
     */
    reconcilePackageCamera() {
        // The bare MAC comes from the raw record, not the narrowed live-state projection that no longer carries identity.
        const uuid = this.hap.uuid.generate(packageCameraId(this.#device.config.mac));
        // The capability is present: cancel any pending detach grace first (a flap back during the grace window must actually cancel the timer - configurePackageCamera's
        // instance guard would otherwise short-circuit before any cancellation could happen), then bring the package camera into being if it is not already.
        if (this.ufp.featureFlags.hasPackageCamera) {
            this.nvr.cancelDeviceRemovalFor(uuid);
            this.configurePackageCamera();
            return;
        }
        // The capability is withdrawn. Is there anything to detach? A live instance, or a cached accessory restored from a prior session (the across-restart ghost).
        const accessory = this.packageCamera?.accessory ?? this.platform.accessories.find(x => x.UUID === uuid);
        if (!accessory) {
            return;
        }
        // Schedule the graced, stability-gated detach through the NVR's removal chokepoint. The stillGone predicate must be absence-tolerant: the fire runs on a bare
        // timer where a throw would crash Homebridge, and the parent projection's config getter throws once the parent record itself has been removed - so we capture the
        // projection's plain id field at schedule time and re-read the record through the selector, treating an absent parent as gone.
        const id = this.#device.id;
        this.nvr.scheduleDeviceRemoval({
            accessory: accessory,
            reason: "The controller no longer reports a package camera on " + this.accessoryName + "; removing its package camera accessory.",
            remove: () => this.detachPackageCamera(),
            stillGone: () => !deviceSelectors.camera.byId(id)(this.nvr.client.state.snapshot())?.featureFlags.hasPackageCamera
        });
    }
    /* Detach the package camera - the removal action a reconciled detach decision runs. Everything derives from persisted identity (the accessory context's MAC)
     * rather than the live projection, because the fire can run after the parent's record has left the store, where every projection config read throws. The accessory
     * derives from the live instance else the platform lookup, so a divergent accessory/instance pair is unrepresentable. In order: clear the dispatcher's event
     * timers for the package id, publishing the terminal MQTT motion reset on the shared parent topic when the cleared package reset timer would have owned it and the
     * parent holds no inflight motion of its own (without this, the shared topic latches "true" on the live parent until its next motion); tear down the live
     * instance, which releases its observers and - through the owner-lifetime MQTT scoping - exactly its handlers on the shared tuple; then remove the accessory
     * through the NVR's shared, presence-guarded removal tail. The schedule-time reason line is the flow's one user-facing message; the only addition is the
     * manual-deletion guidance for a standalone (unbridged) accessory, whose HomeKit-side removal we cannot perform ourselves.
     */
    detachPackageCamera() {
        const mac = this.accessory.context.mac;
        if (!mac) {
            return;
        }
        const packageId = packageCameraId(mac);
        const accessory = this.packageCamera?.accessory ?? this.platform.accessories.find(x => x.UUID === this.hap.uuid.generate(packageId));
        // Clear the package's event timers, taking over the cleared reset timer's terminal publish when the parent's own inflight motion would not cover it. The topic
        // composes from the persisted MAC rather than the publish wrapper, whose topic scope reads the live projection.
        if (this.nvr.events.clearEventTimersForDevice(packageId) && !this.nvr.events.hasInflightMotion(mac)) {
            guardedPublish(this.log, this.nvr.mqtt, mqttTopic(mac, "motion"), "false");
        }
        // Tear down the live instance, if any.
        this.packageCamera?.cleanup();
        this.packageCamera = null;
        if (!accessory) {
            return;
        }
        // Remove the accessory through the shared, presence-guarded tail.
        this.nvr.removeAccessoryFromHomeKit(accessory);
        if (!accessory._associatedHAPAccessory.bridged) {
            this.log.info("You will need to manually delete the package camera accessory in the Home app to complete the removal.");
        }
    }
    // Configure a series of switches to manually enable or disable chimes on Protect doorbells that support attached physical chimes.
    configurePhysicalChimes() {
        const switchesEnabled = [];
        // The Protect controller supports three modes for attached, physical chimes on a doorbell: none, mechanical, and digital. We create switches for each of the modes.
        for (const physicalChimeType of [ProtectReservedNames.SWITCH_DOORBELL_CHIME_NONE, ProtectReservedNames.SWITCH_DOORBELL_CHIME_MECHANICAL, ProtectReservedNames.SWITCH_DOORBELL_CHIME_DIGITAL]) {
            const chimeSetting = physicalChimeType.slice(physicalChimeType.lastIndexOf(".") + 1);
            // Validate whether we should have this service enabled.
            // If we don't have the physical capabilities or the feature option enabled, disable the switch and we're done.
            if (!this.validService(this.hap.Service.Switch, this.ufp.featureFlags.hasChime && this.hasFeature("Doorbell.PhysicalChime"), physicalChimeType)) {
                continue;
            }
            // Acquire the service.
            const service = this.acquireService(this.hap.Service.Switch, this.accessoryName + " Physical Chime " + toStartCase(chimeSetting), physicalChimeType);
            // Fail gracefully.
            if (!service) {
                this.log.error("Unable to add physical chime switch: %s.", chimeSetting);
                continue;
            }
            // Get the current status of the physical chime mode on the doorbell.
            service.getCharacteristic(this.hap.Characteristic.On).onGet(() => {
                return this.chimeDuration === this.getPhysicalChimeDuration(physicalChimeType);
            });
            // Activate the appropriate physical chime mode on the doorbell.
            service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => {
                // We only want to do something if we're being activated. Turning off the switch would really be an undefined state given that there are three different settings
                // one can choose from. Instead, we do nothing and leave it to the user to choose what state they really want to set.
                if (!value) {
                    // Let HomeKit's optimistic write settle, then re-assert the switches' real state through updateDevice. The 50ms is a cosmetic revert nudge, not a
                    // functional delay.
                    setTimeout(() => this.updateDevice(), 50);
                    return;
                }
                // Push the new physical chime duration to the controller, reporting any failure through the shared command-error helper.
                if (!(await this.runDeviceCommand("set the physical chime mode to " + chimeSetting, () => this.#device.update({ chimeDuration: this.getPhysicalChimeDuration(physicalChimeType) })))) {
                    return;
                }
                // Force the other physical chime switches off - the three modes are mutually exclusive, so only one can be on at a time.
                for (const otherChimeSwitch of [ProtectReservedNames.SWITCH_DOORBELL_CHIME_NONE, ProtectReservedNames.SWITCH_DOORBELL_CHIME_MECHANICAL,
                    ProtectReservedNames.SWITCH_DOORBELL_CHIME_DIGITAL]) {
                    // Don't update ourselves a second time.
                    if (physicalChimeType === otherChimeSwitch) {
                        continue;
                    }
                    this.accessory.getServiceById(this.hap.Service.Switch, otherChimeSwitch)?.updateCharacteristic(this.hap.Characteristic.On, false);
                }
                // Inform the user, and we're done.
                this.log.info("Physical chime type set to %s.", chimeSetting);
            });
            // Initialize the physical chime switch state.
            service.updateCharacteristic(this.hap.Characteristic.On, this.chimeDuration === this.getPhysicalChimeDuration(physicalChimeType));
            switchesEnabled.push(chimeSetting);
        }
        if (switchesEnabled.length) {
            this.log.info("Enabling physical chime switches: %s (digital chime duration: %s ms).", switchesEnabled.join(", "), this.chimeDigitalDuration.toLocaleString("en-US"));
        }
        return true;
    }
    // Configure the dimmer for HomeKit to control the volume.
    configureProtectChimeLightbulb() {
        // Validate whether we should have this service enabled.
        if (!this.validService(this.hap.Service.Lightbulb, this.hasFeature("Doorbell.Volume.Dimmer"), ProtectReservedNames.LIGHTBULB_DOORBELL_VOLUME)) {
            return false;
        }
        // Acquire the service.
        const service = this.acquireService(this.hap.Service.Lightbulb, this.accessoryName + " Chime Volume", ProtectReservedNames.LIGHTBULB_DOORBELL_VOLUME);
        if (!service) {
            this.log.error("Unable to add chime volume control.");
            return false;
        }
        // Turn the chime on or off.
        service.getCharacteristic(this.hap.Characteristic.On).onGet(() => this.chimeVolume > 0);
        service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => {
            // We really only want to act when the volume is zero. Otherwise, it's handled by the brightness event.
            if (value) {
                return;
            }
            await this.setChimeVolume(0);
        });
        // Return the volume level of the chime.
        service.getCharacteristic(this.hap.Characteristic.Brightness).onGet(() => this.chimeVolume);
        // Adjust the volume of the chime by adjusting brightness of the light.
        service.getCharacteristic(this.hap.Characteristic.Brightness).onSet(async (value) => this.setChimeVolume(value));
        // Initialize the chime.
        service.updateCharacteristic(this.hap.Characteristic.On, this.chimeVolume > 0);
        service.updateCharacteristic(this.hap.Characteristic.Brightness, this.chimeVolume);
        this.log.info("Enabling Protect chime volume control.");
        return true;
    }
    // Configure the contact sensor to indicate authentication success.
    configureAuthSensor() {
        // Validate whether we should have this service enabled.
        // The authentication contact sensor is disabled by default unless the user enables it. We only make it available if we have at least one of the
        // fingerprint sensor or the NFC sensor available.
        if (!this.validService(this.hap.Service.ContactSensor, this.hasFeature("Doorbell.AuthSensor") && (this.ufp.enableNfc || this.ufp.featureFlags.hasFingerprintSensor), ProtectReservedNames.CONTACT_AUTHSENSOR)) {
            return false;
        }
        // Acquire the service.
        const service = this.acquireService(this.hap.Service.ContactSensor, this.accessoryName + " Authenticated", ProtectReservedNames.CONTACT_AUTHSENSOR);
        if (!service) {
            this.log.error("Unable to add authentication sensor.");
            return false;
        }
        // Initialize the authentication contact sensor.
        service.updateCharacteristic(this.hap.Characteristic.ContactSensorState, this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED);
        this.log.info("Enabling Protect authentication contact sensor.");
        return true;
    }
    // Configure MQTT capabilities for the doorbell: the chime volume and the doorbell message get/set. These ride the capability's observeSignal through the inherited
    // subscribe wrappers, scoped under the camera's MAC. The camera registers its own MQTT separately, so there is no parent camera-MQTT to resolve here.
    configureMqtt() {
        // Get and set the chime volume.
        this.subscribeGet("chime", "chime volume", () => {
            return this.chimeVolume.toString();
        });
        this.subscribeSet("chime", "chime volume", (value) => {
            const volume = parseInt(value);
            // Unknown message - ignore it.
            if (isNaN(volume) || (volume < 0) || (volume > 100)) {
                return;
            }
            // We explicitly want to trigger our set event handler, which will complete this action.
            this.accessory.getServiceById(this.hap.Service.Lightbulb, ProtectReservedNames.LIGHTBULB_DOORBELL_VOLUME)
                ?.setCharacteristic(this.hap.Characteristic.Brightness, volume);
            this.accessory.getServiceById(this.hap.Service.Lightbulb, ProtectReservedNames.LIGHTBULB_DOORBELL_VOLUME)
                ?.setCharacteristic(this.hap.Characteristic.On, volume > 0);
        });
        // Get the current message on the doorbell.
        this.subscribeGet("message", "doorbell message", () => {
            // Read the LCD message non-throwing through the live camera record - an absent record (a doorbell lingering in the removal grace) reads as no message - and run it
            // through the shared expiry predicate, so an expired message reports no message rather than a stale negative duration.
            const lcdMessage = effectiveLcdMessage({ lcdMessage: this.#device.peek()?.lcdMessage ?? {}, nowMs: Date.now() });
            if (!Object.keys(lcdMessage).length) {
                return "";
            }
            const doorbellDuration = (typeof lcdMessage.resetAt === "number") ? Math.round((lcdMessage.resetAt - Date.now()) / 1000) : 0;
            // Return the current message.
            return JSON.stringify({ duration: doorbellDuration, message: lcdMessage.text ?? "" });
        });
        // We support the ability to set the doorbell message like so:
        //
        //   { "message": "some message", "duration": 30 }
        //
        // If duration is omitted, we assume the default duration.
        // If duration is 0, we assume it's not expiring.
        // If the message is blank, we assume we're resetting the doorbell message.
        this.subscribeSet("message", "doorbell message", (_value, rawValue) => {
            let inboundPayload;
            // Catch any errors in parsing what we get over MQTT.
            try {
                inboundPayload = JSON.parse(rawValue);
            }
            catch {
                this.log.error("Unable to process MQTT message: \"%s\". Invalid JSON.", rawValue);
                // Errors mean that we're done now.
                return;
            }
            // Validate the payload's runtime types - the JSON.parse cast is compile-time only. The message must be a string (which subsumes the presence check), and any
            // duration present must be a real number: a non-numeric duration, INCLUDING a numeric-looking string like "30", is rejected per the documented numeric contract
            // rather than silently coerced through arithmetic.
            if ((typeof inboundPayload.message !== "string") ||
                (("duration" in inboundPayload) && ((typeof inboundPayload.duration !== "number") || Number.isNaN(inboundPayload.duration)))) {
                this.log.error("Unable to process MQTT message: \"%s\". The message must include a \"message\" field and any duration must be numeric.", rawValue);
                return;
            }
            // If no duration specified, or a negative duration, we assume the default duration.
            if (!("duration" in inboundPayload) || (("duration" in inboundPayload) && (inboundPayload.duration < 0))) {
                inboundPayload.duration = this.defaultMessageDuration;
            }
            else {
                inboundPayload.duration = inboundPayload.duration * 1000;
            }
            let outboundPayload;
            // No message defined...we assume we're resetting the message.
            if (!inboundPayload.message.length) {
                outboundPayload = { resetAt: Date.now() };
                this.log.info("Received MQTT doorbell message reset.");
            }
            else {
                outboundPayload = { duration: inboundPayload.duration, text: inboundPayload.message, type: "CUSTOM_MESSAGE" };
                this.log.info("Received MQTT doorbell message%s: %s.", outboundPayload.duration ? " (" + (outboundPayload.duration / 1000).toString() + " seconds)" : "", outboundPayload.text);
            }
            // Send it to the doorbell and we're done.
            void this.setMessage(outboundPayload);
        });
        return true;
    }
    // Push the physical-chime switch states from the doorbell's current chime duration, when the doorbell has a chime and the switches are configured. Driven by the
    // chimeDuration observer and composed into the camera's updateDevice.
    updatePhysicalChimes() {
        if (!this.ufp.featureFlags.hasChime || !this.hasFeature("Doorbell.PhysicalChime")) {
            return;
        }
        // Reflect the active physical-chime mode across the three mutually-exclusive switches.
        for (const physicalChimeType of [ProtectReservedNames.SWITCH_DOORBELL_CHIME_NONE, ProtectReservedNames.SWITCH_DOORBELL_CHIME_MECHANICAL, ProtectReservedNames.SWITCH_DOORBELL_CHIME_DIGITAL]) {
            this.accessory.getServiceById(this.hap.Service.Switch, physicalChimeType)?.
                updateCharacteristic(this.hap.Characteristic.On, this.chimeDuration === this.getPhysicalChimeDuration(physicalChimeType));
        }
    }
    // Return the physical chime duration, in milliseconds.
    getPhysicalChimeDuration(physicalChimeType) {
        // Set the physical chime duration to correspond to the settings that Protect configures when selecting different physical chime types.
        switch (physicalChimeType) {
            case ProtectReservedNames.SWITCH_DOORBELL_CHIME_DIGITAL:
                return this.chimeDigitalDuration;
            case ProtectReservedNames.SWITCH_DOORBELL_CHIME_MECHANICAL:
                // Mechanical chimes use a fixed duration, unlike the digital case above, which reads a user-configurable setting.
                return 300;
            case ProtectReservedNames.SWITCH_DOORBELL_CHIME_NONE:
            default:
                return 0;
        }
    }
    // Get the list of messages from the doorbell and the user configuration.
    getMessages() {
        // First, we get the controller's doorbell settings, the source for its builtin messages.
        const doorbellSettings = this.nvr.ufp.doorbellSettings;
        // Something's not right with the configuration...we're done.
        if (!doorbellSettings || !this.isMessagesEnabled) {
            return [];
        }
        let doorbellMessages = [];
        // Grab any messages that the user has configured.
        if (this.nvr.config.doorbellMessages) {
            for (const configEntry of this.nvr.config.doorbellMessages) {
                let duration = this.defaultMessageDuration;
                // If we've set a duration, let's honor it. If it's less than zero, use the default duration.
                if (("duration" in configEntry) && !isNaN(configEntry.duration) && (configEntry.duration >= 0)) {
                    duration = configEntry.duration * 1000;
                }
                // Add it to our list.
                doorbellMessages.push({ duration: duration, text: configEntry.message, type: "CUSTOM_MESSAGE" });
            }
        }
        // If we've got messages on the controller, let's configure those, unless the user has disabled that feature.
        if (this.isMessagesFromControllerEnabled) {
            // The controller's allMessages may omit the duration field that MessageInterface declares; the switch-build loop tolerates that with its "duration" in entry
            // fallback to defaultMessageDuration, which is what makes this cast safe.
            doorbellMessages = doorbellSettings.allMessages.concat(doorbellMessages);
        }
        // Return the list of doorbell messages.
        return doorbellMessages;
    }
    // Validate our existing HomeKit message switch list, syncing it against the controller's current message set. This runs at configure time only: it is the
    // across-restart sync, catching the scenario where Homebridge was shut down and the list of saved messages on the controller changed.
    validateMessageSwitches() {
        // The filter below selects switches whose subtype is not one of our reserved names and is not already tracked in messageSwitches - the doorbell messages that
        // have disappeared from the controller's current set.
        for (const switchService of this.accessory.services.filter(service => (service.UUID === this.hap.Service.Switch.UUID) && service.subtype &&
            !this.isReservedName(service.subtype) && !this.messageSwitches.has(service.subtype))) {
            // The message has been deleted on the doorbell - remove it from HomeKit and inform the user about it.
            this.log.info("Removing saved doorbell message: %s.", switchService.subtype?.slice(switchService.subtype.indexOf(".") + 1));
            this.accessory.removeService(switchService);
        }
    }
    /* Synchronize the message switches with the controller's current LCD message, re-deriving expiry each time. Invoked by the lcdMessage observer, by the configure-time
     * sync, and by the expiry timer's own callback, so all three read one truth. The live slice is read non-throwing - an absent record (a doorbell in the removal grace)
     * reads as a clear, which is exactly right mid-removal - and passes through the shared expiry predicate, so an expired message blanks the switches while an active one
     * sets them. We then arm or clear the local expiry timer from the effective payload: the controller signals expiry by patching lcdMessage, but the store cannot
     * observe an empty-object patch (its structural sharing returns the same record), so the plugin re-synchronizes itself at the resetAt deadline it already knows.
     * Because the timer's callback re-enters here, an expiry fires as a re-read of live truth: an already-replaced message re-asserts the replacement rather than
     * blanking, an expired one clears, and a wire clear that landed first makes the re-sync a harmless repeat. The leg is capped at the largest delay a timer expresses
     * exactly (Node clamps a larger delay to about a millisecond, which would spin a tight refire loop for a beyond-24.8-day message), so the re-entrant re-arm sleeps in
     * legs until the real deadline.
     */
    syncLcdMessageState() {
        const nowMs = Date.now();
        const effective = effectiveLcdMessage({ lcdMessage: this.#device.peek()?.lcdMessage ?? {}, nowMs });
        this.updateLcdSwitch(effective);
        if (this.#lcdMessageExpiryTimer) {
            clearTimeout(this.#lcdMessageExpiryTimer);
            this.#lcdMessageExpiryTimer = null;
        }
        if (typeof effective.resetAt === "number") {
            this.#lcdMessageExpiryTimer = setTimeout(() => this.syncLcdMessageState(), Math.min(effective.resetAt - nowMs, 2147483647));
        }
    }
    // Update the message switch state in HomeKit.
    updateLcdSwitch(payload) {
        // The message has been cleared on the doorbell, turn off all message switches in HomeKit.
        if (!Object.keys(payload).length) {
            for (const entry of this.messageSwitches.values()) {
                entry.state = false;
                entry.service.updateCharacteristic(this.hap.Characteristic.On, false);
            }
            return;
        }
        // Sanity check.
        if (!("type" in payload) || !("text" in payload)) {
            return;
        }
        // The message has been set on the doorbell. Update HomeKit accordingly.
        for (const [key, entry] of this.messageSwitches) {
            // If it's not the message we're interested in, make sure it's off and keep going.
            if (key !== ((payload.type ?? "") + "." + (payload.text ?? ""))) {
                entry.state = false;
                entry.service.updateCharacteristic(this.hap.Characteristic.On, false);
                continue;
            }
            // If the message switch is already on, we're done.
            if (entry.state) {
                continue;
            }
            // Set the message state and update HomeKit.
            entry.state = true;
            entry.service.updateCharacteristic(this.hap.Characteristic.On, true);
            this.log.info("Doorbell message set%s: %s.", payload.resetAt !== null ? " (" + Math.round(((payload.resetAt ?? 0) - Date.now()) / 1000).toString() + " seconds)" : "", payload.text);
            // Publish to MQTT, if the user has configured it.
            this.publish("message", JSON.stringify({ duration: entry.duration / 1000, message: entry.text }));
        }
    }
    // Set the message on the doorbell.
    async setMessage(payload = {}) {
        // If a duration was given, translate it into the resetAt timestamp Protect expects and drop the duration field, which the controller does not understand.
        if ("duration" in payload) {
            payload.resetAt = payload.duration ? Date.now() + payload.duration : null;
            delete payload.duration;
        }
        // Push the update to the doorbell, reporting any failure through the shared command-error helper. An empty payload resets the LCD message back to its default.
        return this.runDeviceCommand("set the doorbell message", () => this.#device.update({ lcdMessage: payload }));
    }
    // This doorbell's effective chime volume, read through the live chime projections. We delegate to the shared chimeVolumeFor helper over deviceSelectors.chime.all of
    // the current snapshot - the identical input the volume observer reduces - so the read-through getter and the reactive push share one definition of "this doorbell's
    // volume". The catalog's chime.all is always an array post-connect.
    get chimeVolume() {
        return chimeVolumeFor(deviceSelectors.chime.all(this.nvr.client.state.snapshot()), this.#device.id);
    }
    async setChimeVolume(value) {
        // Clamp to a non-negative volume.
        value = Math.max(value, 0);
        // A chime can be assigned to multiple doorbells, so update the ring entry for THIS doorbell on every chime that serves it. The controller is the single,
        // immutable source of state, so each update is write-through only: we send the PATCH and let the reducer's stream reflect the change once it arrives. We send a
        // single-entry ringSettings array carrying only the modified ring, matching the controller's expected payload.
        for (const chime of this.nvr.client.chimes.filter(chime => chime.config.cameraIds.includes(this.#device.id))) {
            const ring = chime.config.ringSettings.find(setting => setting.cameraId === this.#device.id);
            if (!ring) {
                continue;
            }
            // eslint-disable-next-line no-await-in-loop
            if (!(await this.runDeviceCommand("set the chime volume", () => chime.update({ ringSettings: [{ ...ring, volume: value }] })))) {
                return;
            }
        }
        this.publish("chime", value.toString());
    }
    // Push the chime-volume projection onto the doorbell's volume Lightbulb. Shares the read path (chimeVolume / chimeVolumeFor) with the onGet handlers, so the displayed
    // volume and the live value never disagree. Idempotent - HomeKit coalesces an unchanged write.
    updateChimeVolume() {
        const volume = this.chimeVolume;
        const service = this.accessory.getServiceById(this.hap.Service.Lightbulb, ProtectReservedNames.LIGHTBULB_DOORBELL_VOLUME);
        service?.updateCharacteristic(this.hap.Characteristic.Brightness, volume);
        service?.updateCharacteristic(this.hap.Characteristic.On, volume > 0);
    }
    /* Remove the doorbell-only services from a camera accessory that the controller no longer reports as a doorbell - the SSOT removal a camera-owned sweep-stale arm
     * runs when a demoted-while-down doorbell reconstructs as a plain camera, where today's doorbell-only services would otherwise linger forever. Removes the reserved
     * doorbell subtypes (the three physical-chime switches, the chime-volume lightbulb, the auth contact sensor) and the non-reserved message-switch Switch services
     * (whose subtype is shaped "type.text", never a reserved name). Deliberately does NOT remove the Doorbell service (configureDoorbellTrigger's existing arm owns its
     * removal for non-doorbell hardware), the mute switch, the HKSV-recording switch, or the three UFP-recording switches - those are camera-level or owned elsewhere.
     * Static and accessory-scoped because it runs without a live capability (none is attached when this fires).
     */
    static removeServices(accessory, hap, log) {
        // Remove the reserved doorbell-only subtypes.
        for (const subtype of DOORBELL_RESERVED_SUBTYPES) {
            const service = accessory.getServiceById(hap.Service.Switch, subtype) ?? accessory.getServiceById(hap.Service.Lightbulb, subtype) ??
                accessory.getServiceById(hap.Service.ContactSensor, subtype);
            if (service) {
                log.info("Removing stale doorbell service: %s.", subtype);
                accessory.removeService(service);
            }
        }
        // Remove the non-reserved message-switch services: a Switch with a subtype that is not a reserved name (the message-switch "type.text" shape).
        for (const switchService of accessory.services.filter(service => (service.UUID === hap.Service.Switch.UUID) && service.subtype &&
            !RESERVED_NAMES.has(service.subtype.toUpperCase()))) {
            log.info("Removing stale doorbell message switch: %s.", switchService.subtype?.slice(switchService.subtype.indexOf(".") + 1));
            accessory.removeService(switchService);
        }
    }
}
//# sourceMappingURL=doorbell.js.map