UNPKG

homebridge-unifi-access

Version:

Homebridge UniFi Access plugin providing complete HomeKit integration for the UniFi Access ecosystem with full support for most features including autoconfiguration, motion detection, multiple controllers, and realtime updates.

858 lines 78.2 kB
/* Copyright(C) 2019-2026, HJD (https://github.com/hjdhjd). All rights reserved. * * access-hub.ts: Unified hub and reader device class for UniFi Access. */ import { AccessDevice } from "./access-device.js"; import { acquireService, validService } from "homebridge-plugin-utils"; import { AccessReservedNames } from "./access-types.js"; // Access methods available to us for readers. const accessMethods = [ { capability: "identity_face_unlock", key: "face", name: "Face Unlock", option: "AccessMethod.Face", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_FACE }, { capability: "hand_wave", key: "wave", name: "Hand Wave", option: "AccessMethod.Hand", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_HAND }, { capability: ["mobile_unlock_ver2", "support_mobile_unlock"], configsApiKeys: ["bt", "bt_button", "bt_tap"], key: "bt_button", name: "Mobile", option: "AccessMethod.Mobile", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_MOBILE }, { capability: "nfc_card_easy_provision", key: "nfc", name: "NFC", option: "AccessMethod.NFC", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_NFC }, { capability: "pin_code", key: "pin_code", name: "PIN", option: "AccessMethod.PIN", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_PIN }, { capability: "qr_code", key: "qr_code", name: "QR Code", option: "AccessMethod.QR", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_QR }, { capability: "support_apple_pass", key: "apple_pass", name: "TouchPass", option: "AccessMethod.TouchPass", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_TOUCHPASS } ]; // Device types that use the /configs API endpoint instead of /settings for access method changes. const configsApiDeviceTypes = ["UVC G6 Entry"]; // Define the dry contact inputs we're interested in for Access hubs. const sensorInputs = ["Dps", "Rel", "Ren", "Rex"]; // Define the sensor wiring. It's a bit convoluted because there's a lot of inconsistency at the API level across device types in Access: // - For UA-ULTRA, we look at rex_button_mode = proxyMode. // - For other models, we look at per-device wiring keys. const sensorWiring = { Dps: { proxyMode: "dps", wiring: { "UA-Hub-Door-Mini": ["wiring_state_d1-dps-neg", "wiring_state_d1-dps-pos"], UAH: ["wiring_state_dps-neg", "wiring_state_dps-pos"], UGT: ["wiring_state_gate-dps-neg", "wiring_state_gate-dps-pos"] } }, Rel: { wiring: { UAH: ["wiring_state_rel-neg", "wiring_state_rel-pos"] } }, Ren: { wiring: { UAH: ["wiring_state_ren-neg", "wiring_state_ren-pos"] } }, Rex: { proxyMode: "rex", wiring: { "UA-Hub-Door-Mini": ["wiring_state_d1-button-neg", "wiring_state_d1-button-pos"], UAH: ["wiring_state_rex-neg", "wiring_state_rex-pos"] } } }; // Constants for timing. const AUTO_LOCK_DELAY_MS = 5000; const GATE_TRANSITION_COOLDOWN_MS = 5000; export class AccessHub extends AccessDevice { _hkDpsState; _hkLockState; _hkSideDoorDpsState; _hkSideDoorLockState; doorbellRingRequestId; gateTransitionUntil; lockDelayInterval; mainDoorLocationId; sideDoorLocationId; sideDoorGateTransitionUntil; uda; // Create an instance. constructor(controller, device, accessory) { super(controller, accessory); this.uda = device; this._hkDpsState = this.hubDpsState; this._hkLockState = this.hubLockState; this._hkSideDoorDpsState = this.hubSideDoorDpsState; this._hkSideDoorLockState = this.hubSideDoorLockState; this.gateTransitionUntil = 0; this.lockDelayInterval = this.getFeatureNumber("Hub.LockDelayInterval") ?? undefined; this.mainDoorLocationId = undefined; this.sideDoorLocationId = undefined; this.sideDoorGateTransitionUntil = 0; this.doorbellRingRequestId = null; // If we attempt to set the delay interval to something invalid, then assume we are using the default unlock behavior. if ((this.lockDelayInterval !== undefined) && (this.lockDelayInterval < 0)) { this.lockDelayInterval = undefined; } this.configureHints(); this.configureDevice(); } // Configure device-specific settings for this device. configureHints() { // Configure our parent's hints. super.configureHints(); this.hints.hasSideDoor = (this.uda.device_type === "UGT") && this.hasFeature("Hub.SideDoor"); this.hints.hasWiringDps = ["UA Ultra", "UA Hub", "UA Hub Door Mini", "UA Gate"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.DPS"); this.hints.hasWiringRel = ["UA Hub"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.REL"); this.hints.hasWiringRen = ["UA Hub"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.REN"); this.hints.hasWiringRex = ["UA Ultra", "UA Hub", "UA Hub Door Mini"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.REX"); this.hints.hasWiringSideDoorDps = this.hints.hasSideDoor && this.hasFeature("Hub.SideDoor.DPS"); this.hints.logDoorbell = this.hasFeature("Log.Doorbell"); this.hints.logDps = this.hasFeature("Log.DPS"); this.hints.logLock = this.hasFeature("Log.Lock"); this.hints.logRel = this.hasFeature("Log.REL"); this.hints.logRen = this.hasFeature("Log.REN"); this.hints.logRex = this.hasFeature("Log.REX"); // The Ultra has a single terminal input that's selectable between DPS and REX modes. We detect which mode it's operating in, and adjust accordingly. We've // over-engineered this a bit for future-proofing. if (this.uda.display_model === "UA Ultra") { this.checkUltraInputs(); } return true; } // Return the door service type from configuration. UA Gate devices default to GarageDoorOpener and can be overridden to Lock. Other hubs default to Lock and can be // overridden to GarageDoorOpener. get doorServiceType() { if (this.uda.device_type === "UGT") { return this.hasFeature("Hub.Door.UseLock") ? "Lock" : "GarageDoorOpener"; } return this.hasFeature("Hub.Door.UseGarageOpener") ? "GarageDoorOpener" : "Lock"; } // Convert lock string value to HomeKit LockCurrentState. toLockState(lockValue) { return ["unlock", "unlocked"].includes(lockValue) ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.SECURED; } // Convert DPS string value to HomeKit ContactSensorState. toDpsState(dpsValue) { return dpsValue === "open" ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED; } // Check if a lock state represents "locked". isLocked(state) { return state === this.hap.Characteristic.LockCurrentState.SECURED; } // Check if a DPS state represents "closed" (contact detected). isClosed(state) { return state === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED; } // Discover main and side door location IDs for UA Gate hubs. This allows us to receive remote_unlock events for each door. discoverDoorIds() { const doors = this.controller.udaApi.doors ?? []; if (doors.length === 0) { this.log.warn("No doors found in Access API. Door event handling may not work correctly."); return; } // Get the primary door ID from device config (may be undefined). // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const primaryDoorId = this.uda.door?.unique_id; // Strategy 1: Use the device's bound door as main door. if (primaryDoorId) { this.mainDoorLocationId = primaryDoorId; } else if (doors.length >= 1) { // Strategy 2: Look for a door named like "main", "gate", "portail" (but not side/pedestrian). const mainDoor = doors.find(door => /portail|main|gate|principal|entry|front/i.test(door.name) && !/portillon|side|pedestrian|pieton|wicket|back/i.test(door.name)); // Strategy 3: Use the first door as main door. this.mainDoorLocationId = mainDoor?.unique_id ?? doors[0].unique_id; } // Find the side door (if enabled). if (this.hints.hasSideDoor) { // Strategy 1: Check extensions for oper2 port setting. const sideDoorFromExt = this.uda.extensions?.find(ext => (ext.extension_name === "port_setting") && (ext.target_name === "oper2"))?.target_value; if (sideDoorFromExt) { this.sideDoorLocationId = sideDoorFromExt; } else { // Strategy 2: Look for a door named like "side", "portillon", "pedestrian". const sideDoor = doors.find(door => (door.unique_id !== this.mainDoorLocationId) && /portillon|side|pedestrian|pieton|wicket|back|secondary/i.test(door.name)); if (sideDoor) { this.sideDoorLocationId = sideDoor.unique_id; } else if (doors.length === 2) { // Strategy 3: If we have exactly 2 doors, the other one is the side door. const otherDoor = doors.find(door => door.unique_id !== this.mainDoorLocationId); this.sideDoorLocationId = otherDoor?.unique_id; } } } // Initialize door states from the already-loaded doors data. this.initializeDoorsFromBootstrap(doors); } // Initialize door states from the doors data loaded during API bootstrap. This avoids making additional API calls which may fail. initializeDoorsFromBootstrap(doors) { // Find and initialize main door state. if (this.mainDoorLocationId) { const mainDoor = doors.find(d => d.unique_id === this.mainDoorLocationId); if (mainDoor) { const dpsStatus = mainDoor.door_position_status ?? "close"; const lockStatus = mainDoor.door_lock_relay_status ?? "lock"; // Set DPS state. const newDpsState = dpsStatus === "open" ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED; this.hkDpsState = newDpsState; // Set lock state. const newLockState = lockStatus === "unlock" ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.SECURED; this._hkLockState = newLockState; // Update the door service. if (this.doorServiceType === "GarageDoorOpener") { this.updateDoorServiceState(false); } } } // Find and initialize side door state. if (this.sideDoorLocationId && this.hints.hasSideDoor) { const sideDoor = doors.find(d => d.unique_id === this.sideDoorLocationId); if (sideDoor) { const dpsStatus = sideDoor.door_position_status ?? "close"; const lockStatus = sideDoor.door_lock_relay_status ?? "lock"; // Set DPS state. const newDpsState = dpsStatus === "open" ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED; this._hkSideDoorDpsState = newDpsState; // Set lock state. const newLockState = lockStatus === "unlock" ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.SECURED; this._hkSideDoorLockState = newLockState; } } } // Initialize and configure the light accessory for HomeKit. configureDevice() { this._hkLockState = this.hubLockState; this._hkSideDoorDpsState = this.hubSideDoorDpsState; this._hkSideDoorLockState = this.hubSideDoorLockState; // Clean out the context object in case it's been polluted somehow. this.accessory.context = {}; this.accessory.context.mac = this.uda.mac; this.accessory.context.controller = this.controller.uda.host.mac; if (this.lockDelayInterval === undefined) { this.log.info("The door lock relay will lock five seconds after unlocking in HomeKit."); } else { this.log.info("The door lock relay will remain unlocked %s after unlocking in HomeKit.", this.lockDelayInterval === 0 ? "indefinitely" : "for " + this.lockDelayInterval.toString() + " minutes"); } if (this.hints.hasSideDoor) { if (this.lockDelayInterval === undefined) { this.log.info("The side door lock relay will lock five seconds after unlocking in HomeKit."); } else { this.log.info("The side door lock relay will remain unlocked %s after unlocking in HomeKit.", this.lockDelayInterval === 0 ? "indefinitely" : "for " + this.lockDelayInterval.toString() + " minutes"); } } // Configure accessory information. this.configureInfo(); // Configure access method switches, if we're a reader device. this.configureAccessMethodSwitches(); // Configure the sensors connected to terminal inputs. This must be done before configuring the lock so that the DPS contact sensor exists when configuring a // GarageDoorOpener service, which derives its state from the DPS sensor. this.configureTerminalInputs(); this.configureSideDoorTerminalInputs(); // Configure the lock, if we're a hub device. this.configureLock(); this.configureLockTrigger(); // Configure the side door lock, if we're a UA Gate device. this.configureSideDoorLock(); this.configureSideDoorLockTrigger(); // Configure the doorbell, if we have one. this.configureDoorbell(); this.configureDoorbellTrigger(); // Configure MQTT services. this.configureMqtt(); // Listen for events. this.controller.events.on(this.uda.unique_id, this.listeners[this.uda.unique_id] = this.eventHandler.bind(this)); this.controller.events.on("access.remote_view", this.listeners["access.remote_view"] = this.eventHandler.bind(this)); this.controller.events.on("access.remote_view.change", this.listeners["access.remote_view.change"] = this.eventHandler.bind(this)); // For UA Gate hubs, we discover door IDs and subscribe to their events. This is needed because remote_unlock events use the door's location_id as the // event_object_id, not the hub's device_id. if (this.uda.device_type === "UGT") { this.discoverDoorIds(); // Subscribe to events for both doors. if (this.mainDoorLocationId) { this.controller.events.on(this.mainDoorLocationId, this.listeners[this.mainDoorLocationId] = this.eventHandler.bind(this)); } if (this.sideDoorLocationId) { this.controller.events.on(this.sideDoorLocationId, this.listeners[this.sideDoorLocationId] = this.eventHandler.bind(this)); } } return true; } // Configure the access method switches for HomeKit. configureAccessMethodSwitches() { for (const accessMethod of accessMethods) { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.Switch, this.hasCapability("is_reader") && this.hasCapability(accessMethod.capability) && this.hasFeature(accessMethod.option), accessMethod.subtype)) { continue; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.Switch, this.accessoryName + " " + accessMethod.name, accessMethod.subtype); if (!service) { this.log.error("Unable to add the %s access method switch.", accessMethod.name); continue; } // Retrieve the state when requested. service.getCharacteristic(this.hap.Characteristic.On).onGet(() => Boolean(this.uda.configs?.find(entry => entry.key === accessMethod.key)?.value === "yes")); // Set the state when requested. service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => { const entry = this.uda.configs?.find(entry => entry.key === accessMethod.key); const isConfigsApi = configsApiDeviceTypes.includes(this.uda.device_type); let success; if (entry) { const endpoint = isConfigsApi ? "/configs?is_camera=true" : "/settings"; const keys = (isConfigsApi && ("configsApiKeys" in accessMethod)) ? accessMethod.configsApiKeys : [entry.key]; const payload = keys.map(key => ({ key: key, tag: "open_door_mode", value: value ? "yes" : "no" })); const response = await this.controller.udaApi.retrieve(this.controller.udaApi.getApiEndpoint("device") + "/" + this.uda.unique_id + endpoint, { body: JSON.stringify(payload), method: "PUT" }); success = this.controller.udaApi.responseOk(response?.statusCode); } // If we didn't find the configuration entry or we didn't succeed in setting the value, revert our switch state. if (!success) { this.log.error("Unable to %s the %s access method.", value ? "activate" : "deactivate", accessMethod.name); setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.On, !value), 50); } }); // Initialize the switch. service.updateCharacteristic(this.hap.Characteristic.On, Boolean(this.uda.configs?.find(entry => entry.key === accessMethod.key)?.value === "yes")); } return true; } // Configure the doorbell service for HomeKit. configureDoorbell() { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.Doorbell, this.hasCapability("door_bell") && this.hasFeature("Hub.Doorbell"))) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.Doorbell, this.accessoryName, undefined, () => this.log.info("Enabling the doorbell.")); if (!service) { this.log.error("Unable to add the doorbell."); return false; } service.setPrimaryService(true); return true; } // Configure our contact sensors for HomeKit. Availability is determined by a combination of hub model, what's been configured on the hub, and feature options. configureTerminalInputs() { const terminalInputs = [ { input: "Dps", label: "Door Position Sensor" }, { input: "Rel", label: "Remote Release" }, { input: "Ren", label: "Request to Enter Sensor" }, { input: "Rex", label: "Request to Exit Sensor" } ]; for (const { input, label } of terminalInputs) { const hint = ("hasWiring" + input); const reservedId = AccessReservedNames[("CONTACT_" + input.toUpperCase())]; const state = ("hub" + input + "State"); // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.ContactSensor, (hasService) => { if (!this.hints[hint] && hasService) { this.log.info("Disabling the " + label.toLowerCase() + "."); } return this.hints[hint]; }, reservedId)) { continue; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.ContactSensor, this.accessoryName + " " + label, reservedId, () => this.log.info("Enabling the " + label.toLowerCase() + ".")); if (!service) { this.log.error("Unable to add the " + label.toLowerCase() + "."); continue; } // Initialize the sensor state. service.updateCharacteristic(this.hap.Characteristic.ContactSensorState, this[state]); service.updateCharacteristic(this.hap.Characteristic.StatusActive, !!this.uda.is_online); // If the hub has tamper indicator capabilities, let's reflect that in HomeKit. if (this.hasCapability("tamper_proofing")) { const tamperedEntry = this.uda.configs?.find(entry => entry.key === "tamper_event"); if (tamperedEntry) { service.updateCharacteristic(this.hap.Characteristic.StatusTampered, (tamperedEntry.value === "true") ? this.hap.Characteristic.StatusTampered.TAMPERED : this.hap.Characteristic.StatusTampered.NOT_TAMPERED); } } } return true; } // Configure contact sensors for the side door terminal inputs on UA Gate hubs. The side door has its own dedicated DPS input separate from the main gate's DPS. configureSideDoorTerminalInputs() { // We only configure side door terminal inputs for UA Gate hubs that have the side door enabled. if (!this.hints.hasSideDoor) { return false; } // Validate whether we should have this service enabled. We check the hasWiringSideDoorDps hint which already incorporates the feature option check. if (!validService(this.accessory, this.hap.Service.ContactSensor, (hasService) => { if (!this.hints.hasWiringSideDoorDps && hasService) { this.log.info("Disabling the side door position sensor."); } return this.hints.hasWiringSideDoorDps; }, AccessReservedNames.CONTACT_DPS_SIDE)) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.ContactSensor, this.accessoryName + " Side Door Position Sensor", AccessReservedNames.CONTACT_DPS_SIDE, () => this.log.info("Enabling the side door position sensor.")); if (!service) { this.log.error("Unable to add the side door position sensor."); return false; } // Initialize the sensor state from the current side door DPS state. service.updateCharacteristic(this.hap.Characteristic.ContactSensorState, this._hkSideDoorDpsState); service.updateCharacteristic(this.hap.Characteristic.StatusActive, !!this.uda.is_online); // If the hub has tamper indicator capabilities, reflect that in HomeKit. if (this.hasCapability("tamper_proofing")) { const tamperedEntry = this.uda.configs?.find(entry => entry.key === "tamper_event"); if (tamperedEntry) { service.updateCharacteristic(this.hap.Characteristic.StatusTampered, (tamperedEntry.value === "true") ? this.hap.Characteristic.StatusTampered.TAMPERED : this.hap.Characteristic.StatusTampered.NOT_TAMPERED); } } return true; } // Configure the door for HomeKit. Supports Lock and GarageDoorOpener service types. configureLock() { // First, remove any previous service types that are no longer selected. const serviceTypes = [this.hap.Service.LockMechanism, this.hap.Service.GarageDoorOpener]; const selectedService = this.doorServiceType === "GarageDoorOpener" ? this.hap.Service.GarageDoorOpener : this.hap.Service.LockMechanism; for (const serviceType of serviceTypes) { if (serviceType !== selectedService) { const oldService = this.accessory.getService(serviceType); if (oldService) { this.accessory.removeService(oldService); } } } // Validate whether we should have this service enabled. if (!validService(this.accessory, selectedService, this.hasCapability("is_hub"))) { return false; } // Acquire the service. const service = acquireService(this.accessory, selectedService, this.accessoryName, undefined, () => this.log.info("Configuring door as %s service.", this.doorServiceType)); if (!service) { this.log.error("Unable to add the door."); return false; } // Configure based on service type. if (this.doorServiceType === "GarageDoorOpener") { this.configureGarageDoorService(service, false); } else { this.configureLockService(service, false); } // Initialize the state. this._hkLockState = -1; service.displayName = this.accessoryName; service.updateCharacteristic(this.hap.Characteristic.Name, this.accessoryName); this.hkLockState = this.hubLockState; service.setPrimaryService(true); return true; } // Configure a LockMechanism service. configureLockService(service, isSideDoor) { if (!service) { return; } const lockStateGetter = isSideDoor ? () => this.hkSideDoorLockState : () => this.hkLockState; const lockCommand = isSideDoor ? async (lock) => this.hubSideDoorLockCommand(lock) : async (lock) => this.hubLockCommand(lock); service.getCharacteristic(this.hap.Characteristic.LockCurrentState).onGet(lockStateGetter); service.getCharacteristic(this.hap.Characteristic.LockTargetState).onGet(lockStateGetter); service.getCharacteristic(this.hap.Characteristic.LockTargetState).onSet(async (value) => { // Check if this is just syncing state from an event (current state already matches target). const currentState = lockStateGetter(); const targetLocked = value === this.hap.Characteristic.LockTargetState.SECURED; const currentlyLocked = currentState === this.hap.Characteristic.LockCurrentState.SECURED; // If state already matches, this is just a sync from an event - don't send command. if (targetLocked === currentlyLocked) { return; } if (!(await lockCommand(targetLocked))) { setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.LockTargetState, currentlyLocked ? this.hap.Characteristic.LockTargetState.SECURED : this.hap.Characteristic.LockTargetState.UNSECURED), 50); } service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, lockStateGetter()); }); } // Configure a GarageDoorOpener service. configureGarageDoorService(service, isSideDoor) { if (!service) { return; } // For UA Gate, we use unlock/trigger command for both open and close operations. The gate motor will move in the appropriate direction based on its current state. // For non-UA Gate hubs, we use lock/unlock commands directly (locked = closed, unlocked = open). const isUaGate = this.uda.device_type === "UGT"; // Determine the current door state. For UA Gate, we use DPS (Door Position Sensor) state since it's a motorized gate with physical positions. For non-UA Gate hubs, // we derive state from the lock relay (locked = closed, unlocked = open) since GarageDoorOpener is just a visual convenience for the same underlying lock behavior. const getDoorState = () => { if (isUaGate) { const dpsState = isSideDoor ? this._hkSideDoorDpsState : this.hkDpsState; return dpsState === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED ? this.hap.Characteristic.CurrentDoorState.CLOSED : this.hap.Characteristic.CurrentDoorState.OPEN; } // Non-UA Gate hubs: derive from lock relay state. const lockState = isSideDoor ? this.hkSideDoorLockState : this.hkLockState; return this.isLocked(lockState) ? this.hap.Characteristic.CurrentDoorState.CLOSED : this.hap.Characteristic.CurrentDoorState.OPEN; }; service.getCharacteristic(this.hap.Characteristic.CurrentDoorState).onGet(getDoorState); service.getCharacteristic(this.hap.Characteristic.TargetDoorState).onSet(async (value) => { const shouldClose = value === this.hap.Characteristic.TargetDoorState.CLOSED; // UA Gate uses a single trigger command that toggles the motorized gate. Non-UA Gate hubs use explicit lock/unlock commands. if (isUaGate) { // Set a transition cooldown to prevent WebSocket events from immediately reverting the door state. This gives the gate time to physically move before we accept // DPS updates. if (isSideDoor) { this.sideDoorGateTransitionUntil = Date.now() + GATE_TRANSITION_COOLDOWN_MS; } else { this.gateTransitionUntil = Date.now() + GATE_TRANSITION_COOLDOWN_MS; } // Immediately show transitional state (Opening/Closing) while the door moves. service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, shouldClose ? this.hap.Characteristic.CurrentDoorState.CLOSING : this.hap.Characteristic.CurrentDoorState.OPENING); // Trigger the gate - for motorized gates, the same trigger command handles both open and close. const triggerGate = isSideDoor ? async () => this.hubSideDoorLockCommand(false) : async () => this.hubLockCommand(false); if (!(await triggerGate())) { // Clear the transition cooldown on failure. if (isSideDoor) { this.sideDoorGateTransitionUntil = 0; } else { this.gateTransitionUntil = 0; } // Revert target state on failure. setTimeout(() => { service.updateCharacteristic(this.hap.Characteristic.TargetDoorState, shouldClose ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED); service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, getDoorState()); }, 50); } // The DPS sensor event will update the CurrentDoorState when the gate finishes moving. return; } // Non-UA Gate hubs: use lock/unlock commands directly (close = lock, open = unlock). The lock state change will drive the door state update via hkLockState setter. const lockCommand = isSideDoor ? async (lock) => this.hubSideDoorLockCommand(lock) : async (lock) => this.hubLockCommand(lock); if (!(await lockCommand(shouldClose))) { // Revert target state on failure. setTimeout(() => { service.updateCharacteristic(this.hap.Characteristic.TargetDoorState, shouldClose ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED); service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, getDoorState()); }, 50); } }); // ObstructionDetected is required - we always report no obstruction. service.getCharacteristic(this.hap.Characteristic.ObstructionDetected).onGet(() => false); } // Configure a switch to manually trigger a doorbell ring event for HomeKit. configureDoorbellTrigger() { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.Switch, this.hasCapability("door_bell") && this.hasFeature("Hub.Doorbell.Trigger"), AccessReservedNames.SWITCH_DOORBELL_TRIGGER)) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.Switch, this.accessoryName + " Doorbell Trigger", AccessReservedNames.SWITCH_DOORBELL_TRIGGER, () => this.log.info("Enabling the doorbell automation trigger.")); if (!service) { this.log.error("Unable to add the doorbell automation trigger."); return false; } // Trigger the doorbell. service.getCharacteristic(this.hap.Characteristic.On).onGet(() => this.doorbellRingRequestId !== null); // The state isn't really user-triggerable. We have no way, currently, to trigger a ring event on the hub. service.getCharacteristic(this.hap.Characteristic.On).onSet(() => { setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.On, this.doorbellRingRequestId !== null), 50); }); // Initialize the switch. service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, this.accessoryName + " Doorbell Trigger"); service.updateCharacteristic(this.hap.Characteristic.On, false); return true; } // Configure a switch to automate lock and unlock events in HomeKit beyond what HomeKit might allow for a lock service that gets treated as a secure service. configureLockTrigger() { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.Switch, this.hasCapability("is_hub") && this.hasFeature("Hub.Lock.Trigger"), AccessReservedNames.SWITCH_LOCK_TRIGGER)) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.Switch, this.accessoryName + " Lock Trigger", AccessReservedNames.SWITCH_LOCK_TRIGGER, () => this.log.info("Enabling the lock automation trigger.")); if (!service) { this.log.error("Unable to add the lock automation trigger."); return false; } // Trigger the doorbell. service.getCharacteristic(this.hap.Characteristic.On).onGet(() => this.hkLockState !== this.hap.Characteristic.LockCurrentState.SECURED); // The state isn't really user-triggerable. We have no way, currently, to trigger a lock or unlock event on the hub. service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => { // If we are on, we are in an unlocked state. If we are off, we are in a locked state. if (!(await this.hubLockCommand(!value))) { // Revert our state. setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.On, !value), 50); } }); // Initialize the switch. service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, this.accessoryName + " Lock Trigger"); service.updateCharacteristic(this.hap.Characteristic.On, false); return true; } // Configure the side door for HomeKit (UA Gate only) - always uses Lock service. configureSideDoorLock() { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.LockMechanism, this.hints.hasSideDoor, AccessReservedNames.LOCK_DOOR_SIDE)) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.LockMechanism, this.accessoryName + " Side Door", AccessReservedNames.LOCK_DOOR_SIDE, () => this.log.info("Configuring side door lock.")); if (!service) { this.log.error("Unable to add the side door."); return false; } // Configure the lock service. this.configureLockService(service, true); // Initialize the lock. this._hkSideDoorLockState = -1; service.displayName = this.accessoryName + " Side Door"; service.updateCharacteristic(this.hap.Characteristic.Name, this.accessoryName + " Side Door"); this.hkSideDoorLockState = this.hubSideDoorLockState; return true; } // Configure a switch to automate side door lock and unlock events in HomeKit beyond what HomeKit might allow for a lock service that gets treated as a secure service. configureSideDoorLockTrigger() { // Validate whether we should have this service enabled. if (!validService(this.accessory, this.hap.Service.Switch, this.hints.hasSideDoor && this.hasFeature("Hub.SideDoor.Lock.Trigger"), AccessReservedNames.SWITCH_LOCK_DOOR_SIDE_TRIGGER)) { return false; } // Acquire the service. const service = acquireService(this.accessory, this.hap.Service.Switch, this.accessoryName + " Side Door Lock Trigger", AccessReservedNames.SWITCH_LOCK_DOOR_SIDE_TRIGGER, () => this.log.info("Enabling the side door lock automation trigger.")); if (!service) { this.log.error("Unable to add the side door lock automation trigger."); return false; } // Trigger the lock state. service.getCharacteristic(this.hap.Characteristic.On).onGet(() => this.hkSideDoorLockState !== this.hap.Characteristic.LockCurrentState.SECURED); // The state isn't really user-triggerable. We have no way, currently, to trigger a lock or unlock event on the hub. service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => { // If we are on, we are in an unlocked state. If we are off, we are in a locked state. if (!(await this.hubSideDoorLockCommand(!value))) { // Revert our state. setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.On, !value), 50); } }); // Initialize the switch. service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, this.accessoryName + " Side Door Lock Trigger"); service.updateCharacteristic(this.hap.Characteristic.On, false); return true; } // Configure MQTT capabilities of this light. configureMqtt() { const lockService = this.accessory.getService(this.hap.Service.LockMechanism); if (!lockService) { return false; } // MQTT doorbell status. this.controller.mqtt?.subscribeGet(this.id, "doorbell", "Doorbell ring", () => { return this.doorbellRingRequestId !== null ? "true" : "false"; }); // MQTT DPS status. this.controller.mqtt?.subscribeGet(this.id, "dps", "Door position sensor", () => { if (!this.isDpsWired) { return "unknown"; } switch (this.hkDpsState) { case this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED: return "false"; case this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED: return "true"; default: return "unknown"; } }); // MQTT lock status. this.controller.mqtt?.subscribeGet(this.id, "lock", "Lock", () => { switch (this.hkLockState) { case this.hap.Characteristic.LockCurrentState.SECURED: return "true"; case this.hap.Characteristic.LockCurrentState.UNSECURED: return "false"; default: return "unknown"; } }); // MQTT lock status. this.controller.mqtt?.subscribeSet(this.id, "lock", "Lock", (value) => { switch (value) { case "true": void this.controller.udaApi.unlock(this.uda, 0); break; case "false": void this.controller.udaApi.unlock(this.uda, Infinity); break; default: this.log.error("MQTT: Unknown lock set message received: %s.", value); break; } }); // MQTT side door lock status (UA Gate only). if (this.hints.hasSideDoor) { this.controller.mqtt?.subscribeGet(this.id, "sidedoor/lock", "Side Door Lock", () => { switch (this.hkSideDoorLockState) { case this.hap.Characteristic.LockCurrentState.SECURED: return "true"; case this.hap.Characteristic.LockCurrentState.UNSECURED: return "false"; default: return "unknown"; } }); this.controller.mqtt?.subscribeSet(this.id, "sidedoor/lock", "Side Door Lock", (value) => { switch (value) { case "true": void this.hubSideDoorLockCommand(true); break; case "false": void this.hubSideDoorLockCommand(false); break; default: this.log.error("MQTT: Unknown side door lock set message received: %s.", value); break; } }); // MQTT side door DPS status (UA Gate only). this.controller.mqtt?.subscribeGet(this.id, "sidedoor/dps", "Side door position sensor", () => { if (!this.isSideDoorDpsWired) { return "unknown"; } switch (this._hkSideDoorDpsState) { case this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED: return "false"; case this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED: return "true"; default: return "unknown"; } }); } return true; } // Check and validate Ultra inputs with what the user has configured in HomeKit. checkUltraInputs() { for (const input of ["Dps", "Rex"]) { const hint = ("hasWiring" + input); const mode = input.toLowerCase(); // Is the mode enabled on the hub? const isEnabled = this.uda.extensions?.[0]?.target_config?.some(entry => (entry.config_key === "rex_button_mode") && (entry.config_value === mode)); if (this.hints[hint] && !isEnabled) { // The hub has disabled this input. this.hints[hint] = false; } else if (!this.hints[hint] && isEnabled && this.hasFeature("Hub." + input.toUpperCase())) { // The hub has the input enabled, and we want it enabled in HomeKit. this.hints[hint] = true; } } } // Unified utility function to execute lock and unlock actions on a hub door. async hubDoorLockCommand(isLocking, isSideDoor = false) { const action = isLocking ? "lock" : "unlock"; const doorName = isSideDoor ? "side door" : (this.uda.device_type === "UGT" ? "gate" : "door"); const doorId = isSideDoor ? this.sideDoorLocationId : this.mainDoorLocationId; // Only allow relocking if we are able to do so. UA Gate is exempt since it's a motorized gate that needs to close. For non-UA Gate hubs, the same restriction // applies to both Lock and GarageDoorOpener service types since GarageDoorOpener is just a visual convenience for the same underlying lock behavior. if ((this.lockDelayInterval === undefined) && isLocking && (this.uda.device_type !== "UGT")) { this.log.error("Unable to manually relock the %s when the lock relay is configured to the default settings.", doorName); return false; } // If we're not online, we're done. if (!this.isOnline) { this.log.error("Unable to %s the %s. Device is offline.", action, doorName); return false; } // For UA Gate hubs, use the location-based unlock API since the device API is not supported. if (this.uda.device_type === "UGT") { if (!doorId) { this.log.error("Unable to %s the %s. Door not found.", action, isSideDoor ? "side door" : "gate"); return false; } // Execute the action using the location endpoint. const endpoint = this.controller.udaApi.getApiEndpoint("location") + "/" + doorId + "/unlock"; const response = await this.controller.udaApi.retrieve(endpoint, { body: JSON.stringify({}), method: "PUT" }); if (!this.controller.udaApi.responseOk(response?.statusCode)) { this.log.error("Unable to %s the %s.", action, doorName); return false; } // When unlocking from HomeKit, the controller doesn't send the events to the events API. Manually update the state and schedule the auto-lock. if (!isLocking) { if (isSideDoor) { this.hkSideDoorLockState = this.hap.Characteristic.LockCurrentState.UNSECURED; if (this.hints.logLock) { this.log.info("Side door unlocked."); } setTimeout(() => { this.hkSideDoorLockState = this.hap.Characteristic.LockCurrentState.SECURED; if (this.hints.logLock) { this.log.info("Side door locked."); } }, AUTO_LOCK_DELAY_MS); } else { this.hkLockState = this.hap.Characteristic.LockCurrentState.UNSECURED; setTimeout(() => this.hkLockState = this.hap.Characteristic.LockCurrentState.SECURED, AUTO_LOCK_DELAY_MS); } } return true; } // For hub types other than UA Gate, we use the standard device unlock API. GarageDoorOpener uses the same lock delay interval as Lock service since it's just a // visual convenience for the same underlying lock behavior. const delayInterval = this.lockDelayInterval; // Execute the action. if (!(await this.controller.udaApi.unlock(this.uda, (delayInterval === undefined) ? undefined : (isLocking ? 0 : Infinity)))) { this.log.error("Unable to %s.", action); return false; } return true; } // Wrapper for door lock command. async hubLockCommand(isLocking) { return this.hubDoorLockCommand(isLocking); } // Wrapper for side door lock command (for backwards compatibility). async hubSideDoorLockCommand(isLocking) { return this.hubDoorLockCommand(isLocking, true); } // Return the current HomeKit DPS state that we are tracking for this hub. We read from the contact sensor service if it exists, otherwise we fall back to the // backing variable. This allows GarageDoorOpener to function correctly even when the DPS contact sensor is disabled. get hkDpsState() { const service = this.accessory.getServiceById(this.hap.Service.ContactSensor, AccessReservedNames.CONTACT_DPS); if (service) { return service.getCharacteristic(this.hap.Characteristic.ContactSensorState).value ?? this._hkDpsState; } return this._hkDpsState; } // Set the current HomeKit DPS state for this hub. We always update the backing variable and also update the contact sensor service if it exists. set hkDpsState(value) { this._hkDpsState = value; this.setContactSensorState(AccessReservedNames.CONTACT_DPS, value); } // Return the current HomeKit lock state that we are tracking for this hub. get hkLockState() { return this._hkLockState; } // Set the current HomeKit lock state for this hub. set hkLockState(value) { // If nothing is changed, we're done. if (this.hkLockState === value) { return; } // Update the lock state. this._hkLockState = value; // For Lock service type, update the service. For GarageDoorOpener on non-UA Gate hubs, also update the service since door state is derived from lock state. For UA // Gate with GarageDoorOpener, DPS events handle updates since it's a motorized gate with physical positions. if ((this.doorServiceType === "Lock") || (this.uda.device_type !== "UGT")) { this.updateDoorServiceState(false); } else { // UA Gate with GarageDoorOpener: only update the lock trigger switch if enabled. this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_LOCK_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, value !== this.hap.Characteristic.LockCurrentState.SECURED); } } // Update door service state based on configured service type. updateDoorServiceState(isSideDoor) { const serviceType = isSideDoor ? "Lock" : this.doorServiceType; const lockState = isSideDoor ? this.hkSideDoorLockState : this.hkLockState; const triggerSubtype = isSideDoor ? AccessReservedNames.SWITCH_LOCK_DOOR_SIDE_TRIGGER : AccessReservedNames.SWITCH_LOCK_TRIGGER; // Check if we're in a transition cooldown period - skip updates to preserve the Opening/Closing state. const tra