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.

170 lines 13.7 kB
import { formatErrorMessage, loopFaultReporter, prefixedLog, superviseLoop } from "homebridge-plugin-utils"; import { guardedPublish, mqttTopic } from "../mqtt.js"; import { ProtectAuthorizationError } from "unifi-protect"; export class ProtectBase { api; hap; log; nvr; platform; // Initialize the shared owner fields (api, hap, nvr, platform) and build the device-prefixed log wrapper. constructor(nvr) { this.api = nvr.platform.api; this.hap = this.api.hap; this.nvr = nvr; this.platform = nvr.platform; // Derive this device's logger from the plugin logging root, prefixing every line with this.logName: the plain name on the base, overridden by ProtectDevice to the // full "Name [Model]" descriptor so device lines show which hardware they belong to. The prefix supplier is read on every call, so a device rename reaches the next // line. logName is deliberately separate from the bare `name` getter, which is the stable functional identity (it keys the livestream request id and the HomeKit // accessory) and must not carry the bracketed model. this.log = prefixedLog(nvr.platform.pluginLog, () => this.logName); } // Configure the device information for HomeKit. setInfo(accessory, device) { const infoService = accessory.getService(this.hap.Service.AccessoryInformation); // Update the manufacturer information for this device. infoService?.updateCharacteristic(this.hap.Characteristic.Manufacturer, "Ubiquiti Inc."); // Update the model information for this device. // marketName is optional at runtime despite its declared type, so the nullish fallback to device.type is real, not redundant. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const deviceModel = device.marketName ?? device.type; if (deviceModel.length) { infoService?.updateCharacteristic(this.hap.Characteristic.Model, deviceModel); } // Update the serial number for this device. if (device.mac.length) { infoService?.updateCharacteristic(this.hap.Characteristic.SerialNumber, device.mac); } // Update the hardware revision for this device, if available. if (device.hardwareRevision?.length) { infoService?.updateCharacteristic(this.hap.Characteristic.HardwareRevision, device.hardwareRevision); } // Update the firmware revision for this device. if (device.firmwareVersion?.length) { infoService?.updateCharacteristic(this.hap.Characteristic.FirmwareRevision, device.firmwareVersion); } return true; } // Utility function to return the fully enumerated name of this device. We default it to the controller but expect it to be overridden downstream. The controller // label is single-sourced from the unifi-protect library's selectControllerName (client.controllerName). controllerName is null only pre-bootstrap; the empty-string // fallback keeps log lines producing "" before bootstrap rather than "null". get name() { return this.nvr.client.controllerName ?? ""; } // The log-line prefix for this owner. The base returns the bare name because a controller-scoped owner (system information, liveviews) has no hardware model to // surface. ProtectDevice overrides this to append "[Model]", so each device line shows its hardware model. It is deliberately a separate accessor from `name`: as the // constructor's log seam notes, `name` is the stable functional identity (livestream request id, HomeKit accessory) and must never carry the bracketed model. get logName() { return this.name; } // The owner-lifetime signal that scopes this owner's state observers AND its MQTT subscriptions. The base binds to the controller's terminal shutdown signal, the // correct lifetime for the teardown-less controller-scoped owners (system information, liveviews) that live for the whole controller connection. Two kinds of owner // override the seam with a composed signal so their own teardown unwinds only their own observers and releases exactly their own MQTT handlers: every ProtectDevice // (its per-accessory controller composed with nvr.signal), and the security system (whose accessory comes and goes with the Protect-* liveviews, so its lifetime is // shorter than the controller connection). This is one of the seams the shared observeState varies by leaf, and the signal the MQTT subscribe wrappers below thread // to homebridge-plugin-utils. get observeSignal() { return this.nvr.signal; } // Hook fired each time a state observer wakes, so a leaf can attribute the wake to a diagnostics subject. The base is a deliberate no-op: controller-scoped owners have // no single accessory identity to key a wake to, mirroring the NVR's own observe loops which likewise publish nothing here. ProtectDevice overrides this to publish the // accessory-scoped wake milestone. Another of the seams the shared observeState varies by leaf. onObserverWake(_key) { // No-op by default; the per-accessory wake milestone is published by ProtectDevice's override. } // Whether this owner's backing controller record is currently present. The base returns true unconditionally: a controller-scoped owner (system information, liveviews, // the security system) has no per-device record that can vanish - its backing controller record is present for the whole connection. ProtectDevice overrides this to // peek() !== undefined, and DoorbellCapability delegates to its camera, so the one observeState gate below neutralizes a vanished record on every owner. Another of // the seams the shared observeState varies by leaf, alongside observeSignal and onObserverWake. // eslint-disable-next-line @typescript-eslint/class-literal-property-style -- Polymorphic seam overridden by getters; the base cannot be a readonly field. get recordPresent() { return true; } // The single narrow-selector state-observe primitive, shared by every HomeKit-projection owner - device leaves and controller-scoped owners alike. The loop wakes only // when its reduced slice changes by reference (the store's Object.is dedup is upstream of the yield), the handler re-reads through the owner's live projection rather // than trusting the yielded value so a multi-read reaction always sees a coherent snapshot, and the seams leaves vary are the lifetime signal (observeSignal) and // the wake attribution (onObserverWake). The slice descriptor's names route to separate consumers: its key tags the wake on the diagnostics channel, its title // names the capability in the user-facing fault report. The detached-loop resilience envelope (swallow on abort, surface a fault once) is delegated to // homebridge-plugin-utils' superviseLoop and the fault report to its loopFaultReporter; both single-sourced. What remains here is just the observe-specific body // superviseLoop supervises. observeState(slice, handler) { const { key, selector, title } = slice; const signal = this.observeSignal; void superviseLoop({ loop: async () => { for await (const value of this.nvr.client.state.observe(selector, { signal })) { // A value can still drain from the store's queue after teardown aborted our signal (the iterator empties its queue before it closes). Reacting then would run // a handler against a record the membership loop is removing, so we drop the trailing yield and let the loop end. if (signal.aborted) { break; } // The owner's controller record can vanish while its observe loops stay subscribed: a device unadopted at the controller lingers for the DelayDeviceRemoval // grace, during which a wake on the removal dispatch (its slices going undefined) would re-read a vanished record and throw. We skip this wake and stay // subscribed - a re-adoption within the grace resumes the handler with no respawn. CONTINUE, not break: the loop's lifetime is the owner signal, not the // record's presence. The gate and the handler's synchronous prefix run in one microtask, so the presence read cannot go stale before the handler acts. if (!this.recordPresent) { continue; } this.onObserverWake(key); handler(value); } }, onError: loopFaultReporter(this.log, title), signal }); } // The MQTT topic-scope identifier for this owner - the leading path segment under homebridge-plugin-utils' configured topic prefix, yielding the wire topic // {topicPrefix}/{mqttId}/{topic}. Controller-scoped owners (system information, liveviews, the security system) scope under the controller's MAC; ProtectDevice // overrides this to its own device MAC. It is the single seam the MQTT wrappers vary by, so the device-scoped-topic convention is defined in exactly one place. get mqttId() { return this.nvr.ufp.mac; } // Publish an MQTT event under this owner's scope: compose the owner's mqttId into the topic tail (homebridge-plugin-utils' MqttClient then prefixes the configured // topic) and route through the shared guardedPublish, so a rejected publish is logged under its own label rather than floating as an unhandled rejection. publish(topic, message) { guardedPublish(this.log, this.nvr.mqtt, mqttTopic(this.mqttId, topic), message); } // Configure an MQTT get subscription under this owner's scope. The registration is bound to the owner-lifetime signal, so an owner's teardown (cleanup, removal, // reclassification) releases exactly this owner's handler - load-bearing on shared topics, where the package camera and its parent doorbell each hold handlers on // the same parent-MAC tuple and a tuple-wide unsubscribe would clobber the survivor's. The signal governs registration lifetime only; an in-flight handler runs to // completion under homebridge-plugin-utils' client-level signal, by design. subscribeGet(topic, type, getValue) { this.nvr.mqtt?.subscribeGet(mqttTopic(this.mqttId, topic), type, getValue, { signal: this.observeSignal }); } // Configure an MQTT set subscription under this owner's scope, bound to the owner-lifetime signal exactly as subscribeGet is. subscribeSet(topic, type, setValue) { this.nvr.mqtt?.subscribeSet(mqttTopic(this.mqttId, topic), type, setValue, { signal: this.observeSignal }); } // Remove an MQTT subscription under this owner's scope. The homebridge-plugin-utils unsubscribe takes the id and the topic tail as separate arguments (it reconstructs // {topicPrefix}/{id}/{topic} internally and does not append /get or /set), so the caller passes the full tail - for example "motion/get" - and we supply the mqttId. unsubscribe(topic) { this.nvr.mqtt?.unsubscribe(this.mqttId, topic); } // Run a user-initiated device command and report whether it succeeded. Device commands are write-through: they PATCH the controller and throw the classified // FatalError on failure (rather than returning null), so this is the single place that converts a thrown command error into the boolean a HomeKit onSet handler // branches on, and the single place a user-initiated command failure is reported - autoconfiguration writes narrate their own outcomes at their call sites, the one // place the consequence and the remedy are known. The command is supplied as a thunk by the caller, where this.device is narrowed to the concrete projection, so the // update typechecks against its own config; a helper that called this.device.update() itself would face the contravariance of the base's full device-projection union. // An authorization failure is the one actionable case for the user - the account lacks the full management role - so it earns specific guidance; any other failure is // reported with its underlying cause. The action is a verb phrase ("turn the light on") interpolated into the message. This lives on ProtectBase, the lowest common // ancestor of every command-issuer (every ProtectDevice subclass plus the DoorbellCapability), so the one copy serves them all; the controller-scoped ProtectBase-only // owners (the security system, the system-info owner, and liveviews) issue no Protect write commands and inherit it unused - a deliberate, accepted consequence of // placing it at the common ancestor. async runDeviceCommand(action, command) { try { await command(); return true; } catch (error) { if (error instanceof ProtectAuthorizationError) { this.log.error("Unable to %s. Please ensure this username has the full management role in UniFi Protect.", action); return false; } // Report the failure with its underlying cause. The format string supplies the terminal period, and formatErrorMessage strips the trailing period a classified // error's own full-sentence message carries, so the line reads as one clean sentence rather than ending in a doubled period. this.log.error("Unable to %s: %s.", action, formatErrorMessage(error)); return false; } } } //# sourceMappingURL=device-base.js.map