UNPKG

@homebridge-plugins/homebridge-august

Version:

The August plugin allows you to access your August & Yale device(s) from HomeKit.

493 lines 27 kB
/* Copyright(C) 2021-2024, donavanbecker (https://github.com/donavanbecker). All rights reserved. * * lock.ts: homebridge-august. */ import August from 'august-yale'; import { Subject } from 'rxjs'; import { debounceTime, tap } from 'rxjs/operators'; import { deviceBase } from './device.js'; /** * Platform Accessory * An instance of this class is created for each accessory your platform registers * Each accessory may expose multiple services of different service types. */ export class LockMechanism extends deviceBase { platform; // Service LockMechanism; Battery; ContactSensor; // Lock Mechanism lockEvent; lockStatus; lockDetails; // Lock Updates lockUpdateInProgress; doLockUpdate; // PubNub subscription cleanup function. Captured from August.subscribe() // so the subscription can be properly torn down (both when the lock is // unregistered and before creating a replacement subscription). pubnubUnsubscribe; constructor(platform, accessory, device) { super(platform, accessory, device); this.platform = platform; // this is subject we use to track when we need to POST changes to the August API this.doLockUpdate = new Subject(); this.lockUpdateInProgress = false; // Initialize Lock Mechanism Service if (device.lock?.hide_lock) { if (this.LockMechanism?.Service) { this.debugLog('Removing Lock Mechanism Service'); this.LockMechanism.Service = accessory.getService(this.hap.Service.LockMechanism); accessory.removeService(this.LockMechanism.Service); accessory.context.LockMechanism = {}; } } else { accessory.context.LockMechanism = accessory.context.LockMechanism ?? {}; this.LockMechanism = { Name: accessory.displayName, Service: accessory.getService(this.hap.Service.LockMechanism) ?? accessory.addService(this.hap.Service.LockMechanism), LockTargetState: accessory.context.LockMechanismLockTargetState ?? this.hap.Characteristic.LockTargetState.SECURED, LockCurrentState: accessory.context.LockMechanismLockCurrentState ?? this.hap.Characteristic.LockCurrentState.SECURED, }; accessory.context.LockMechanism = this.LockMechanism; // Seed context keys for updateCharacteristic change detection accessory.context.LockMechanismLockCurrentState ??= this.LockMechanism.LockCurrentState; accessory.context.LockMechanismLockTargetState ??= this.LockMechanism.LockTargetState; // Initialize Lock Mechanism Characteristics this.LockMechanism.Service .setCharacteristic(this.hap.Characteristic.Name, this.LockMechanism.Name) .getCharacteristic(this.hap.Characteristic.LockTargetState) .onGet(() => { return this.LockMechanism.LockTargetState; }) .onSet(this.setLockTargetState.bind(this)); } // Initialize Contact Sensor Service if (device.lock?.hide_contactsensor) { if (this.ContactSensor?.Service) { this.debugLog('Removing Conact Sensor Service'); this.ContactSensor.Service = accessory.getService(this.hap.Service.ContactSensor); accessory.removeService(this.ContactSensor.Service); accessory.context.ContactSensor = {}; } } else { accessory.context.ContactSensor = accessory.context.ContactSensor ?? {}; this.ContactSensor = { Name: `${accessory.displayName} Contact Sensor`, Service: accessory.getService(this.hap.Service.ContactSensor) ?? accessory.addService(this.hap.Service.ContactSensor), ContactSensorState: accessory.context.ContactSensorContactSensorState ?? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED, }; accessory.context.ContactSensor = this.ContactSensor; // Seed context key for updateCharacteristic change detection accessory.context.ContactSensorContactSensorState ??= this.ContactSensor.ContactSensorState; // Initialize Conact Sensor Characteristics this.ContactSensor.Service .setCharacteristic(this.hap.Characteristic.Name, this.ContactSensor.Name) .getCharacteristic(this.hap.Characteristic.ContactSensorState) .onGet(() => { return this.ContactSensor.ContactSensorState; }); } // Initialize Battery Service accessory.context.Battery = accessory.context.Battery ?? {}; this.Battery = { Name: `${accessory.displayName} Battery`, Service: accessory.getService(this.hap.Service.Battery) ?? accessory.addService(this.hap.Service.Battery), BatteryLevel: accessory.context.BatteryBatteryLevel ?? 100, StatusLowBattery: accessory.context.BatteryStatusLowBattery ?? this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL, ChargingState: accessory.context.ChargingState ?? this.hap.Characteristic.ChargingState.NOT_CHARGING, }; accessory.context.Battery = this.Battery; // Seed context keys for updateCharacteristic change detection accessory.context.BatteryBatteryLevel ??= this.Battery.BatteryLevel; accessory.context.BatteryStatusLowBattery ??= this.Battery.StatusLowBattery; // Initialize Battery Characteristics this.Battery.Service .setCharacteristic(this.hap.Characteristic.Name, this.Battery.Name) .setCharacteristic(this.hap.Characteristic.ChargingState, this.hap.Characteristic.ChargingState.NOT_CHARGEABLE) .getCharacteristic(this.hap.Characteristic.BatteryLevel) .onGet(() => { return this.Battery.BatteryLevel; }); this.Battery.Service .getCharacteristic(this.hap.Characteristic.StatusLowBattery) .onGet(() => { return this.Battery.StatusLowBattery; }); // Initial Device Refresh this.refreshStatus(); // Subscribe to august changes. PubNub subscriptions are independent of // the HTTP session and survive refreshAugustSession() — no resubscribe // needed. The August.subscribe() call uses its own internal August // instance dedicated to PubNub, separate from platform.augustConfig. this.subscribeAugust(); // Polling is now owned by the platform. AugustPlatform.startPolling() // iterates registered locks serially and short-circuits on the first // failure, so a network outage produces ONE timeout per cycle instead // of N (one per lock). The previous per-lock rxjs interval that lived // here was the source of the log-spam cascade after router restarts. // Watch for Lock change events // We put in a debounce of 100ms so we don't make duplicate calls if (!device.lock?.hide_lock) { this.doLockUpdate .pipe(tap(() => { this.lockUpdateInProgress = true; }), debounceTime(this.devicePushRate * 1000)) .subscribe(async () => { try { await this.pushChanges(); } catch (e) { await this.statusCode('pushChanges', e); await this.errorLog(`doLockUpdate pushChanges: ${e.message ?? e}`); } this.lockUpdateInProgress = false; }); } } /** * Parse the device status from the August api */ async parseStatus() { await this.debugLog('parseStatus'); const retryCount = 1; if (this.lockStatus) { if (this.lockStatus.state) { // Lock Mechanism this.platform.augustConfig?.addSimpleProps(this.lockStatus); if (this.LockMechanism && (this.lockStatus.state.unlocking || this.lockStatus.state.locking)) { await this.warnLog(`LockCurrentState: ${this.LockMechanism.LockCurrentState}, locking/unlocking parseStatus` + ` lockStatus: ${JSON.stringify(this.lockStatus)}`); } if (!this.device.lock?.hide_lock && this.LockMechanism?.Service && (this.lockStatus.state.locked !== this.lockStatus.state.unlocked)) { this.LockMechanism.LockCurrentState = this.lockStatus.state.locked ? this.hap.Characteristic.LockCurrentState.SECURED : this.lockStatus.state.unlocked ? this.hap.Characteristic.LockCurrentState.UNSECURED : retryCount > 1 ? this.hap.Characteristic.LockCurrentState.JAMMED : this.hap.Characteristic.LockCurrentState.UNKNOWN; if (!this.lockUpdateInProgress) { this.LockMechanism.LockTargetState = this.LockMechanism.LockCurrentState; } if (this.LockMechanism.LockCurrentState === this.hap.Characteristic.LockCurrentState.UNKNOWN) { await this.warnLog(`LockCurrentState: ${this.LockMechanism.LockCurrentState}, (UNKNOWN) parseStatus` + ` lockStatus: ${JSON.stringify(this.lockStatus)}`); } await this.debugLog(`LockCurrentState: ${this.LockMechanism.LockCurrentState}`); await this.debugLog(`LockTargetState: ${this.LockMechanism.LockTargetState}`); } // Contact Sensor if (!this.device.lock?.hide_contactsensor && this.ContactSensor?.Service) { // ContactSensorState this.ContactSensor.ContactSensorState = this.lockStatus.state.open ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.lockStatus.state.closed ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED : this.lockStatus.doorState?.includes('open') ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.lockStatus.doorState?.includes('closed') ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED : this.ContactSensor.ContactSensorState; await this.debugLog(`ContactSensorState: ${this.ContactSensor.ContactSensorState}`); } } else { await this.debugWarnLog(`lockStatus state: ${JSON.stringify(this.lockStatus)}`); } } if (this.lockDetails) { // BatteryLevel this.Battery.BatteryLevel = Math.min(Math.max(Number((this.lockDetails.battery * 100).toFixed()), 0), 100); await this.debugLog(`BatteryLevel: ${this.Battery.BatteryLevel}`); // StatusLowBattery this.Battery.StatusLowBattery = this.Battery.BatteryLevel < 15 ? this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL; await this.debugLog(`StatusLowBattery: ${this.Battery.StatusLowBattery}`); // Firmware Version if (this.accessory.context.currentFirmwareVersion !== this.lockDetails.currentFirmwareVersion) { await this.warnLog(`Firmware Version changed to Current Firmware Version: ${this.lockDetails.currentFirmwareVersion}`); this.accessory .getService(this.hap.Service.AccessoryInformation) .setCharacteristic(this.hap.Characteristic.HardwareRevision, this.lockDetails.currentFirmwareVersion) .setCharacteristic(this.hap.Characteristic.FirmwareRevision, this.lockDetails.currentFirmwareVersion) .getCharacteristic(this.hap.Characteristic.FirmwareRevision) .updateValue(this.lockDetails.currentFirmwareVersion); this.accessory.context.currentFirmwareVersion = this.lockDetails.currentFirmwareVersion; } } } /** * Parse the device status from the August api */ async parseEventStatus() { await this.debugLog('parseEventStatus'); const retryCount = 1; if (this.lockEvent) { if (this.lockEvent.state) { this.debugLog(`lockEvent: ${JSON.stringify(this.lockEvent)}`); // Lock Mechanism this.platform.augustConfig?.addSimpleProps(this.lockEvent); if (this.LockMechanism && (this.lockEvent.state.unlocking || this.lockEvent.state.locking)) { await this.debugLog(`is ${this.lockEvent.state.unlocking ? 'Unlocking' : this.lockEvent.state.locking ? 'Locking' : ''}, parseEventStatus` + ` lockEventState: ${JSON.stringify(this.lockEvent.state)}`); return; } if (!this.device.lock?.hide_lock && this.LockMechanism?.Service && (this.lockEvent.state.locked !== this.lockEvent.state.unlocked)) { this.LockMechanism.LockCurrentState = this.lockEvent.state.locked ? this.hap.Characteristic.LockCurrentState.SECURED : this.lockEvent.state.unlocked ? this.hap.Characteristic.LockCurrentState.UNSECURED : retryCount > 1 ? this.hap.Characteristic.LockCurrentState.JAMMED : this.hap.Characteristic.LockCurrentState.UNKNOWN; if (!this.lockUpdateInProgress) { this.LockMechanism.LockTargetState = this.LockMechanism.LockCurrentState; } if (this.LockMechanism.LockCurrentState === this.hap.Characteristic.LockCurrentState.UNKNOWN) { await this.warnLog(`LockCurrentState: ${this.LockMechanism.LockCurrentState}, (UNKNOWN) parseEventStatus` + ` lockEvent: ${JSON.stringify(this.lockEvent)}`); } await this.debugLog(`LockCurrentState: ${this.LockMechanism.LockCurrentState}`); await this.debugLog(`LockTargetState: ${this.LockMechanism.LockTargetState}`); } // Contact Sensor if (!this.device.lock?.hide_contactsensor && this.ContactSensor?.Service) { // ContactSensorState this.ContactSensor.ContactSensorState = this.lockEvent.state.open ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.lockEvent.state.closed ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED : this.lockEvent.doorState === 'open' ? this.hap.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : this.lockEvent.doorState === 'closed' ? this.hap.Characteristic.ContactSensorState.CONTACT_DETECTED : this.ContactSensor.ContactSensorState; await this.debugLog(`ContactSensorState: ${this.ContactSensor.ContactSensorState}`); } } else { await this.debugWarnLog(`lovckEvent state: ${JSON.stringify(this.lockStatus)}`); } } } /** * Asks the August Home API for the latest device information. * * Used for the initial fetch in the constructor. Periodic polling is * driven by AugustPlatform.startPolling(), which calls applyRefresh() * directly with details fetched serially across all registered locks. * * On failure: returns silently. The ConnectivityManager has already * classified the error and (if it's a network error) put itself into * 'degraded' state — a probe is scheduled and the next poll cycle * will skip until the probe confirms recovery. */ async refreshStatus() { if (this.deviceRefreshRate === 0) { await this.debugLog(`(refreshStatus) deviceRefreshRate: ${this.deviceRefreshRate}`); return; } if (!this.platform.connectivity) { await this.debugLog('(refreshStatus) connectivity not initialized — skipping'); return; } const lockDetails = await this.platform.connectivity.execute(`refreshStatus ${this.accessory.displayName}`, client => client.details(this.device.lockId)); if (lockDetails === undefined) { // Either offline, or the call failed. ConnectivityManager has the // state machine; nothing to do here. return; } await this.applyRefresh(lockDetails); } /** * Apply a freshly-fetched lockDetails payload to HomeKit characteristics. * * Public so AugustPlatform.startPolling() can hand-off the result of * its serial details() call without going through refreshStatus() * (which would re-fetch). */ async applyRefresh(lockDetails) { await this.debugSuccessLog(`(applyRefresh) lockDetails: ${JSON.stringify(lockDetails)}`); this.lockDetails = lockDetails; this.lockStatus = lockDetails.LockStatus; await this.parseStatus(); await this.updateHomeKitCharacteristics(); } /** * Pushes the requested changes to the August API */ async pushChanges() { if (!this.LockMechanism) { await this.errorLog(`lockTargetState: ${JSON.stringify(this.LockMechanism)}`); return; } const targetState = this.LockMechanism.LockTargetState; const currentState = this.LockMechanism.LockCurrentState; if (targetState === currentState) { await this.debugLog(`No changes, LockTargetState: ${targetState}, LockCurrentState: ${currentState}`); this.LockMechanism.LockTargetState = currentState === this.hap.Characteristic.LockCurrentState.SECURED ? this.hap.Characteristic.LockTargetState.SECURED : this.hap.Characteristic.LockTargetState.UNSECURED; await this.updateHomeKitCharacteristics(); return; } await this.debugLog(`Making API call - Target: ${targetState}, Current: ${currentState}`); if (!this.platform.connectivity) { await this.errorLog('pushChanges: connectivity not initialized'); return; } try { // throwOnOffline:true: user-initiated lock/unlock should fail // visibly to HomeKit when the network is down rather than // silently dropping the request. The ConnectivityManager // classifies the error and updates state, but we re-raise so // HomeKit sees a real failure. // // The manager handles auth/network failures internally // (rebuilds the client on auth, schedules a probe on network), // so the explicit retry block that used to live here is gone. // If the user retries the action, the manager will either be // recovered by then or still offline (and throw OfflineError // again). await this.platform.connectivity.execute(`pushChanges ${this.device.lockId}`, async (client) => { if (targetState === this.hap.Characteristic.LockTargetState.UNSECURED) { await client.unlock(this.device.lockId); } else { await client.lock(this.device.lockId); } }, { throwOnOffline: true }); await this.successLog(`Sending request to August API: ${targetState === 1 ? 'Locked' : 'Unlocked'}`); await this.updateHomeKitCharacteristics(); } catch (e) { await this.statusCode('pushChanges', e); await this.errorLog(`pushChanges: ${e.message ?? e}`); } } /** * Updates the status for each of the HomeKit Characteristics */ async updateHomeKitCharacteristics() { await this.debugLog('updateHomeKitCharacteristics'); // Lock Mechanism if (!this.device.lock?.hide_lock && this.LockMechanism?.Service) { // LockTargetState await this.updateCharacteristic(this.LockMechanism.Service, 'LockMechanism', this.hap.Characteristic.LockTargetState, this.LockMechanism.LockTargetState, 'LockTargetState'); // LockCurrentState await this.updateCharacteristic(this.LockMechanism.Service, 'LockMechanism', this.hap.Characteristic.LockCurrentState, this.LockMechanism.LockCurrentState, 'LockCurrentState', 1, 'Locked', 'Unlocked'); } // Battery // BatteryLevel await this.updateCharacteristic(this.Battery.Service, 'Battery', this.hap.Characteristic.BatteryLevel, this.Battery.BatteryLevel, 'BatteryLevel'); // StatusLowBattery await this.updateCharacteristic(this.Battery.Service, 'Battery', this.hap.Characteristic.StatusLowBattery, this.Battery.StatusLowBattery, 'StatusLowBattery'); // Contact Sensor if (!this.device.lock?.hide_contactsensor && this.ContactSensor?.Service) { // ContactSensorState await this.updateCharacteristic(this.ContactSensor.Service, 'ContactSensor', this.hap.Characteristic.ContactSensorState, this.ContactSensor.ContactSensorState, 'ContactSensorState', 1, 'Opened', 'Closed'); } } async setLockTargetState(value) { if (this.LockMechanism) { if (this.LockMechanism.LockTargetState !== this.LockMechanism.LockCurrentState) { await this.debugLog(`Set LockTargetState: ${value}`); } else { await this.debugLog(`No changes, LockTargetState: ${this.LockMechanism.LockTargetState},` + ` LockCurrentState: ${this.LockMechanism.LockCurrentState}`); } this.accessory.context.LockMechanismLockTargetState = this.LockMechanism.LockTargetState = value; this.doLockUpdate.next(); } } // Holds the PubNub status-listener unsubscribe alongside the channel // unsubscribe (this.pubnubUnsubscribe). Both are owned by the same // August instance underneath; both need to be torn down on lock // removal. pubnubStatusUnsubscribe; // The August instance that owns the PubNub WebSocket for THIS lock. // Kept so we can register status listeners on it. Distinct from // platform.connectivity's August (which is for HTTP only). pubnubAugust; async subscribeAugust() { await this.debugLog('subscribeAugust'); await this.platform.augustCredentials(); if (this.config.credentials) { // Clean up any previous subscription before creating a new one. // This is defensive: subscribeAugust() is currently only called once // from the constructor, but this makes the method idempotent and safe // to call again in the future. this.tearDownPubNubSubscription(); const normalizedCredentials = await this.platform.getNormalizedCredentials(); // Construct an August instance manually so we can register a // PubNub status listener on the same instance that owns the // channel subscription. The previous code used the static // August.subscribe(), which hides the instance and made it // impossible to register status listeners. this.pubnubAugust = new August(normalizedCredentials); // Status listener: feed PubNub reconnect events into the // platform's ConnectivityManager. PubNub's WebSocket reconnects // seconds before HTTP polling would notice the network is back, // so this is the fastest signal connectivity has recovered. // Defensive guard around connectivity in case this method is // ever called before the manager is initialized. this.pubnubStatusUnsubscribe = this.pubnubAugust.onPubNubStatus((status) => { const category = status?.category; if (category === 'PNConnectedCategory' || category === 'PNReconnectedCategory') { this.debugLog(`PubNub status: ${category} — signalling connectivity recovery`); this.platform.connectivity?.onPubNubReconnect(); } else if (category === 'PNNetworkDownCategory' || category === 'PNDisconnectedCategory') { this.debugLog(`PubNub status: ${category}`); } }); this.pubnubUnsubscribe = await this.pubnubAugust.subscribe(this.device.lockId, async (AugustEvent, timestamp) => { await this.debugLog(`AugustEvent: ${JSON.stringify(AugustEvent)}, ${JSON.stringify(timestamp)}`); // Update HomeKit this.lockEvent = AugustEvent; await this.parseEventStatus(); await this.updateHomeKitCharacteristics(); }); } else { await this.errorLog('subscribeAugust: No credentials'); } } /** * Tear down the PubNub subscription for this lock. Safe to call multiple * times and when no subscription exists. Called on lock removal to free * the PubNub instance, WebSocket connection, and listener. */ tearDownPubNubSubscription() { if (this.pubnubStatusUnsubscribe) { try { this.pubnubStatusUnsubscribe(); } catch (e) { this.debugLog(`Error tearing down PubNub status listener: ${e.message || e}`); } this.pubnubStatusUnsubscribe = undefined; } if (this.pubnubUnsubscribe) { try { this.pubnubUnsubscribe(); } catch (e) { this.debugLog(`Error tearing down PubNub subscription: ${e.message || e}`); } this.pubnubUnsubscribe = undefined; } if (this.pubnubAugust) { try { this.pubnubAugust.destroy(); } catch (e) { this.debugLog(`Error destroying PubNub August instance: ${e.message || e}`); } this.pubnubAugust = undefined; } } } //# sourceMappingURL=lock.js.map