@homebridge-plugins/homebridge-august
Version:
The August plugin allows you to access your August & Yale device(s) from HomeKit.
325 lines • 16.8 kB
JavaScript
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