homebridge-unifi-access 1.10.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- /* Copyright(C) 2019-2025, HJD (https://github.com/hjdhjd). All rights reserved.
1
+ /* Copyright(C) 2019-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
2
  *
3
3
  * access-hub.ts: Unified hub and reader device class for UniFi Access.
4
4
  */
@@ -9,11 +9,15 @@ import { AccessReservedNames } from "./access-types.js";
9
9
  const accessMethods = [
10
10
  { capability: "identity_face_unlock", key: "face", name: "Face Unlock", option: "AccessMethod.Face", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_FACE },
11
11
  { capability: "hand_wave", key: "wave", name: "Hand Wave", option: "AccessMethod.Hand", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_HAND },
12
- { capability: "mobile_unlock_ver2", key: "bt_button", name: "Mobile", option: "AccessMethod.Mobile", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_MOBILE },
12
+ { capability: ["mobile_unlock_ver2", "support_mobile_unlock"], configsApiKeys: ["bt", "bt_button", "bt_tap"], key: "bt_button",
13
+ name: "Mobile", option: "AccessMethod.Mobile", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_MOBILE },
13
14
  { capability: "nfc_card_easy_provision", key: "nfc", name: "NFC", option: "AccessMethod.NFC", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_NFC },
14
15
  { capability: "pin_code", key: "pin_code", name: "PIN", option: "AccessMethod.PIN", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_PIN },
15
- { capability: "qr_code", key: "qr_code", name: "QR Code", option: "AccessMethod.QR", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_QR }
16
+ { capability: "qr_code", key: "qr_code", name: "QR Code", option: "AccessMethod.QR", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_QR },
17
+ { capability: "support_apple_pass", key: "apple_pass", name: "TouchPass", option: "AccessMethod.TouchPass", subtype: AccessReservedNames.SWITCH_ACCESSMETHOD_TOUCHPASS }
16
18
  ];
19
+ // Device types that use the /configs API endpoint instead of /settings for access method changes.
20
+ const configsApiDeviceTypes = ["UVC G6 Entry"];
17
21
  // Define the dry contact inputs we're interested in for Access hubs.
18
22
  const sensorInputs = ["Dps", "Rel", "Ren", "Rex"];
19
23
  // 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:
@@ -46,17 +50,34 @@ const sensorWiring = {
46
50
  }
47
51
  }
48
52
  };
