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
JavaScript
/* 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