UNPKG

@homebridge-plugins/homebridge-august

Version:

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

382 lines 15.4 kB
import August, { AbortedError, InvalidAuth, NetworkError, TimeoutError } from 'august-yale'; /** * Backoff schedule for probes after the network is detected as broken. * Capped at 5 minutes; ±10% jitter is applied at use to avoid synchronized * probes across instances during ISP-level events. */ const BACKOFF_SCHEDULE_MS = [5_000, 10_000, 20_000, 40_000, 80_000, 160_000, 300_000]; /** Probe timeout — short on purpose. A healthy August API responds in <1s. */ const PROBE_TIMEOUT_MS = 8_000; /** * How often to emit a heartbeat log line while offline. * * Without it the log goes silent after the initial healthy → degraded → * offline transition, making it impossible to distinguish "state machine * is working hard, probes are failing every backoff slot" from "state * machine is silently stuck". The heartbeat surfaces probe attempts and * the most recent failure so future regressions are debuggable from the * log alone. * * One hour matches the typical "I checked on it later" cadence; not so * frequent it becomes log spam during a real ISP outage. */ const OFFLINE_HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; export class OfflineError extends Error { constructor(label) { super(`Operation skipped — connectivity is offline: ${label}`); this.name = 'OfflineError'; } } export class ConnectivityManager { log; credentialsFactory; onClientChanged; state = 'healthy'; client; probeTimer; heartbeatTimer; backoffIndex = 0; rebuildInFlight; listeners = new Set(); // Diagnostic counters. Reset on transition out of offline. Used by the // heartbeat log so an ongoing outage is visible in the log without // having to trace per-probe details. offlineSinceMs; probeAttempts = 0; lastFailureMessage; constructor(log, credentialsFactory, /** * Called whenever the manager builds or rebuilds the August client. * The platform uses this to keep its public `augustConfig` field in * sync, so existing call sites that read `platform.augustConfig` * continue to see the current client without going through the * manager's accessor. */ onClientChanged = () => { }) { this.log = log; this.credentialsFactory = credentialsFactory; this.onClientChanged = onClientChanged; } async init() { if (this.client) { return; } this.client = new August(await this.credentialsFactory()); this.onClientChanged(this.client); } /** * Single entry point for every August API call. Wraps the call, * classifies any error, and updates connectivity state. * * Default behavior: returns undefined when state is 'offline' or when * the call fails with a network/auth error. The platform-level poller * treats undefined as 'skip this cycle'. Set throwOnOffline:true for * user-initiated calls (lock/unlock) where HomeKit needs to see a * real failure. * * Transient errors (4xx other than 401) are always rethrown — they're * not connectivity issues and the caller needs to handle them. */ async execute(label, fn, opts = {}) { if (this.state === 'offline') { if (opts.throwOnOffline) { throw new OfflineError(label); } return undefined; } if (!this.client) { if (opts.throwOnOffline) { throw new OfflineError(label); } return undefined; } try { const result = await fn(this.client); this.reportSuccess(); return result; } catch (e) { const kind = this.classify(e); this.reportFailure(label, kind); if (kind === 'transient') { // Not a connectivity issue — let the caller see the real error. throw e; } if (opts.throwOnOffline) { throw e; } return undefined; } } /** * External signal: PubNub WebSocket reported a (re)connection. PubNub * recovers from a network blip seconds before HTTP polling would notice, * so this is the fastest path back to healthy. Triggers an immediate * probe that bypasses the current backoff delay. */ onPubNubReconnect() { if (this.state === 'healthy') { return; } const offlineFor = this.offlineSinceMs ? ` (offline for ${Math.floor((Date.now() - this.offlineSinceMs) / 1000)}s)` : ''; this.log.info(`Connectivity: PubNub reconnect signal received in ${this.state}${offlineFor} — probing immediately`); this.clearProbeTimer(); void this.runProbe(); } getState() { return this.state; } /** * Returns the current August client. Used by code paths that need * direct access (e.g. addSimpleProps, which is a pure utility) and * during the transition period while not all callers are routed * through execute(). New code should prefer execute(). */ getClient() { return this.client; } /** * Subscribe to connectivity state changes. Used by the platform-level * poller to pause polling when offline and resume when healthy. * Returns an unsubscribe function. */ onStateChange(l) { this.listeners.add(l); return () => this.listeners.delete(l); } shutdown() { this.clearProbeTimer(); this.stopHeartbeat(); this.client?.destroy(); this.client = undefined; this.onClientChanged(undefined); this.listeners.clear(); } // --- internals --- reportSuccess() { if (this.state !== 'healthy') { this.transitionTo('healthy'); } this.backoffIndex = 0; } reportFailure(label, kind) { if (kind === 'auth') { // Session expired. Rebuild the client; do not change connectivity state. this.log.info(`Connectivity: auth expiry on "${label}" — rebuilding client`); void this.rebuildClient('auth expiry'); return; } if (kind === 'transient') { // 4xx/422/429/etc. — caller's responsibility, not a network issue. return; } // kind === 'network' if (this.state === 'healthy') { this.log.warn(`Connectivity: network error on "${label}" — entering degraded state`); this.transitionTo('degraded'); this.backoffIndex = 0; this.scheduleProbe(); } // If already degraded/offline, the probe timer is already running. } scheduleProbe() { this.clearProbeTimer(); const idx = Math.min(this.backoffIndex, BACKOFF_SCHEDULE_MS.length - 1); const base = BACKOFF_SCHEDULE_MS[idx]; const jittered = base * (0.9 + Math.random() * 0.2); this.probeTimer = setTimeout(() => void this.runProbe(), jittered); } async runProbe() { this.probeAttempts++; let client = this.client; if (!client) { // No client at all — rebuild from credentials. This branch is // hit only at startup if init() hasn't completed; in steady // state we always have a client. this.log.warn(`Connectivity: probe attempt #${this.probeAttempts} — no client to probe; trying to (re)build`); try { await this.rebuildClient('probe with no client'); } catch (e) { this.lastFailureMessage = `probe-build: ${e?.message ?? e}`; this.scheduleProbe(); return; } client = this.client; if (!client) { // rebuildClient resolved but didn't actually populate this.client. // Defensive: don't proceed without a client; reschedule and try // again on the next backoff slot. this.lastFailureMessage = 'probe-build: rebuildClient produced no client'; this.scheduleProbe(); return; } } // Reset the transport BEFORE probing. // // The previous implementation probed against the existing client's // socket pool. That pool may hold stale half-open sockets from // before the outage; probing against them just keeps failing on // dead connections, so the system stayed offline even after the // network had recovered. The PubNub-triggered immediate probe // hit the same wall. // // resetTransport() throws away the dispatcher and creates a fresh // one without losing auth state, the session token, or // configuration. Distinct from destroy() + new August() — that // would force a re-auth round-trip on the next call, which itself // can fail on a still-flaky network and leave us stuck. With just // a transport reset, the next request gets a clean connection // pool and reuses the cached session token; if it succeeds, we're // healthy in one round-trip. client.resetTransport(); this.log.info(`Connectivity: probe attempt #${this.probeAttempts} (state=${this.state})`); try { await Promise.race([ client.locks(), new Promise((_resolve, reject) => setTimeout(() => reject(new TimeoutError('probe timed out')), PROBE_TIMEOUT_MS)), ]); this.log.info(`Connectivity: probe attempt #${this.probeAttempts} succeeded`); this.transitionTo('healthy'); this.backoffIndex = 0; } catch (e) { this.lastFailureMessage = `${this.classify(e)}: ${e?.message ?? e}`; if (this.state !== 'offline') { this.log.warn('Connectivity: probe failed — entering offline state'); this.transitionTo('offline'); } this.backoffIndex = Math.min(this.backoffIndex + 1, BACKOFF_SCHEDULE_MS.length - 1); this.scheduleProbe(); } } async rebuildClient(reason) { if (this.rebuildInFlight) { return this.rebuildInFlight; } this.rebuildInFlight = (async () => { this.log.info(`Connectivity: rebuilding August client (${reason})`); const old = this.client; this.client = new August(await this.credentialsFactory()); this.onClientChanged(this.client); old?.destroy(); })(); try { await this.rebuildInFlight; } finally { this.rebuildInFlight = undefined; } } classify(e) { // august-yale wraps every transport-level fetch failure into one of // its typed exceptions before the error reaches us. We rely on those // types here rather than re-implementing undici's 24-class taxonomy. // // - NetworkError (and its TimeoutError subclass) covers connect // timeouts, socket resets, headers/body timeouts, DNS failures, // TLS proxy failures, malformed responses, etc. // // - AbortedError reflects an in-flight request racing with our own // teardown (e.g. during rebuildClient). NOT 'network': the consumer // intent was to tear down, so we don't want this to drive the // state machine into degraded. // // - InvalidAuth is august-yale's auth-failure type. Maps to 'auth'. // // Anything else (including programmer errors and server-answered // 4xx/5xx that august-yale leaves unwrapped) is 'transient' — bubble // back to the caller, don't change connectivity state. if (e instanceof NetworkError) { return 'network'; } if (e instanceof AbortedError) { return 'transient'; } if (e instanceof TimeoutError) { // Defensive: TimeoutError is also a NetworkError so the first // check should match. Kept for clarity in case of future // exception hierarchy changes upstream. return 'network'; } if (e instanceof InvalidAuth) { return 'auth'; } // Fallback for HTTP-level errors that august-yale leaves unwrapped // (e.g. server answered with a non-2xx without bare-401 semantics). const err = e; if (err?.statusCode === 401) { return 'auth'; } if (err?.statusCode === 502 || err?.statusCode === 503 || err?.statusCode === 504) { // 5xx responses where the server is reachable but reporting an // upstream issue. Treat as network — these are recoverable by // waiting and retrying (matches the state-machine semantic). return 'network'; } return 'transient'; } transitionTo(next) { const prev = this.state; if (prev === next) { return; } this.state = next; this.log.info(`Connectivity: ${prev} -> ${next}`); // Heartbeat lifecycle: start when entering offline, stop when leaving. // offlineSinceMs is set on entry; probe-attempt count is NOT reset // here because runProbe() already incremented it for the failure // that triggered this transition. if (next === 'offline') { this.offlineSinceMs = Date.now(); this.startHeartbeat(); } else if (prev === 'offline') { this.stopHeartbeat(); this.offlineSinceMs = undefined; this.probeAttempts = 0; } for (const l of this.listeners) { try { l(next, prev); } catch (e) { this.log.error(`Connectivity listener threw: ${e?.message ?? e}`); } } } startHeartbeat() { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => this.emitHeartbeat(), OFFLINE_HEARTBEAT_INTERVAL_MS); // Allow Node to exit if this is the only outstanding timer; we // don't want the heartbeat to keep the process alive on shutdown. if (typeof this.heartbeatTimer.unref === 'function') { this.heartbeatTimer.unref(); } } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; } } emitHeartbeat() { if (this.state !== 'offline' || this.offlineSinceMs === undefined) { return; } const minutes = Math.floor((Date.now() - this.offlineSinceMs) / 60000); const lastFailure = this.lastFailureMessage ?? 'unknown'; this.log.warn(`Connectivity: still offline after ${minutes} min — ` + `${this.probeAttempts} probe attempt${this.probeAttempts === 1 ? '' : 's'}, ` + `last failure: ${lastFailure}`); } clearProbeTimer() { if (this.probeTimer) { clearTimeout(this.probeTimer); this.probeTimer = undefined; } } } //# sourceMappingURL=connectivity-manager.js.map