UNPKG

@homebridge-plugins/homebridge-august

Version:

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

325 lines 16.8 kB
import August from 'august-yale'; import { timer } from 'rxjs'; import { exhaustMap } from 'rxjs/operators'; import { AugustPlatform } from './Platform.HAP.js'; import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'; /** * AugustMatterPlatform * Extends AugustPlatform to register August locks as Matter DoorLock accessories * instead of HAP accessories. Used when Homebridge Matter support is available, * enabled, and not disabled by the `disableMatter` config option. */ export class AugustMatterPlatform extends AugustPlatform { // Track restored Matter cached accessories matterAccessories = new Map(); // HAP accessories staged for removal after Matter registration succeeds. // Deferred to avoid leaving users with zero accessories if Matter init fails. pendingHapCleanup = new Map(); // PubNub unsubscribe functions keyed by accessory UUID. // Must be captured from August.subscribe() and called on lock removal to // prevent accumulating orphaned PubNub instances (equivalent of the fix in // lock.ts / PR #206 for the HAP path). matterPubNubUnsubscribes = new Map(); // RxJS polling subscriptions keyed by accessory UUID. Must be tracked so // they can be unsubscribed when a lock is removed or re-registered, otherwise // the polling timer keeps firing forever even after the accessory is gone. matterPollingSubscriptions = new Map(); /** * Called when homebridge restores cached HAP accessories from disk at startup. * HAP accessories are staged here and removed only after Matter registration * succeeds for the same lock, so that if Matter init fails the user is not * left with zero accessories. */ async configureAccessory(accessory) { this.log.debug(`Staging cached HAP accessory for deferred cleanup: ${accessory.displayName}`); this.pendingHapCleanup.set(accessory.UUID, accessory); } /** * Called when homebridge restores cached Matter accessories from disk at startup. * Stores the restored accessory so we can update it later during device discovery. */ configureMatterAccessory(accessory) { this.log.debug(`Loading cached Matter accessory: ${accessory.displayName}`); this.matterAccessories.set(accessory.UUID, accessory); } /** * Run device discovery, then sweep any staged HAP accessories that were not * handled during discovery. * * The normal deferred-cleanup flow removes a staged HAP accessory when Lock() * is called for its UUID and Matter registration succeeds. But if a lock has * been removed from the user's August account entirely, Lock() is never * called for its UUID — leaving the staged HAP accessory registered with * Homebridge indefinitely and the pendingHapCleanup map growing on every * restart. * * After discoverDevices() finishes, any remaining entries in * pendingHapCleanup correspond to locks no longer in the account, so it is * safe to unregister them. */ async discoverDevices() { await super.discoverDevices(); await this.sweepUnhandledHapAccessories(); } async sweepUnhandledHapAccessories() { if (this.pendingHapCleanup.size === 0) { return; } const accessories = Array.from(this.pendingHapCleanup.values()); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories); for (const accessory of accessories) { this.log.debug(`Removing unhandled cached HAP accessory (no matching lock found): ${accessory.displayName}`); } this.pendingHapCleanup.clear(); } /** * Register an August lock as a Matter DoorLock accessory. * Overrides the HAP-based Lock() method from AugustPlatform. */ async Lock(device) { const matterApi = this.api.matter; if (!matterApi) { await this.errorLog('Matter API is not available. Cannot register Matter accessory.'); return; } const uuid = matterApi.uuid.generate(device.lockId); // Determine whether the device should be registered. If not, clean up any stale Matter // accessory from a previous session and return early. const shouldRegister = await this.registerDevice(device); if (!shouldRegister) { // Tear down all per-lock state atomically. Whether there's a stale Matter // accessory from a previous session, a PubNub subscription, or a polling // subscription, they all need to go together when a lock is hidden or // removed from the account. await this.tearDownMatterLock(uuid, device.LockName, matterApi); await this.debugErrorLog(`Unable to Register: ${device.LockName}, Lock ID: ${device.lockId} Check Config to see if is being Hidden.`); return; } const displayName = device.configLockName ? await this.validateAndCleanDisplayName(device.configLockName, 'configLockName', device.configLockName) : await this.validateAndCleanDisplayName(device.LockName, 'LockName', device.LockName); const existingAccessory = this.matterAccessories.get(uuid); const accessory = { UUID: uuid, displayName, deviceType: matterApi.deviceTypes.DoorLock, serialNumber: device.SerialNumber || device.lockId, manufacturer: 'August Home Inc.', model: device.skuNumber || 'August Lock', firmwareRevision: device.currentFirmwareVersion || this.version || '0.0.0', hardwareRevision: device.currentFirmwareVersion || this.version || '0.0.0', context: { lockId: device.lockId, }, clusters: { doorLock: { // Use DoorLock.LockState enum for type safety and readability: // NotFullyLocked = 0 (unknown until first poll), Locked = 1, Unlocked = 2. // timer(0, ...) fires immediately so accurate state is pushed on the first poll tick. lockState: matterApi.types.DoorLock.LockState.NotFullyLocked, lockType: 0, // 0 = DeadBolt actuatorEnabled: true, operatingMode: 0, // 0 = Normal }, }, handlers: { doorLock: { lockDoor: async () => { try { await this.augustCredentials(); if (!this.connectivity) { throw new Error('Connectivity not initialized'); } await this.connectivity.execute(`Matter lockDoor ${device.lockId}`, client => client.lock(device.lockId), { throwOnOffline: true }); await this.successLog(`Matter: Locked ${displayName}`); await matterApi.updateAccessoryState(uuid, 'doorLock', { lockState: matterApi.types.DoorLock.LockState.Locked }); } catch (e) { await this.errorLog(`Matter: lockDoor failed: ${e.message ?? e}`); } }, unlockDoor: async () => { try { await this.augustCredentials(); if (!this.connectivity) { throw new Error('Connectivity not initialized'); } await this.connectivity.execute(`Matter unlockDoor ${device.lockId}`, client => client.unlock(device.lockId), { throwOnOffline: true }); await this.successLog(`Matter: Unlocked ${displayName}`); await matterApi.updateAccessoryState(uuid, 'doorLock', { lockState: matterApi.types.DoorLock.LockState.Unlocked }); } catch (e) { await this.errorLog(`Matter: unlockDoor failed: ${e.message ?? e}`); } }, }, }, }; if (existingAccessory) { await this.infoLog(`Restoring existing Matter accessory from cache: ${displayName}, Lock ID: ${device.lockId}`); } else { await this.infoLog(`Adding new Matter accessory: ${displayName}, Lock ID: ${device.lockId}`); } // Register (or re-register to update handlers/context) with Homebridge Matter await matterApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); this.matterAccessories.set(uuid, accessory); // After Matter registration succeeds, clean up any staged HAP accessory for this lock. // This deferred removal ensures that if Matter registration had failed, the user would // have kept the HAP accessory rather than being left with no accessory at all. const stagedHapAccessory = this.pendingHapCleanup.get(uuid); if (stagedHapAccessory) { this.log.debug(`Removing migrated HAP accessory after successful Matter registration: ${stagedHapAccessory.displayName}`); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [stagedHapAccessory]); this.pendingHapCleanup.delete(uuid); } // Subscribe to August real-time events for instant state updates await this.subscribeAugustMatter(device, uuid, matterApi); // Start polling for periodic status refresh (fires immediately at 0ms for accurate initial state) this.startMatterStatusPolling(device, uuid, matterApi); } /** * Subscribe to August real-time lock events and update the Matter DoorLock state. * Captures the unsubscribe function returned by August.subscribe() to allow proper * cleanup on lock removal (equivalent of the fix in lock.ts / PR #206 for HAP). */ async subscribeAugustMatter(device, uuid, matterApi) { try { await this.augustCredentials(); if (this.config.credentials) { // Tear down any previous subscription for this lock before creating a new one // (defensive: makes this method safe to call on re-registration). const existingUnsubscribe = this.matterPubNubUnsubscribes.get(uuid); if (existingUnsubscribe) { try { existingUnsubscribe(); } catch { /* ignore */ } this.matterPubNubUnsubscribes.delete(uuid); } const normalizedCredentials = await this.getNormalizedCredentials(); const unsubscribe = await August.subscribe(normalizedCredentials, device.lockId, async (augustEvent) => { await this.debugLog(`Matter AugustEvent: ${JSON.stringify(augustEvent)}`); if (augustEvent.state) { const lockState = this.mapLockState(augustEvent.state, matterApi); try { await matterApi.updateAccessoryState(uuid, 'doorLock', { lockState }); await this.debugLog(`Matter: Updated lockState to ${lockState} for ${device.LockName}`); } catch (e) { await this.errorLog(`Matter: updateAccessoryState failed: ${e.message ?? e}`); } } }); if (typeof unsubscribe === 'function') { this.matterPubNubUnsubscribes.set(uuid, unsubscribe); } } } catch (e) { await this.errorLog(`Matter: subscribeAugust failed: ${e.message ?? e}`); } } /** * Map an August lock state (from details() or a PubNub event) to a Matter * DoorLock.LockState enum value. * * If both `locked` and `unlocked` are set simultaneously (unexpected), we * treat the lock as Locked (fail-safe). If neither is set, we report * NotFullyLocked, which is the correct Matter state for "position unknown". */ mapLockState(state, matterApi) { if (state.locked) { return matterApi.types.DoorLock.LockState.Locked; } if (state.unlocked) { return matterApi.types.DoorLock.LockState.Unlocked; } return matterApi.types.DoorLock.LockState.NotFullyLocked; } /** * Fetch the current lock status from the August API and update the Matter DoorLock state. * * Routed through the platform's ConnectivityManager: failures are * classified and the state machine handles retry/backoff/probe-driven * recovery. No manual session-refresh-and-retry block is needed here * — when the network is down, execute() returns undefined and the * next poll cycle will skip until the manager confirms recovery. */ async fetchAndUpdateMatterLockState(device, uuid, matterApi) { if (!this.connectivity) { await this.debugLog('Matter: connectivity not initialized — skipping'); return; } const lockDetails = await this.connectivity.execute(`Matter poll ${device.lockId}`, client => client.details(device.lockId)); if (lockDetails === undefined) { // execute() returned undefined: either offline, or the call // failed and the manager has already taken care of state. return; } const details = lockDetails; if (!details?.LockStatus?.state) { return; } const lockState = this.mapLockState(details.LockStatus.state, matterApi); await matterApi.updateAccessoryState(uuid, 'doorLock', { lockState }); await this.debugLog(`Matter: Poll updated lockState to ${lockState} for ${device.LockName}`); } /** * Poll the August API for lock status at the configured refresh rate and update the Matter * DoorLock state. Uses `timer(0, ...)` for an immediate first fetch (accurate initial state) * and `exhaustMap` to prevent overlapping concurrent requests. * * Idempotent: disposes any previous polling subscription for the same UUID * before starting a new one. The subscription is tracked in * matterPollingSubscriptions so it can be disposed on lock removal — otherwise * the timer keeps firing forever even after the accessory is gone. */ startMatterStatusPolling(device, uuid, matterApi) { const refreshRate = this.platformRefreshRate ?? 30; if (refreshRate === 0) { return; } // Dispose any previous polling subscription for this UUID before starting a new one const existing = this.matterPollingSubscriptions.get(uuid); if (existing) { existing.unsubscribe(); this.matterPollingSubscriptions.delete(uuid); } const subscription = timer(0, refreshRate * 1000) .pipe(exhaustMap(() => this.fetchAndUpdateMatterLockState(device, uuid, matterApi))) .subscribe(); this.matterPollingSubscriptions.set(uuid, subscription); } /** * Release all resources associated with a given Matter lock UUID: * the registered Matter accessory, the PubNub subscription, and the * RxJS polling subscription. Safe to call when some or all of these * do not exist. */ async tearDownMatterLock(uuid, lockName, matterApi) { // Polling subscription — must be disposed or the timer keeps firing const pollingSub = this.matterPollingSubscriptions.get(uuid); if (pollingSub) { pollingSub.unsubscribe(); this.matterPollingSubscriptions.delete(uuid); } // PubNub subscription — calls the unsubscribe function returned by August.subscribe() const pubnubUnsubscribe = this.matterPubNubUnsubscribes.get(uuid); if (pubnubUnsubscribe) { try { pubnubUnsubscribe(); } catch { /* best-effort */ } this.matterPubNubUnsubscribes.delete(uuid); } // Registered Matter accessory const staleAccessory = this.matterAccessories.get(uuid); if (staleAccessory) { await matterApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [staleAccessory]); this.matterAccessories.delete(uuid); await this.warnLog(`Removing stale Matter accessory: ${lockName}`); } } } //# sourceMappingURL=platform.matter.js.map