53
+ // Constants for timing.
54
+ const AUTO_LOCK_DELAY_MS = 5000;
55
+ const GATE_TRANSITION_COOLDOWN_MS = 5000;
49
56
  export class AccessHub extends AccessDevice {
57
+ _hkDpsState;
50
58
  _hkLockState;
59
+ _hkSideDoorDpsState;
60
+ _hkSideDoorLockState;
51
61
  doorbellRingRequestId;
62
+ gateTransitionUntil;
52
63
  lockDelayInterval;
64
+ mainDoorLocationId;
65
+ sideDoorLocationId;
66
+ sideDoorGateTransitionUntil;
53
67
  uda;
54
68
  // Create an instance.
55
69
  constructor(controller, device, accessory) {
56
70
  super(controller, accessory);
57
71
  this.uda = device;
72
+ this._hkDpsState = this.hubDpsState;
58
73
  this._hkLockState = this.hubLockState;
74
+ this._hkSideDoorDpsState = this.hubSideDoorDpsState;
75
+ this._hkSideDoorLockState = this.hubSideDoorLockState;
76
+ this.gateTransitionUntil = 0;
59
77
  this.lockDelayInterval = this.getFeatureNumber("Hub.LockDelayInterval") ?? undefined;
78
+ this.mainDoorLocationId = undefined;
79
+ this.sideDoorLocationId = undefined;
80
+ this.sideDoorGateTransitionUntil = 0;
60
81
  this.doorbellRingRequestId = null;
61
82
  // If we attempt to set the delay interval to something invalid, then assume we are using the default unlock behavior.
62
83
  if ((this.lockDelayInterval !== undefined) && (this.lockDelayInterval < 0)) {
@@ -69,10 +90,12 @@ export class AccessHub extends AccessDevice {
69
90
  configureHints() {
70
91
  // Configure our parent's hints.
71
92
  super.configureHints();
72
- this.hints.hasWiringDps = ["UA Ultra", "UA Hub", "UA Hub Door Mini"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.DPS");
93
+ this.hints.hasSideDoor = (this.uda.device_type === "UGT") && this.hasFeature("Hub.SideDoor");
94
+ this.hints.hasWiringDps = ["UA Ultra", "UA Hub", "UA Hub Door Mini", "UA Gate"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.DPS");
73
95
  this.hints.hasWiringRel = ["UA Hub"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.REL");
74
96
  this.hints.hasWiringRen = ["UA Hub"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.REN");
75
97
  this.hints.hasWiringRex = ["UA Ultra", "UA Hub", "UA Hub Door Mini"].includes(this.uda.display_model ?? "") && this.hasFeature("Hub.REX");
98
+ this.hints.hasWiringSideDoorDps = this.hints.hasSideDoor && this.hasFeature("Hub.SideDoor.DPS");
76
99
  this.hints.logDoorbell = this.hasFeature("Log.Doorbell");
77
100
  this.hints.logDps = this.hasFeature("Log.DPS");
78
101
  this.hints.logLock = this.hasFeature("Log.Lock");
@@ -86,9 +109,115 @@ export class AccessHub extends AccessDevice {
86
109
  }
87
110
  return true;
88
111
  }
112
+ // 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
113
+ // overridden to GarageDoorOpener.
114
+ get doorServiceType() {
115
+ if (this.uda.device_type === "UGT") {
116
+ return this.hasFeature("Hub.Door.UseLock") ? "Lock" : "GarageDoorOpener";
117
+ }
118
+ return this.hasFeature("Hub.Door.UseGarageOpener") ? "GarageDoorOpener" : "Lock";
119
+ }
120
+ // Convert lock string value to HomeKit LockCurrentState.
121
+ toLockState(lockValue) {
122
+ return ["unlock", "unlocked"].includes(lockValue) ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.SECURED;
123
+ }
124
+ // Convert DPS string value to HomeKit ContactSensorState.
125
+ toDpsState(dpsValue) {
126
+ return dpsValue === "open" ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED;
127
+ }
128
+ // Check if a lock state represents "locked".
129
+ isLocked(state) {
130
+ return state === this.hap.Characteristic.LockCurrentState.SECURED;
131
+ }
132
+ // Check if a DPS state represents "closed" (contact detected).
133
+ isClosed(state) {
134
+ return state === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED;
135
+ }
136
+ // Discover main and side door location IDs for UA Gate hubs. This allows us to receive remote_unlock events for each door.
137
+ discoverDoorIds() {
138
+ const doors = this.controller.udaApi.doors ?? [];
139
+ if (doors.length === 0) {
140
+ this.log.warn("No doors found in Access API. Door event handling may not work correctly.");
141
+ return;
142
+ }
143
+ // Get the primary door ID from device config (may be undefined).
144
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
145
+ const primaryDoorId = this.uda.door?.unique_id;
146
+ // Strategy 1: Use the device's bound door as main door.
147
+ if (primaryDoorId) {
148
+ this.mainDoorLocationId = primaryDoorId;
149
+ }
150
+ else if (doors.length >= 1) {
151
+ // Strategy 2: Look for a door named like "main", "gate", "portail" (but not side/pedestrian).
152
+ 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));
153
+ // Strategy 3: Use the first door as main door.
154
+ this.mainDoorLocationId = mainDoor?.unique_id ?? doors[0].unique_id;
155
+ }
156
+ // Find the side door (if enabled).
157
+ if (this.hints.hasSideDoor) {
158
+ // Strategy 1: Check extensions for oper2 port setting.
159
+ const sideDoorFromExt = this.uda.extensions?.find(ext => (ext.extension_name === "port_setting") && (ext.target_name === "oper2"))?.target_value;
160
+ if (sideDoorFromExt) {
161
+ this.sideDoorLocationId = sideDoorFromExt;
162
+ }
163
+ else {
164
+ // Strategy 2: Look for a door named like "side", "portillon", "pedestrian".
165
+ const sideDoor = doors.find(door => (door.unique_id !== this.mainDoorLocationId) && /portillon|side|pedestrian|pieton|wicket|back|secondary/i.test(door.name));
166
+ if (sideDoor) {
167
+ this.sideDoorLocationId = sideDoor.unique_id;
168
+ }
169
+ else if (doors.length === 2) {
170
+ // Strategy 3: If we have exactly 2 doors, the other one is the side door.
171
+ const otherDoor = doors.find(door => door.unique_id !== this.mainDoorLocationId);
172
+ this.sideDoorLocationId = otherDoor?.unique_id;
173
+ }
174
+ }
175
+ }
176
+ // Initialize door states from the already-loaded doors data.
177
+ this.initializeDoorsFromBootstrap(doors);
178
+ }
179
+ // Initialize door states from the doors data loaded during API bootstrap. This avoids making additional API calls which may fail.
180
+ initializeDoorsFromBootstrap(doors) {
181
+ // Find and initialize main door state.
182
+ if (this.mainDoorLocationId) {
183
+ const mainDoor = doors.find(d => d.unique_id === this.mainDoorLocationId);
184
+ if (mainDoor) {
185
+ const dpsStatus = mainDoor.door_position_status ?? "close";
186
+ const lockStatus = mainDoor.door_lock_relay_status ?? "lock";
187
+ // Set DPS state.
188
+ const newDpsState = dpsStatus === "open" ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED :
189
+ this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED;
190
+ this.hkDpsState = newDpsState;
191
+ // Set lock state.
192
+ const newLockState = lockStatus === "unlock" ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.SECURED;
193
+ this._hkLockState = newLockState;
194
+ // Update the door service.
195
+ if (this.doorServiceType === "GarageDoorOpener") {
196
+ this.updateDoorServiceState(false);
197
+ }
198
+ }
199
+ }
200
+ // Find and initialize side door state.
201
+ if (this.sideDoorLocationId && this.hints.hasSideDoor) {
202
+ const sideDoor = doors.find(d => d.unique_id === this.sideDoorLocationId);
203
+ if (sideDoor) {
204
+ const dpsStatus = sideDoor.door_position_status ?? "close";
205
+ const lockStatus = sideDoor.door_lock_relay_status ?? "lock";
206
+ // Set DPS state.
207
+ const newDpsState = dpsStatus === "open" ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED :
208
+ this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED;
209
+ this._hkSideDoorDpsState = newDpsState;
210
+ // Set lock state.
211
+ const newLockState = lockStatus === "unlock" ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.SECURED;
212
+ this._hkSideDoorLockState = newLockState;
213
+ }
214
+ }
215
+ }
89
216
  // Initialize and configure the light accessory for HomeKit.
90
217
  configureDevice() {
91
218
  this._hkLockState = this.hubLockState;
219
+ this._hkSideDoorDpsState = this.hubSideDoorDpsState;
220
+ this._hkSideDoorLockState = this.hubSideDoorLockState;
92
221
  // Clean out the context object in case it's been polluted somehow.
93
222
  this.accessory.context = {};
94
223
  this.accessory.context.mac = this.uda.mac;
@@ -99,24 +228,49 @@ export class AccessHub extends AccessDevice {
99
228
  else {
100
229
  this.log.info("The door lock relay will remain unlocked %s after unlocking in HomeKit.", this.lockDelayInterval === 0 ? "indefinitely" : "for " + this.lockDelayInterval.toString() + " minutes");
101
230
  }
231
+ if (this.hints.hasSideDoor) {
232
+ if (this.lockDelayInterval === undefined) {
233
+ this.log.info("The side door lock relay will lock five seconds after unlocking in HomeKit.");
234
+ }
235
+ else {
236
+ 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");
237
+ }
238
+ }
102
239
  // Configure accessory information.
103
240
  this.configureInfo();
104
241
  // Configure access method switches, if we're a reader device.
105
242
  this.configureAccessMethodSwitches();
243
+ // 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
244
+ // GarageDoorOpener service, which derives its state from the DPS sensor.
245
+ this.configureTerminalInputs();
246
+ this.configureSideDoorTerminalInputs();
106
247
  // Configure the lock, if we're a hub device.
107
248
  this.configureLock();
108
249
  this.configureLockTrigger();
250
+ // Configure the side door lock, if we're a UA Gate device.
251
+ this.configureSideDoorLock();
252
+ this.configureSideDoorLockTrigger();
109
253
  // Configure the doorbell, if we have one.
110
254
  this.configureDoorbell();
111
255
  this.configureDoorbellTrigger();
112
- // Configure the sensors connected to terminal inputs.
113
- this.configureTerminalInputs();
114
256
  // Configure MQTT services.
115
257
  this.configureMqtt();
116
258
  // Listen for events.
117
259
  this.controller.events.on(this.uda.unique_id, this.listeners[this.uda.unique_id] = this.eventHandler.bind(this));
118
260
  this.controller.events.on("access.remote_view", this.listeners["access.remote_view"] = this.eventHandler.bind(this));
119
261
  this.controller.events.on("access.remote_view.change", this.listeners["access.remote_view.change"] = this.eventHandler.bind(this));
262
+ // 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
263
+ // event_object_id, not the hub's device_id.
264
+ if (this.uda.device_type === "UGT") {
265
+ this.discoverDoorIds();
266
+ // Subscribe to events for both doors.
267
+ if (this.mainDoorLocationId) {
268
+ this.controller.events.on(this.mainDoorLocationId, this.listeners[this.mainDoorLocationId] = this.eventHandler.bind(this));
269
+ }
270
+ if (this.sideDoorLocationId) {
271
+ this.controller.events.on(this.sideDoorLocationId, this.listeners[this.sideDoorLocationId] = this.eventHandler.bind(this));
272
+ }
273
+ }
120
274
  return true;
121
275
  }
122
276
  // Configure the access method switches for HomeKit.
@@ -137,10 +291,14 @@ export class AccessHub extends AccessDevice {
137
291
  // Set the state when requested.
138
292
  service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => {
139
293
  const entry = this.uda.configs?.find(entry => entry.key === accessMethod.key);
294
+ const isConfigsApi = configsApiDeviceTypes.includes(this.uda.device_type);
140
295
  let success;
141
296
  if (entry) {
142
- const response = await this.controller.udaApi.retrieve(this.controller.udaApi.getApiEndpoint("device") + "/" + this.id + "/settings", {
143
- body: JSON.stringify([{ key: entry.key, tag: "open_door_mode", value: value ? "yes" : "no" }]),
297
+ const endpoint = isConfigsApi ? "/configs?is_camera=true" : "/settings";
298
+ const keys = (isConfigsApi && ("configsApiKeys" in accessMethod)) ? accessMethod.configsApiKeys : [entry.key];
299
+ const payload = keys.map(key => ({ key: key, tag: "open_door_mode", value: value ? "yes" : "no" }));
300
+ const response = await this.controller.udaApi.retrieve(this.controller.udaApi.getApiEndpoint("device") + "/" + this.uda.unique_id + endpoint, {
301
+ body: JSON.stringify(payload),
144
302
  method: "PUT"
145
303
  });
146
304
  success = this.controller.udaApi.responseOk(response?.statusCode);
@@ -212,28 +370,71 @@ export class AccessHub extends AccessDevice {
212
370
  }
213
371
  return true;
214
372
  }
215
- // Configure the lock for HomeKit.
373
+ // 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.
374
+ configureSideDoorTerminalInputs() {
375
+ // We only configure side door terminal inputs for UA Gate hubs that have the side door enabled.
376
+ if (!this.hints.hasSideDoor) {
377
+ return false;
378
+ }
379
+ // Validate whether we should have this service enabled. We check the hasWiringSideDoorDps hint which already incorporates the feature option check.
380
+ if (!validService(this.accessory, this.hap.Service.ContactSensor, (hasService) => {
381
+ if (!this.hints.hasWiringSideDoorDps && hasService) {
382
+ this.log.info("Disabling the side door position sensor.");
383
+ }
384
+ return this.hints.hasWiringSideDoorDps;
385
+ }, AccessReservedNames.CONTACT_DPS_SIDE)) {
386
+ return false;
387
+ }
388
+ // Acquire the service.
389
+ 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."));
390
+ if (!service) {
391
+ this.log.error("Unable to add the side door position sensor.");
392
+ return false;
393
+ }
394
+ // Initialize the sensor state from the current side door DPS state.
395
+ service.updateCharacteristic(this.hap.Characteristic.ContactSensorState, this._hkSideDoorDpsState);
396
+ service.updateCharacteristic(this.hap.Characteristic.StatusActive, !!this.uda.is_online);
397
+ // If the hub has tamper indicator capabilities, reflect that in HomeKit.
398
+ if (this.hasCapability("tamper_proofing")) {
399
+ const tamperedEntry = this.uda.configs?.find(entry => entry.key === "tamper_event");
400
+ if (tamperedEntry) {
401
+ service.updateCharacteristic(this.hap.Characteristic.StatusTampered, (tamperedEntry.value === "true") ? this.hap.Characteristic.StatusTampered.TAMPERED :
402
+ this.hap.Characteristic.StatusTampered.NOT_TAMPERED);
403
+ }
404
+ }
405
+ return true;
406
+ }
407
+ // Configure the door for HomeKit. Supports Lock and GarageDoorOpener service types.
216
408
  configureLock() {
409
+ // First, remove any previous service types that are no longer selected.
410
+ const serviceTypes = [this.hap.Service.LockMechanism, this.hap.Service.GarageDoorOpener];
411
+ const selectedService = this.doorServiceType === "GarageDoorOpener" ? this.hap.Service.GarageDoorOpener : this.hap.Service.LockMechanism;
412
+ for (const serviceType of serviceTypes) {
413
+ if (serviceType !== selectedService) {
414
+ const oldService = this.accessory.getService(serviceType);
415
+ if (oldService) {
416
+ this.accessory.removeService(oldService);
417
+ }
418
+ }
419
+ }
217
420
  // Validate whether we should have this service enabled.
218
- if (!validService(this.accessory, this.hap.Service.LockMechanism, this.hasCapability("is_hub"))) {
421
+ if (!validService(this.accessory, selectedService, this.hasCapability("is_hub"))) {
219
422
  return false;
220
423
  }
221
424
  // Acquire the service.
222
- const service = acquireService(this.accessory, this.hap.Service.LockMechanism, this.accessoryName);
425
+ const service = acquireService(this.accessory, selectedService, this.accessoryName, undefined, () => this.log.info("Configuring door as %s service.", this.doorServiceType));
223
426
  if (!service) {
224
- this.log.error("Unable to add the lock.");
427
+ this.log.error("Unable to add the door.");
225
428
  return false;
226
429
  }
227
- // Return the lock state.
228
- service.getCharacteristic(this.hap.Characteristic.LockCurrentState).onGet(() => this.hkLockState);
229
- service.getCharacteristic(this.hap.Characteristic.LockTargetState).onSet(async (value) => {
230
- if (!(await this.hubLockCommand(value === this.hap.Characteristic.LockTargetState.SECURED))) {
231
- // Revert our target state.
232
- setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.LockTargetState, !value), 50);
233
- }
234
- service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.hkLockState);
235
- });
236
- // Initialize the lock.
430
+ // Configure based on service type.
431
+ if (this.doorServiceType === "GarageDoorOpener") {
432
+ this.configureGarageDoorService(service, false);
433
+ }
434
+ else {
435
+ this.configureLockService(service, false);
436
+ }
437
+ // Initialize the state.
237
438
  this._hkLockState = -1;
238
439
  service.displayName = this.accessoryName;
239
440
  service.updateCharacteristic(this.hap.Characteristic.Name, this.accessoryName);
@@ -241,6 +442,100 @@ export class AccessHub extends AccessDevice {
241
442
  service.setPrimaryService(true);
242
443
  return true;
243
444
  }
445
+ // Configure a LockMechanism service.
446
+ configureLockService(service, isSideDoor) {
447
+ if (!service) {
448
+ return;
449
+ }
450
+ const lockStateGetter = isSideDoor ? () => this.hkSideDoorLockState : () => this.hkLockState;
451
+ const lockCommand = isSideDoor ? async (lock) => this.hubSideDoorLockCommand(lock) :
452
+ async (lock) => this.hubLockCommand(lock);
453
+ service.getCharacteristic(this.hap.Characteristic.LockCurrentState).onGet(lockStateGetter);
454
+ service.getCharacteristic(this.hap.Characteristic.LockTargetState).onGet(lockStateGetter);
455
+ service.getCharacteristic(this.hap.Characteristic.LockTargetState).onSet(async (value) => {
456
+ // Check if this is just syncing state from an event (current state already matches target).
457
+ const currentState = lockStateGetter();
458
+ const targetLocked = value === this.hap.Characteristic.LockTargetState.SECURED;
459
+ const currentlyLocked = currentState === this.hap.Characteristic.LockCurrentState.SECURED;
460
+ // If state already matches, this is just a sync from an event - don't send command.
461
+ if (targetLocked === currentlyLocked) {
462
+ return;
463
+ }
464
+ if (!(await lockCommand(targetLocked))) {
465
+ setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.LockTargetState, currentlyLocked ? this.hap.Characteristic.LockTargetState.SECURED : this.hap.Characteristic.LockTargetState.UNSECURED), 50);
466
+ }
467
+ service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, lockStateGetter());
468
+ });
469
+ }
470
+ // Configure a GarageDoorOpener service.
471
+ configureGarageDoorService(service, isSideDoor) {
472
+ if (!service) {
473
+ return;
474
+ }
475
+ // 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.
476
+ // For non-UA Gate hubs, we use lock/unlock commands directly (locked = closed, unlocked = open).
477
+ const isUaGate = this.uda.device_type === "UGT";
478
+ // 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,
479
+ // we derive state from the lock relay (locked = closed, unlocked = open) since GarageDoorOpener is just a visual convenience for the same underlying lock behavior.
480
+ const getDoorState = () => {
481
+ if (isUaGate) {
482
+ const dpsState = isSideDoor ? this._hkSideDoorDpsState : this.hkDpsState;
483
+ return dpsState === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED ? this.hap.Characteristic.CurrentDoorState.CLOSED :
484
+ this.hap.Characteristic.CurrentDoorState.OPEN;
485
+ }
486
+ // Non-UA Gate hubs: derive from lock relay state.
487
+ const lockState = isSideDoor ? this.hkSideDoorLockState : this.hkLockState;
488
+ return this.isLocked(lockState) ? this.hap.Characteristic.CurrentDoorState.CLOSED : this.hap.Characteristic.CurrentDoorState.OPEN;
489
+ };
490
+ service.getCharacteristic(this.hap.Characteristic.CurrentDoorState).onGet(getDoorState);
491
+ service.getCharacteristic(this.hap.Characteristic.TargetDoorState).onSet(async (value) => {
492
+ const shouldClose = value === this.hap.Characteristic.TargetDoorState.CLOSED;
493
+ // UA Gate uses a single trigger command that toggles the motorized gate. Non-UA Gate hubs use explicit lock/unlock commands.
494
+ if (isUaGate) {
495
+ // 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
496
+ // DPS updates.
497
+ if (isSideDoor) {
498
+ this.sideDoorGateTransitionUntil = Date.now() + GATE_TRANSITION_COOLDOWN_MS;
499
+ }
500
+ else {
501
+ this.gateTransitionUntil = Date.now() + GATE_TRANSITION_COOLDOWN_MS;
502
+ }
503
+ // Immediately show transitional state (Opening/Closing) while the door moves.
504
+ service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, shouldClose ? this.hap.Characteristic.CurrentDoorState.CLOSING :
505
+ this.hap.Characteristic.CurrentDoorState.OPENING);
506
+ // Trigger the gate - for motorized gates, the same trigger command handles both open and close.
507
+ const triggerGate = isSideDoor ? async () => this.hubSideDoorLockCommand(false) : async () => this.hubLockCommand(false);
508
+ if (!(await triggerGate())) {
509
+ // Clear the transition cooldown on failure.
510
+ if (isSideDoor) {
511
+ this.sideDoorGateTransitionUntil = 0;
512
+ }
513
+ else {
514
+ this.gateTransitionUntil = 0;
515
+ }
516
+ // Revert target state on failure.
517
+ setTimeout(() => {
518
+ service.updateCharacteristic(this.hap.Characteristic.TargetDoorState, shouldClose ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED);
519
+ service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, getDoorState());
520
+ }, 50);
521
+ }
522
+ // The DPS sensor event will update the CurrentDoorState when the gate finishes moving.
523
+ return;
524
+ }
525
+ // 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.
526
+ const lockCommand = isSideDoor ? async (lock) => this.hubSideDoorLockCommand(lock) :
527
+ async (lock) => this.hubLockCommand(lock);
528
+ if (!(await lockCommand(shouldClose))) {
529
+ // Revert target state on failure.
530
+ setTimeout(() => {
531
+ service.updateCharacteristic(this.hap.Characteristic.TargetDoorState, shouldClose ? this.hap.Characteristic.TargetDoorState.OPEN : this.hap.Characteristic.TargetDoorState.CLOSED);
532
+ service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, getDoorState());
533
+ }, 50);
534
+ }
535
+ });
536
+ // ObstructionDetected is required - we always report no obstruction.
537
+ service.getCharacteristic(this.hap.Characteristic.ObstructionDetected).onGet(() => false);
538
+ }
244
539
  // Configure a switch to manually trigger a doorbell ring event for HomeKit.
245
540
  configureDoorbellTrigger() {
246
541
  // Validate whether we should have this service enabled.
@@ -291,6 +586,54 @@ export class AccessHub extends AccessDevice {
291
586
  service.updateCharacteristic(this.hap.Characteristic.On, false);
292
587
  return true;
293
588
  }
589
+ // Configure the side door for HomeKit (UA Gate only) - always uses Lock service.
590
+ configureSideDoorLock() {
591
+ // Validate whether we should have this service enabled.
592
+ if (!validService(this.accessory, this.hap.Service.LockMechanism, this.hints.hasSideDoor, AccessReservedNames.LOCK_DOOR_SIDE)) {
593
+ return false;
594
+ }
595
+ // Acquire the service.
596
+ const service = acquireService(this.accessory, this.hap.Service.LockMechanism, this.accessoryName + " Side Door", AccessReservedNames.LOCK_DOOR_SIDE, () => this.log.info("Configuring side door lock."));
597
+ if (!service) {
598
+ this.log.error("Unable to add the side door.");
599
+ return false;
600
+ }
601
+ // Configure the lock service.
602
+ this.configureLockService(service, true);
603
+ // Initialize the lock.
604
+ this._hkSideDoorLockState = -1;
605
+ service.displayName = this.accessoryName + " Side Door";
606
+ service.updateCharacteristic(this.hap.Characteristic.Name, this.accessoryName + " Side Door");
607
+ this.hkSideDoorLockState = this.hubSideDoorLockState;
608
+ return true;
609
+ }
610
+ // 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.
611
+ configureSideDoorLockTrigger() {
612
+ // Validate whether we should have this service enabled.
613
+ if (!validService(this.accessory, this.hap.Service.Switch, this.hints.hasSideDoor && this.hasFeature("Hub.SideDoor.Lock.Trigger"), AccessReservedNames.SWITCH_LOCK_DOOR_SIDE_TRIGGER)) {
614
+ return false;
615
+ }
616
+ // Acquire the service.
617
+ 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."));
618
+ if (!service) {
619
+ this.log.error("Unable to add the side door lock automation trigger.");
620
+ return false;
621
+ }
622
+ // Trigger the lock state.
623
+ service.getCharacteristic(this.hap.Characteristic.On).onGet(() => this.hkSideDoorLockState !== this.hap.Characteristic.LockCurrentState.SECURED);
624
+ // The state isn't really user-triggerable. We have no way, currently, to trigger a lock or unlock event on the hub.
625
+ service.getCharacteristic(this.hap.Characteristic.On).onSet(async (value) => {
626
+ // If we are on, we are in an unlocked state. If we are off, we are in a locked state.
627
+ if (!(await this.hubSideDoorLockCommand(!value))) {
628
+ // Revert our state.
629
+ setTimeout(() => service.updateCharacteristic(this.hap.Characteristic.On, !value), 50);
630
+ }
631
+ });
632
+ // Initialize the switch.
633
+ service.updateCharacteristic(this.hap.Characteristic.ConfiguredName, this.accessoryName + " Side Door Lock Trigger");
634
+ service.updateCharacteristic(this.hap.Characteristic.On, false);
635
+ return true;
636
+ }
294
637
  // Configure MQTT capabilities of this light.
295
638
  configureMqtt() {
296
639
  const lockService = this.accessory.getService(this.hap.Service.LockMechanism);
@@ -340,6 +683,46 @@ export class AccessHub extends AccessDevice {
340
683
  break;
341
684
  }
342
685
  });
686
+ // MQTT side door lock status (UA Gate only).
687
+ if (this.hints.hasSideDoor) {
688
+ this.controller.mqtt?.subscribeGet(this.id, "sidedoor/lock", "Side Door Lock", () => {
689
+ switch (this.hkSideDoorLockState) {
690
+ case this.hap.Characteristic.LockCurrentState.SECURED:
691
+ return "true";
692
+ case this.hap.Characteristic.LockCurrentState.UNSECURED:
693
+ return "false";
694
+ default:
695
+ return "unknown";
696
+ }
697
+ });
698
+ this.controller.mqtt?.subscribeSet(this.id, "sidedoor/lock", "Side Door Lock", (value) => {
699
+ switch (value) {
700
+ case "true":
701
+ void this.hubSideDoorLockCommand(true);
702
+ break;
703
+ case "false":
704
+ void this.hubSideDoorLockCommand(false);
705
+ break;
706
+ default:
707
+ this.log.error("MQTT: Unknown side door lock set message received: %s.", value);
708
+ break;
709
+ }
710
+ });
711
+ // MQTT side door DPS status (UA Gate only).
712
+ this.controller.mqtt?.subscribeGet(this.id, "sidedoor/dps", "Side door position sensor", () => {
713
+ if (!this.isSideDoorDpsWired) {
714
+ return "unknown";
715
+ }
716
+ switch (this._hkSideDoorDpsState) {
717
+ case this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED:
718
+ return "false";
719
+ case this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED:
720
+ return "true";
721
+ default:
722
+ return "unknown";
723
+ }
724
+ });
725
+ }
343
726
  return true;
344
727
  }
345
728
  // Check and validate Ultra inputs with what the user has configured in HomeKit.
@@ -348,7 +731,7 @@ export class AccessHub extends AccessDevice {
348
731
  const hint = ("hasWiring" + input);
349
732
  const mode = input.toLowerCase();
350
733
  // Is the mode enabled on the hub?
351
- const isEnabled = this.uda.extensions?.[0]?.target_config?.some(entry => (entry.config_key === "rex_button_mode") && entry.config_value === mode);
734
+ const isEnabled = this.uda.extensions?.[0]?.target_config?.some(entry => (entry.config_key === "rex_button_mode") && (entry.config_value === mode));
352
735
  if (this.hints[hint] && !isEnabled) {
353
736
  // The hub has disabled this input.
354
737
  this.hints[hint] = false;
@@ -359,26 +742,91 @@ export class AccessHub extends AccessDevice {
359
742
  }
360
743
  }
361
744
  }
362
- // Utility function to execute lock and unlock actions on a hub.
363
- async hubLockCommand(isLocking) {
745
+ // Unified utility function to execute lock and unlock actions on a hub door.
746
+ async hubDoorLockCommand(isLocking, isSideDoor = false) {
364
747
  const action = isLocking ? "lock" : "unlock";
365
- // Only allow relocking if we are able to do so.
366
- if ((this.lockDelayInterval === undefined) && isLocking) {
367
- this.log.error("Unable to manually relock when the lock relay is configured to the default settings.");
748
+ const doorName = isSideDoor ? "side door" : (this.uda.device_type === "UGT" ? "gate" : "door");
749
+ const doorId = isSideDoor ? this.sideDoorLocationId : this.mainDoorLocationId;
750
+ // 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
751
+ // applies to both Lock and GarageDoorOpener service types since GarageDoorOpener is just a visual convenience for the same underlying lock behavior.
752
+ if ((this.lockDelayInterval === undefined) && isLocking && (this.uda.device_type !== "UGT")) {
753
+ this.log.error("Unable to manually relock the %s when the lock relay is configured to the default settings.", doorName);
368
754
  return false;
369
755
  }
370
756
  // If we're not online, we're done.
371
757
  if (!this.isOnline) {
372
- this.log.error("Unable to %s. Device is offline.", action);
758
+ this.log.error("Unable to %s the %s. Device is offline.", action, doorName);
373
759
  return false;
374
760
  }
761
+ // For UA Gate hubs, use the location-based unlock API since the device API is not supported.
762
+ if (this.uda.device_type === "UGT") {
763
+ if (!doorId) {
764
+ this.log.error("Unable to %s the %s. Door not found.", action, isSideDoor ? "side door" : "gate");
765
+ return false;
766
+ }
767
+ // Execute the action using the location endpoint.
768
+ const endpoint = this.controller.udaApi.getApiEndpoint("location") + "/" + doorId + "/unlock";
769
+ const response = await this.controller.udaApi.retrieve(endpoint, {
770
+ body: JSON.stringify({}),
771
+ method: "PUT"
772
+ });
773
+ if (!this.controller.udaApi.responseOk(response?.statusCode)) {
774
+ this.log.error("Unable to %s the %s.", action, doorName);
775
+ return false;
776
+ }
777
+ // When unlocking from HomeKit, the controller doesn't send the events to the events API. Manually update the state and schedule the auto-lock.
778
+ if (!isLocking) {
779
+ if (isSideDoor) {
780
+ this.hkSideDoorLockState = this.hap.Characteristic.LockCurrentState.UNSECURED;
781
+ if (this.hints.logLock) {
782
+ this.log.info("Side door unlocked.");
783
+ }
784
+ setTimeout(() => {
785
+ this.hkSideDoorLockState = this.hap.Characteristic.LockCurrentState.SECURED;
786
+ if (this.hints.logLock) {
787
+ this.log.info("Side door locked.");
788
+ }
789
+ }, AUTO_LOCK_DELAY_MS);
790
+ }
791
+ else {
792
+ this.hkLockState = this.hap.Characteristic.LockCurrentState.UNSECURED;
793
+ setTimeout(() => this.hkLockState = this.hap.Characteristic.LockCurrentState.SECURED, AUTO_LOCK_DELAY_MS);
794
+ }
795
+ }
796
+ return true;
797
+ }
798
+ // 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
799
+ // visual convenience for the same underlying lock behavior.
800
+ const delayInterval = this.lockDelayInterval;
375
801
  // Execute the action.
376
- if (!(await this.controller.udaApi.unlock(this.uda, (this.lockDelayInterval === undefined) ? undefined : (isLocking ? 0 : Infinity)))) {
802
+ if (!(await this.controller.udaApi.unlock(this.uda, (delayInterval === undefined) ? undefined : (isLocking ? 0 : Infinity)))) {
377
803
  this.log.error("Unable to %s.", action);
378
804
  return false;
379
805
  }
380
806
  return true;
381
807
  }
808
+ // Wrapper for door lock command.
809
+ async hubLockCommand(isLocking) {
810
+ return this.hubDoorLockCommand(isLocking);
811
+ }
812
+ // Wrapper for side door lock command (for backwards compatibility).
813
+ async hubSideDoorLockCommand(isLocking) {
814
+ return this.hubDoorLockCommand(isLocking, true);
815
+ }
816
+ // 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
817
+ // backing variable. This allows GarageDoorOpener to function correctly even when the DPS contact sensor is disabled.
818
+ get hkDpsState() {
819
+ const service = this.accessory.getServiceById(this.hap.Service.ContactSensor, AccessReservedNames.CONTACT_DPS);
820
+ if (service) {
821
+ return service.getCharacteristic(this.hap.Characteristic.ContactSensorState).value ?? this._hkDpsState;
822
+ }
823
+ return this._hkDpsState;
824
+ }
825
+ // 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.
826
+ set hkDpsState(value) {
827
+ this._hkDpsState = value;
828
+ this.setContactSensorState(AccessReservedNames.CONTACT_DPS, value);
829
+ }
382
830
  // Return the current HomeKit lock state that we are tracking for this hub.
383
831
  get hkLockState() {
384
832
  return this._hkLockState;
@@ -391,16 +839,79 @@ export class AccessHub extends AccessDevice {
391
839
  }
392
840
  // Update the lock state.
393
841
  this._hkLockState = value;
394
- // Retrieve the lock service.
395
- const lockService = this.accessory.getService(this.hap.Service.LockMechanism);
396
- if (!lockService) {
842
+ // 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
843
+ // Gate with GarageDoorOpener, DPS events handle updates since it's a motorized gate with physical positions.
844
+ if ((this.doorServiceType === "Lock") || (this.uda.device_type !== "UGT")) {
845
+ this.updateDoorServiceState(false);
846
+ }
847
+ else {
848
+ // UA Gate with GarageDoorOpener: only update the lock trigger switch if enabled.
849
+ this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_LOCK_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, value !== this.hap.Characteristic.LockCurrentState.SECURED);
850
+ }
851
+ }
852
+ // Update door service state based on configured service type.
853
+ updateDoorServiceState(isSideDoor) {
854
+ const serviceType = isSideDoor ? "Lock" : this.doorServiceType;
855
+ const lockState = isSideDoor ? this.hkSideDoorLockState : this.hkLockState;
856
+ const triggerSubtype = isSideDoor ? AccessReservedNames.SWITCH_LOCK_DOOR_SIDE_TRIGGER : AccessReservedNames.SWITCH_LOCK_TRIGGER;
857
+ // Check if we're in a transition cooldown period - skip updates to preserve the Opening/Closing state.
858
+ const transitionUntil = isSideDoor ? this.sideDoorGateTransitionUntil : this.gateTransitionUntil;
859
+ if (serviceType === "GarageDoorOpener") {
860
+ // GarageDoorOpener is only used for the primary door, which has no subtype.
861
+ const service = this.accessory.getService(this.hap.Service.GarageDoorOpener);
862
+ if (service) {
863
+ // UA Gate uses DPS state since it's a motorized gate with physical positions. Non-UA Gate hubs derive state from lock relay (locked = closed, unlocked = open)
864
+ // since GarageDoorOpener is just a visual convenience for the same underlying lock behavior.
865
+ if (this.uda.device_type === "UGT") {
866
+ const dpsState = isSideDoor ? this._hkSideDoorDpsState : this.hkDpsState;
867
+ const doorState = dpsState === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED ? this.hap.Characteristic.CurrentDoorState.CLOSED :
868
+ this.hap.Characteristic.CurrentDoorState.OPEN;
869
+ const targetState = dpsState === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED ?
870
+ this.hap.Characteristic.TargetDoorState.CLOSED : this.hap.Characteristic.TargetDoorState.OPEN;
871
+ // If in transition cooldown, ignore all DPS updates to let the gate stabilize. The gate sensor often bounces between open/closed during movement. We'll accept
872
+ // the final state once the cooldown expires.
873
+ if (Date.now() < transitionUntil) {
874
+ return;
875
+ }
876
+ service.updateCharacteristic(this.hap.Characteristic.TargetDoorState, targetState);
877
+ service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, doorState);
878
+ }
879
+ else {
880
+ // Non-UA Gate hubs: derive from lock relay state.
881
+ const doorState = this.isLocked(lockState) ? this.hap.Characteristic.CurrentDoorState.CLOSED : this.hap.Characteristic.CurrentDoorState.OPEN;
882
+ const targetState = this.isLocked(lockState) ? this.hap.Characteristic.TargetDoorState.CLOSED : this.hap.Characteristic.TargetDoorState.OPEN;
883
+ service.updateCharacteristic(this.hap.Characteristic.TargetDoorState, targetState);
884
+ service.updateCharacteristic(this.hap.Characteristic.CurrentDoorState, doorState);
885
+ }
886
+ }
887
+ }
888
+ else {
889
+ // The primary door has no subtype. The side door (UA Gate only) uses a subtype to distinguish it.
890
+ const service = isSideDoor ? this.accessory.getServiceById(this.hap.Service.LockMechanism, AccessReservedNames.LOCK_DOOR_SIDE) :
891
+ this.accessory.getService(this.hap.Service.LockMechanism);
892
+ if (service) {
893
+ service.updateCharacteristic(this.hap.Characteristic.LockTargetState, lockState === this.hap.Characteristic.LockCurrentState.UNSECURED ?
894
+ this.hap.Characteristic.LockTargetState.UNSECURED : this.hap.Characteristic.LockTargetState.SECURED);
895
+ service.updateCharacteristic(this.hap.Characteristic.LockCurrentState, lockState);
896
+ }
897
+ }
898
+ // Update the lock trigger switch if enabled.
899
+ this.accessory.getServiceById(this.hap.Service.Switch, triggerSubtype)?.updateCharacteristic(this.hap.Characteristic.On, lockState !== this.hap.Characteristic.LockCurrentState.SECURED);
900
+ }
901
+ // Return the current HomeKit side door lock state that we are tracking for this hub.
902
+ get hkSideDoorLockState() {
903
+ return this._hkSideDoorLockState;
904
+ }
905
+ // Set the current HomeKit side door lock state for this hub.
906
+ set hkSideDoorLockState(value) {
907
+ // If nothing is changed, we're done.
908
+ if (this.hkSideDoorLockState === value) {
397
909
  return;
398
910
  }
399
- // Update the state in HomeKit.
400
- lockService.updateCharacteristic(this.hap.Characteristic.LockTargetState, this.hkLockState === this.hap.Characteristic.LockCurrentState.UNSECURED ?
401
- this.hap.Characteristic.LockTargetState.UNSECURED : this.hap.Characteristic.LockTargetState.SECURED);
402
- lockService.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.hkLockState);
403
- this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_LOCK_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, this.hkLockState !== this.hap.Characteristic.LockCurrentState.SECURED);
911
+ // Update the lock state.
912
+ this._hkSideDoorLockState = value;
913
+ // Update the lock service state.
914
+ this.updateDoorServiceState(true);
404
915
  }
405
916
  // Return the current state of the DPS on the hub.
406
917
  get hubDpsState() {
@@ -425,6 +936,16 @@ export class AccessHub extends AccessDevice {
425
936
  return (this.uda.configs?.find(entry => entry.key === relayType)?.value === "on") ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED :
426
937
  this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED;
427
938
  }
939
+ // Return the current state of the side door DPS on the UA Gate hub.
940
+ get hubSideDoorDpsState() {
941
+ // If we don't have the wiring connected for the side door DPS, we report our default closed state.
942
+ if (!this.isSideDoorDpsWired) {
943
+ return this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED;
944
+ }
945
+ // Return our side door DPS state. If it's anything other than on, we assume it's open.
946
+ return (this.uda.configs?.find(entry => entry.key === "input_door_dps")?.value === "on") ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED :
947
+ this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED;
948
+ }
428
949
  // Return the current state of the relay lock on the hub.
429
950
  get hubLockState() {
430
951
  let relayType;
@@ -443,6 +964,15 @@ export class AccessHub extends AccessDevice {
443
964
  const lockRelay = this.uda.configs?.find(entry => entry.key === relayType);
444
965
  return (lockRelay?.value === "off") ? this.hap.Characteristic.LockCurrentState.SECURED : this.hap.Characteristic.LockCurrentState.UNSECURED;
445
966
  }
967
+ // Return the current state of the side door relay lock on the UA Gate hub.
968
+ get hubSideDoorLockState() {
969
+ // Side door lock is only available on UA Gate.
970
+ if (this.uda.device_type !== "UGT") {
971
+ return this.hap.Characteristic.LockCurrentState.SECURED;
972
+ }
973
+ const lockRelay = this.uda.configs?.find(entry => entry.key === "output_oper2_relay");
974
+ return (lockRelay?.value === "off") ? this.hap.Characteristic.LockCurrentState.SECURED : this.hap.Characteristic.LockCurrentState.UNSECURED;
975
+ }
446
976
  // Return the current state of the REL on the hub.
447
977
  get hubRelState() {
448
978
  // If we don't have the wiring connected for the REL, we report our default closed state.
@@ -501,11 +1031,20 @@ export class AccessHub extends AccessDevice {
501
1031
  return (this.uda.configs?.find(relay => relay.key === relayType)?.value === "on") ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED :
502
1032
  this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED;
503
1033
  }
1034
+ // Return the wiring state of the side door DPS on UA Gate hubs. The side door DPS uses different wiring keys than the main gate DPS.
1035
+ get isSideDoorDpsWired() {
1036
+ // Side door DPS is only available on UA Gate.
1037
+ if (this.uda.device_type !== "UGT") {
1038
+ return false;
1039
+ }
1040
+ // Check if the side door DPS is wired (wiring_state_door-dps-neg and wiring_state_door-dps-pos).
1041
+ return ["wiring_state_door-dps-neg", "wiring_state_door-dps-pos"].every(wire => this.uda.configs?.some(e => (e.key === wire) && (e.value === "on")));
1042
+ }
504
1043
  // Utility to check the wiring state of a given terminal input.
505
1044
  isWired(input) {
506
1045
  // UA-ULTRA proxies via button mode.
507
1046
  if ((this.uda.device_type === "UA-ULTRA") && sensorWiring[input].proxyMode) {
508
- return this.uda.extensions?.[0]?.target_config?.some(e => e.config_key === "rex_button_mode" && e.config_value === sensorWiring[input].proxyMode) ?? false;
1047
+ return this.uda.extensions?.[0]?.target_config?.some(e => (e.config_key === "rex_button_mode") && (e.config_value === sensorWiring[input].proxyMode)) ?? false;
509
1048
  }
510
1049
  // Find the wiring keys for this model.
511
1050
  const wires = sensorWiring[input].wiring?.[this.uda.device_type];
@@ -528,109 +1067,287 @@ export class AccessHub extends AccessDevice {
528
1067
  hasCapability(capability) {
529
1068
  return Array.isArray(capability) ? capability.some(c => this.uda.capabilities.includes(c)) : this.uda.capabilities.includes(capability);
530
1069
  }
531
- // Handle hub-related events.
532
- eventHandler(packet) {
1070
+ // Update door state from location data (lock and DPS).
1071
+ updateDoorFromLocationState(doorState, isSideDoor) {
1072
+ const newLockState = this.toLockState(doorState.lock);
1073
+ const newDpsState = this.toDpsState(doorState.dps);
1074
+ const doorName = isSideDoor ? "Side door" : "";
1075
+ // Update lock state.
1076
+ const currentLockState = isSideDoor ? this.hkSideDoorLockState : this.hkLockState;
1077
+ if (newLockState !== currentLockState) {
1078
+ if (isSideDoor) {
1079
+ this.hkSideDoorLockState = newLockState;
1080
+ this.controller.mqtt?.publish(this.id, "sidedoor/lock", this.isLocked(newLockState) ? "true" : "false");
1081
+ if (this.hints.logLock) {
1082
+ this.log.info("%s %s.", doorName, this.isLocked(newLockState) ? "locked" : "unlocked");
1083
+ }
1084
+ }
1085
+ else {
1086
+ this.hkLockState = newLockState;
1087
+ this.controller.mqtt?.publish(this.id, "lock", this.isLocked(newLockState) ? "true" : "false");
1088
+ if (this.hints.logLock) {
1089
+ this.log.info(this.isLocked(newLockState) ? "Locked." : "Unlocked.");
1090
+ }
1091
+ }
1092
+ }
1093
+ // Update DPS state.
1094
+ const currentDpsState = isSideDoor ? this._hkSideDoorDpsState : this.hkDpsState;
1095
+ if (newDpsState !== currentDpsState) {
1096
+ const contactDetected = this.isClosed(newDpsState);
1097
+ if (isSideDoor) {
1098
+ this._hkSideDoorDpsState = newDpsState;
1099
+ this.setContactSensorState(AccessReservedNames.CONTACT_DPS_SIDE, newDpsState);
1100
+ this.controller.mqtt?.publish(this.id, "sidedoor/dps", contactDetected ? "false" : "true");
1101
+ if (this.hints.logDps) {
1102
+ this.log.info("Side door position sensor %s.", contactDetected ? "closed" : "open");
1103
+ }
1104
+ }
1105
+ else {
1106
+ this.hkDpsState = newDpsState;
1107
+ this.controller.mqtt?.publish(this.id, "dps", contactDetected ? "false" : "true");
1108
+ if ((this.doorServiceType === "GarageDoorOpener") || this.hints.logDps) {
1109
+ this.log.info("Door position sensor %s.", contactDetected ? "closed" : "open");
1110
+ }
1111
+ if (this.doorServiceType === "GarageDoorOpener") {
1112
+ this.updateDoorServiceState(false);
1113
+ }
1114
+ }
1115
+ }
1116
+ }
1117
+ // Handle remote unlock events.
1118
+ handleRemoteUnlock(packet) {
1119
+ // For UA Gate hubs, determine which door was unlocked based on the event_object_id.
1120
+ if (this.uda.device_type === "UGT") {
1121
+ const eventDoorId = packet.event_object_id;
1122
+ const isSideDoor = this.sideDoorLocationId && (eventDoorId === this.sideDoorLocationId);
1123
+ const isMainDoor = this.mainDoorLocationId && (eventDoorId === this.mainDoorLocationId);
1124
+ if (!isSideDoor && !isMainDoor) {
1125
+ return;
1126
+ }
1127
+ const doorName = isSideDoor ? "Side door" : "Gate";
1128
+ const mqttTopic = isSideDoor ? "sidedoor/lock" : "lock";
1129
+ // Set unlocked state.
1130
+ if (isSideDoor) {
1131
+ this.hkSideDoorLockState = this.hap.Characteristic.LockCurrentState.UNSECURED;
1132
+ }
1133
+ else {
1134
+ this.hkLockState = this.hap.Characteristic.LockCurrentState.UNSECURED;
1135
+ }
1136
+ this.controller.mqtt?.publish(this.id, mqttTopic, "false");
1137
+ if (this.hints.logLock) {
1138
+ this.log.info("%s unlocked.", doorName);
1139
+ }
1140
+ // Auto-lock after delay.
1141
+ setTimeout(() => {
1142
+ if (isSideDoor) {
1143
+ this.hkSideDoorLockState = this.hap.Characteristic.LockCurrentState.SECURED;
1144
+ }
1145
+ else {
1146
+ this.hkLockState = this.hap.Characteristic.LockCurrentState.SECURED;
1147
+ }
1148
+ this.controller.mqtt?.publish(this.id, mqttTopic, "true");
1149
+ if (this.hints.logLock) {
1150
+ this.log.info("%s locked.", doorName);
1151
+ }
1152
+ }, AUTO_LOCK_DELAY_MS);
1153
+ }
1154
+ else {
1155
+ // Non-UA Gate hubs: default behavior.
1156
+ this.hkLockState = this.hap.Characteristic.LockCurrentState.UNSECURED;
1157
+ this.controller.mqtt?.publish(this.id, "lock", "false");
1158
+ if (this.hints.logLock) {
1159
+ this.log.info("Unlocked.");
1160
+ }
1161
+ }
1162
+ }
1163
+ // Handle device update events (v1 API).
1164
+ handleDeviceUpdate(packet) {
533
1165
  const terminalInputs = [
534
1166
  { input: "Dps", label: "Door position sensor", topic: "dps" },
535
1167
  { input: "Rel", label: "Remote release", topic: "rel" },
536
1168
  { input: "Ren", label: "Request to enter sensor", topic: "ren" },
537
1169
  { input: "Rex", label: "Request to exit sensor", topic: "rex" }
538
1170
  ];
539
- switch (packet.event) {
540
- case "access.data.device.remote_unlock":
541
- // Process an Access unlock event.
542
- this.hkLockState = this.hap.Characteristic.LockCurrentState.UNSECURED;
543
- // Publish to MQTT, if configured to do so.
544
- this.controller.mqtt?.publish(this.id, "lock", "false");
1171
+ // Process a lock update event if our state has changed. Skip for UA Gate hubs since we handle state manually because Access doesn't send those events out.
1172
+ if ((this.uda.device_type !== "UGT") && (this.hubLockState !== this.hkLockState)) {
1173
+ this.hkLockState = this.hubLockState;
1174
+ this.controller.mqtt?.publish(this.id, "lock", this.isLocked(this.hkLockState) ? "true" : "false");
1175
+ if (this.hints.logLock) {
1176
+ this.log.info(this.isLocked(this.hkLockState) ? "Locked." : "Unlocked.");
1177
+ }
1178
+ }
1179
+ // Process a side door lock update event if our state has changed (UA Gate only). We skip this for UGT since polling handles state updates.
1180
+ if (this.hints.hasSideDoor && (this.uda.device_type !== "UGT")) {
1181
+ const currentHkState = this.hkSideDoorLockState;
1182
+ const newHubState = this.hubSideDoorLockState;
1183
+ if (newHubState !== currentHkState) {
1184
+ this.hkSideDoorLockState = newHubState;
1185
+ this.controller.mqtt?.publish(this.id, "sidedoor/lock", this.isLocked(this.hkSideDoorLockState) ? "true" : "false");
545
1186
  if (this.hints.logLock) {
546
- this.log.info("Unlocked.");
1187
+ this.log.info("Side door %s.", this.isLocked(this.hkSideDoorLockState) ? "locked" : "unlocked");
547
1188
  }
548
- break;
549
- case "access.data.device.update":
550
- // Process a lock update event if our state has changed.
551
- if (this.hubLockState !== this.hkLockState) {
552
- this.hkLockState = this.hubLockState;
553
- this.controller.mqtt?.publish(this.id, "lock", this.hkLockState === this.hap.Characteristic.LockCurrentState.SECURED ? "true" : "false");
554
- if (this.hints.logLock) {
555
- this.log.info(this.hkLockState === this.hap.Characteristic.LockCurrentState.SECURED ? "Locked." : "Unlocked.");
556
- }
1189
+ }
1190
+ }
1191
+ // Process a side door DPS update event if our state has changed (UA Gate only). We skip this for UGT since polling handles state updates.
1192
+ if (this.hints.hasSideDoor && this.hints.hasWiringDps && (this.uda.device_type !== "UGT")) {
1193
+ const newSideDoorDpsState = this.hubSideDoorDpsState;
1194
+ if (newSideDoorDpsState !== this._hkSideDoorDpsState) {
1195
+ this._hkSideDoorDpsState = newSideDoorDpsState;
1196
+ const contactDetected = this.isClosed(this._hkSideDoorDpsState);
1197
+ this.controller.mqtt?.publish(this.id, "sidedoor/dps", contactDetected ? "false" : "true");
1198
+ if (this.hints.logDps) {
1199
+ this.log.info("Side door position sensor %s.", contactDetected ? "closed" : "open");
557
1200
  }
558
- // Process any terminal input update events if our state has changed.
559
- for (const { input, topic, label } of terminalInputs) {
560
- const hasKey = ("hasWiring" + input);
561
- const hkKey = ("hk" + input + "State");
562
- const hubKey = ("hub" + input + "State");
563
- const logKey = ("log" + input);
564
- const wiredKey = ("is" + input + "Wired");
565
- if (this.hints[hasKey] && this[hubKey] !== this[hkKey]) {
566
- this[hkKey] = this[hubKey];
567
- if (this[wiredKey]) {
568
- const contactDetected = this[hkKey] === this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED;
569
- this.controller.mqtt?.publish(this.id, topic, contactDetected ? "false" : "true");
570
- if (this.hints[logKey]) {
571
- this.log.info(label + " " + (contactDetected ? "closed" : "open") + ".");
572
- }
573
- }
1201
+ }
1202
+ }
1203
+ // Process any terminal input update events if our state has changed.
1204
+ for (const { input, topic, label } of terminalInputs) {
1205
+ const hasKey = ("hasWiring" + input);
1206
+ const hkKey = ("hk" + input + "State");
1207
+ const hubKey = ("hub" + input + "State");
1208
+ const logKey = ("log" + input);
1209
+ const wiredKey = ("is" + input + "Wired");
1210
+ if (this.hints[hasKey] && (this[hubKey] !== this[hkKey])) {
1211
+ this[hkKey] = this[hubKey];
1212
+ if (this[wiredKey]) {
1213
+ const contactDetected = this.isClosed(this[hkKey]);
1214
+ this.controller.mqtt?.publish(this.id, topic, contactDetected ? "false" : "true");
1215
+ if (this.hints[logKey]) {
1216
+ this.log.info("%s %s.", label, contactDetected ? "closed" : "open");
574
1217
  }
575
1218
  }
576
- // Process any changes to terminal input configuration.
577
- if (packet.data.extensions?.[0]?.target_config && (this.uda.display_model === "UA Ultra")) {
578
- // Ensure we sync our state with HomeKit.
579
- this.checkUltraInputs();
580
- this.configureTerminalInputs();
581
- }
582
- // Process any changes to our online status.
583
- if (packet.data.is_online !== undefined) {
584
- for (const sensor of Object.keys(AccessReservedNames).filter(key => key.startsWith("CONTACT_"))) {
585
- this.accessory.getServiceById(this.hap.Service.ContactSensor, AccessReservedNames[sensor])?.
586
- updateCharacteristic(this.hap.Characteristic.StatusActive, !!packet.data.is_online);
1219
+ // When DPS state changes on UA Gate, update the GarageDoorOpener service state. Non-UA Gate hubs with GarageDoorOpener derive state from lock relay changes
1220
+ // instead, so DPS updates don't affect the door service.
1221
+ if ((input === "Dps") && (this.doorServiceType === "GarageDoorOpener") && (this.uda.device_type === "UGT")) {
1222
+ this.updateDoorServiceState(false);
1223
+ }
1224
+ }
1225
+ }
1226
+ // Process any changes to terminal input configuration.
1227
+ if (packet.data.extensions?.[0]?.target_config && (this.uda.display_model === "UA Ultra")) {
1228
+ this.checkUltraInputs();
1229
+ this.configureTerminalInputs();
1230
+ }
1231
+ // Process any changes to our online status.
1232
+ if (packet.data.is_online !== undefined) {
1233
+ for (const sensor of Object.keys(AccessReservedNames).filter(key => key.startsWith("CONTACT_"))) {
1234
+ this.accessory.getServiceById(this.hap.Service.ContactSensor, AccessReservedNames[sensor])?.
1235
+ updateCharacteristic(this.hap.Characteristic.StatusActive, !!packet.data.is_online);
1236
+ }
1237
+ }
1238
+ }
1239
+ // Handle device update v2 events.
1240
+ handleDeviceUpdateV2(packet) {
1241
+ const data = packet.data;
1242
+ // Process access method updates. We only process entries with explicit "yes" or "no" values, ignoring any malformed entries that contain other values (e.g., key
1243
+ // names echoed back as values).
1244
+ if (data.access_method) {
1245
+ for (const [key, value] of Object.entries(data.access_method)) {
1246
+ if ((value !== "yes") && (value !== "no")) {
1247
+ continue;
1248
+ }
1249
+ const accessMethod = accessMethods.find(entry => entry.key === key);
1250
+ if (accessMethod) {
1251
+ this.accessory.getServiceById(this.hap.Service.Switch, accessMethod.subtype)?.updateCharacteristic(this.hap.Characteristic.On, value === "yes");
1252
+ }
1253
+ }
1254
+ }
1255
+ // Process location_states for UA Gate hubs - this contains lock state per door.
1256
+ if (data.location_states && (this.uda.device_type === "UGT")) {
1257
+ const locationStates = data.location_states;
1258
+ // Process main door state.
1259
+ const mainDoorExtension = this.uda.extensions?.find(ext => ext.source_id === "port1");
1260
+ const mainDoorId = mainDoorExtension?.target_value ?? this.mainDoorLocationId;
1261
+ if (mainDoorId) {
1262
+ const mainDoorState = locationStates.find(state => state.location_id === mainDoorId);
1263
+ if (mainDoorState) {
1264
+ this.updateDoorFromLocationState(mainDoorState, false);
1265
+ }
1266
+ }
1267
+ // Process side door state.
1268
+ if (this.hints.hasSideDoor) {
1269
+ const sideDoorExtension = this.uda.extensions?.find(ext => ext.source_id === "port2");
1270
+ const sideDoorId = sideDoorExtension?.target_value ?? this.sideDoorLocationId;
1271
+ if (sideDoorId) {
1272
+ const sideDoorState = locationStates.find(state => state.location_id === sideDoorId);
1273
+ if (sideDoorState) {
1274
+ this.updateDoorFromLocationState(sideDoorState, true);
587
1275
  }
588
1276
  }
1277
+ }
1278
+ }
1279
+ }
1280
+ // Handle location update events (v2 API).
1281
+ handleLocationUpdate(packet) {
1282
+ // Only process for UA Gate hubs.
1283
+ if (this.uda.device_type !== "UGT") {
1284
+ return;
1285
+ }
1286
+ const locationData = packet.data;
1287
+ if (!locationData.state) {
1288
+ return;
1289
+ }
1290
+ const locationId = locationData.id;
1291
+ const isMainDoor = locationId === this.mainDoorLocationId;
1292
+ const isSideDoor = locationId === this.sideDoorLocationId;
1293
+ if (isMainDoor) {
1294
+ this.updateDoorFromLocationState(locationData.state, false);
1295
+ }
1296
+ else if (isSideDoor && this.hints.hasSideDoor) {
1297
+ this.updateDoorFromLocationState(locationData.state, true);
1298
+ }
1299
+ }
1300
+ // Handle doorbell ring events.
1301
+ handleDoorbellRing(packet) {
1302
+ if ((packet.data.connected_uah_id !== this.uda.unique_id) || !this.hasCapability("door_bell")) {
1303
+ return;
1304
+ }
1305
+ this.doorbellRingRequestId = packet.data.request_id;
1306
+ // Trigger the doorbell event in HomeKit.
1307
+ this.accessory.getService(this.hap.Service.Doorbell)?.getCharacteristic(this.hap.Characteristic.ProgrammableSwitchEvent)
1308
+ ?.sendEventNotification(this.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS);
1309
+ // Update our doorbell trigger, if needed.
1310
+ this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_DOORBELL_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, true);
1311
+ // Publish to MQTT.
1312
+ this.controller.mqtt?.publish(this.id, "doorbell", "true");
1313
+ if (this.hints.logDoorbell) {
1314
+ this.log.info("Doorbell ring detected.");
1315
+ }
1316
+ }
1317
+ // Handle doorbell cancel events.
1318
+ handleDoorbellCancel(packet) {
1319
+ if (this.doorbellRingRequestId !== packet.data.remote_call_request_id) {
1320
+ return;
1321
+ }
1322
+ this.doorbellRingRequestId = null;
1323
+ // Update our doorbell trigger, if needed.
1324
+ this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_DOORBELL_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, false);
1325
+ // Publish to MQTT.
1326
+ this.controller.mqtt?.publish(this.id, "doorbell", "false");
1327
+ if (this.hints.logDoorbell) {
1328
+ this.log.info("Doorbell ring cancelled.");
1329
+ }
1330
+ }
1331
+ // Handle hub-related events.
1332
+ eventHandler(packet) {
1333
+ switch (packet.event) {
1334
+ case "access.data.device.remote_unlock":
1335
+ this.handleRemoteUnlock(packet);
1336
+ break;
1337
+ case "access.data.device.update":
1338
+ this.handleDeviceUpdate(packet);
589
1339
  break;
590
1340
  case "access.data.v2.device.update":
591
- if (packet.data.access_method) {
592
- const accessMethodData = packet.data.access_method;
593
- // Process access method updates.
594
- for (const [key, value] of Object.entries(accessMethodData)) {
595
- const accessMethod = accessMethods.find(entry => entry.key === key);
596
- if (!accessMethod) {
597
- continue;
598
- }
599
- // Update any access method switches we have enabled with the current value.
600
- this.accessory.getServiceById(this.hap.Service.Switch, accessMethod.subtype)?.updateCharacteristic(this.hap.Characteristic.On, value === "yes");
601
- }
602
- }
1341
+ this.handleDeviceUpdateV2(packet);
1342
+ break;
1343
+ case "access.data.v2.location.update":
1344
+ this.handleLocationUpdate(packet);
603
1345
  break;
604
1346
  case "access.remote_view":
605
- // Process an Access ring event if we're the intended target.
606
- if ((packet.data.connected_uah_id !== this.uda.unique_id) || !this.hasCapability("door_bell")) {
607
- break;
608
- }
609
- this.doorbellRingRequestId = packet.data.request_id;
610
- // Trigger the doorbell event in HomeKit.
611
- this.accessory.getService(this.hap.Service.Doorbell)?.getCharacteristic(this.hap.Characteristic.ProgrammableSwitchEvent)
612
- ?.sendEventNotification(this.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS);
613
- // Update our doorbell trigger, if needed.
614
- this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_DOORBELL_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, true);
615
- // Publish to MQTT, if configured to do so.
616
- this.controller.mqtt?.publish(this.id, "doorbell", "true");
617
- if (this.hints.logDoorbell) {
618
- this.log.info("Doorbell ring detected.");
619
- }
1347
+ this.handleDoorbellRing(packet);
620
1348
  break;
621
1349
  case "access.remote_view.change":
622
- // Process the cancellation of an Access ring event if we're the intended target.
623
- if (this.doorbellRingRequestId !== packet.data.remote_call_request_id) {
624
- break;
625
- }
626
- this.doorbellRingRequestId = null;
627
- // Update our doorbell trigger, if needed.
628
- this.accessory.getServiceById(this.hap.Service.Switch, AccessReservedNames.SWITCH_DOORBELL_TRIGGER)?.updateCharacteristic(this.hap.Characteristic.On, false);
629
- // Publish to MQTT, if configured to do so.
630
- this.controller.mqtt?.publish(this.id, "doorbell", "false");
631
- if (this.hints.logDoorbell) {
632
- this.log.info("Doorbell ring cancelled.");
633
- }
1350
+ this.handleDoorbellCancel(packet);
634
1351
  break;
635
1352
  default:
636
1353
  break;
@@ -639,8 +1356,9 @@ export class AccessHub extends AccessDevice {
639
1356
  // We dynamically define our getters and setters for terminal inputs so we can streamline redundancies. Yes, this is fancy...but it's meant to future-proof a bit
640
1357
  // against whatever Ubiquiti may do in the future given the inconsistencies in their API implementation for Access across devices of even similar types.
641
1358
  static {
642
- // We define the specific sensor input properties we need.
643
- for (const input of sensorInputs) {
1359
+ // We define the specific sensor input properties we need. We skip DPS since we implement it with a manual getter/setter that provides fallback behavior when the
1360
+ // DPS contact sensor is disabled.
1361
+ for (const input of sensorInputs.filter(i => i !== "Dps")) {
644
1362
  let propName = "hk" + input + "State";
645
1363
  const enumKey = "CONTACT_" + input.toUpperCase();
646
1364
  Object.defineProperty(AccessHub.prototype, propName, {