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