• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homebridge / HAP-NodeJS / 17775229178

16 Sep 2025 06:21PM UTC coverage: 63.437%. Remained the same
17775229178

push

github

bwp91
v2.0.2

1743 of 3286 branches covered (53.04%)

Branch coverage included in aggregate %.

6250 of 9314 relevant lines covered (67.1%)

312.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.65
/src/lib/util/eventedhttp.ts
1
import { getNetAddress } from "@homebridge/ciao/lib/util/domain-formatter";
42✔
2
import assert from "assert";
42✔
3
import createDebug from "debug";
42✔
4
import { EventEmitter } from "events";
42✔
5
import { SrpServer } from "fast-srp-hap";
6
import http, { IncomingMessage, ServerResponse } from "http";
42✔
7
import net, { AddressInfo, Socket } from "net";
42✔
8
import os from "os";
42✔
9
import { CharacteristicEventNotification, EventNotification } from "../../internal-types";
10
import { CharacteristicValue, Nullable, SessionIdentifier } from "../../types";
11
import * as hapCrypto from "./hapCrypto";
42✔
12
import { getOSLoopbackAddressIfAvailable } from "./net-utils";
42✔
13
import * as uuid from "./uuid";
42✔
14

15

16
const debug = createDebug("HAP-NodeJS:EventedHTTPServer");
42✔
17
const debugCon = createDebug("HAP-NodeJS:EventedHTTPServer:Connection");
42✔
18
const debugEvents = createDebug("HAP-NodeJS:EventEmitter");
42✔
19

20
/**
21
 * @group HAP Accessory Server
22
 */
23
export type HAPUsername = string;
24
/**
25
 * @group HAP Accessory Server
26
 */
27
export type EventName = string; // "<aid>.<iid>"
28

29
/**
30
 * Simple struct to hold vars needed to support HAP encryption.
31
 *
32
 * @group Cryptography
33
 */
34
export class HAPEncryption {
42✔
35

36
  readonly clientPublicKey: Buffer;
37
  readonly secretKey: Buffer;
38
  readonly publicKey: Buffer;
39
  readonly sharedSecret: Buffer;
40
  readonly hkdfPairEncryptionKey: Buffer;
41

42
  accessoryToControllerCount = 0;
99✔
43
  controllerToAccessoryCount = 0;
99✔
44
  accessoryToControllerKey: Buffer;
45
  controllerToAccessoryKey: Buffer;
46

47
  incompleteFrame?: Buffer;
48

49
  public constructor(clientPublicKey: Buffer, secretKey: Buffer, publicKey: Buffer, sharedSecret: Buffer, hkdfPairEncryptionKey: Buffer) {
50
    this.clientPublicKey = clientPublicKey;
99✔
51
    this.secretKey = secretKey;
99✔
52
    this.publicKey = publicKey;
99✔
53
    this.sharedSecret = sharedSecret;
99✔
54
    this.hkdfPairEncryptionKey = hkdfPairEncryptionKey;
99✔
55

56
    this.accessoryToControllerKey = Buffer.alloc(0);
99✔
57
    this.controllerToAccessoryKey = Buffer.alloc(0);
99✔
58
  }
59
}
60

61
/**
62
 * @group HAP Accessory Server
63
 */
64
export const enum EventedHTTPServerEvent {
42✔
65
  LISTENING = "listening",
42✔
66
  CONNECTION_OPENED = "connection-opened",
42✔
67
  REQUEST = "request",
42✔
68
  CONNECTION_CLOSED = "connection-closed",
42✔
69
}
70

71
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
72
export declare interface EventedHTTPServer {
73

74
  on(event: "listening", listener: (port: number, address: string) => void): this;
75
  on(event: "connection-opened", listener: (connection: HAPConnection) => void): this;
76
  on(event: "request", listener: (connection: HAPConnection, request: IncomingMessage, response: ServerResponse) => void): this;
77
  on(event: "connection-closed", listener: (connection: HAPConnection) => void): this;
78

79
  emit(event: "listening", port: number, address: string): boolean;
80
  emit(event: "connection-opened", connection: HAPConnection): boolean;
81
  emit(event: "request", connection: HAPConnection, request: IncomingMessage, response: ServerResponse): boolean;
82
  emit(event: "connection-closed", connection: HAPConnection): boolean;
83

84
}
85

86
/**
87
 * EventedHTTPServer provides an HTTP-like server that supports HAP "extensions" for security and events.
88
 *
89
 * Implementation
90
 * --------------
91
 * In order to implement the "custom HTTP" server required by the HAP protocol (see HAPServer.js) without completely
92
 * reinventing the wheel, we create both a generic TCP socket server and a standard Node HTTP server.
93
 * The TCP socket server acts as a proxy, allowing users of this class to transform data (for encryption) as necessary
94
 * and passing through bytes directly to the HTTP server for processing. This way we get Node to do all
95
 * the "heavy lifting" of HTTP like parsing headers and formatting responses.
96
 *
97
 * Events are sent by simply waiting for current HTTP traffic to subside and then sending a custom response packet
98
 * directly down the wire via the socket.
99
 *
100
 * Each connection to the main TCP server gets its own internal HTTP server, so we can track ongoing requests/responses
101
 * for safe event insertion.
102
 *
103
 * @group HAP Accessory Server
104
 */
105
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
106
export class EventedHTTPServer extends EventEmitter {
42✔
107

108
  private static readonly CONNECTION_TIMEOUT_LIMIT = 16; // if we have more (or equal) # connections we start the timeout
42✔
109
  private static readonly MAX_CONNECTION_IDLE_TIME = 60 * 60 * 1000; // 1h
42✔
110

111
  private readonly tcpServer: net.Server;
112

113
  /**
114
   * Set of all currently connected HAP connections.
115
   */
116
  private readonly connections: Set<HAPConnection> = new Set();
252✔
117
  /**
118
   * Session dictionary indexed by username/identifier. The username uniquely identifies every person added to the home.
119
   * So there can be multiple sessions open for a single username (multiple devices connected to the same Apple ID).
120
   */
121
  private readonly connectionsByUsername: Map<HAPUsername, HAPConnection[]> = new Map();
252✔
122
  private connectionIdleTimeout?: NodeJS.Timeout;
123
  private connectionLoggingInterval?: NodeJS.Timeout;
124

125
  constructor() {
126
    super();
252✔
127
    this.tcpServer = net.createServer();
252✔
128
  }
129

130
  private scheduleNextConnectionIdleTimeout(): void {
131
    this.connectionIdleTimeout = undefined;
×
132

133
    if (!this.tcpServer.listening) {
×
134
      return;
×
135
    }
136

137
    debug("Running idle timeout timer...");
×
138

139
    const currentTime = new Date().getTime();
×
140
    let nextTimeout = -1;
×
141

142
    for (const connection of this.connections) {
×
143
      const timeDelta = currentTime - connection.lastSocketOperation;
×
144

145
      if (timeDelta >= EventedHTTPServer.MAX_CONNECTION_IDLE_TIME) {
×
146
        debug("[%s] Closing connection as it was inactive for " + timeDelta + "ms");
×
147
        connection.close();
×
148
      } else {
149
        nextTimeout = Math.max(nextTimeout, EventedHTTPServer.MAX_CONNECTION_IDLE_TIME - timeDelta);
×
150
      }
151
    }
152

153
    if (this.connections.size >= EventedHTTPServer.CONNECTION_TIMEOUT_LIMIT) {
×
154
      this.connectionIdleTimeout = setTimeout(this.scheduleNextConnectionIdleTimeout.bind(this), nextTimeout);
×
155
    }
156
  }
157

158
  public address(): AddressInfo {
159
    return this.tcpServer.address() as AddressInfo;
12✔
160
  }
161

162
  public listen(targetPort: number, hostname?: string): void {
163
    this.tcpServer.listen(targetPort, hostname, () => {
252✔
164
      const address = this.tcpServer.address() as AddressInfo; // address() is only a string when listening to unix domain sockets
162✔
165

166
      debug("Server listening on %s:%s", address.family === "IPv6"? `[${address.address}]`: address.address, address.port);
162!
167

168
      this.connectionLoggingInterval = setInterval(() => {
162✔
169
        const connectionInformation = [...this.connections]
×
170
          .map(connection => `${connection.remoteAddress}:${connection.remotePort}`)
×
171
          .join(", ");
172
        debug("Currently %d hap connections open: %s", this.connections.size, connectionInformation);
×
173
      }, 60_000);
174
      this.connectionLoggingInterval.unref();
162✔
175

176
      this.emit(EventedHTTPServerEvent.LISTENING, address.port, address.address);
162✔
177
    });
178

179
    this.tcpServer.on("connection", this.onConnection.bind(this));
252✔
180
  }
181

182
  public stop(): void {
183
    if (this.connectionLoggingInterval != null) {
351✔
184
      clearInterval(this.connectionLoggingInterval);
162✔
185
      this.connectionLoggingInterval = undefined;
162✔
186
    }
187

188
    if (this.connectionIdleTimeout != null) {
351!
189
      clearTimeout(this.connectionIdleTimeout);
×
190
      this.connectionIdleTimeout = undefined;
×
191
    }
192

193
    this.tcpServer.close();
351✔
194
    for (const connection of this.connections) {
351✔
195
      connection.close();
186✔
196
    }
197
  }
198

199
  public destroy(): void {
200
    this.stop();
9✔
201
    this.removeAllListeners();
9✔
202
  }
203

204
  /**
205
   * Send an event notification for given characteristic and changed value to all connected clients.
206
   * If `originator` is specified, the given {@link HAPConnection} will be excluded from the broadcast.
207
   *
208
   * @param aid - The accessory id of the updated characteristic.
209
   * @param iid - The instance id of the updated characteristic.
210
   * @param value - The newly set value of the characteristic.
211
   * @param originator - If specified, the connection will not get an event message.
212
   * @param immediateDelivery - The HAP spec requires some characteristics to be delivery immediately.
213
   *   Namely, for the {@link Characteristic.ButtonEvent} and the {@link Characteristic.ProgrammableSwitchEvent} characteristics.
214
   */
215
  public broadcastEvent(aid: number, iid: number, value: Nullable<CharacteristicValue>, originator?: HAPConnection, immediateDelivery?: boolean): void {
216
    for (const connection of this.connections) {
39✔
217
      if (connection === originator) {
6✔
218
        debug("[%s] Muting event '%s' notification for this connection since it originated here.", connection.remoteAddress, aid + "." + iid);
3✔
219
        continue;
3✔
220
      }
221

222
      connection.sendEvent(aid, iid, value, immediateDelivery);
3✔
223
    }
224
  }
225

226
  private onConnection(socket: Socket): void {
227
    // eslint-disable-next-line @typescript-eslint/no-use-before-define
228
    const connection = new HAPConnection(this, socket);
102✔
229

230
    connection.on(HAPConnectionEvent.REQUEST, (request, response) => {
102✔
231
      this.emit(EventedHTTPServerEvent.REQUEST, connection, request, response);
198✔
232
    });
233
    connection.on(HAPConnectionEvent.AUTHENTICATED, this.handleConnectionAuthenticated.bind(this, connection));
102✔
234
    connection.on(HAPConnectionEvent.CLOSED, this.handleConnectionClose.bind(this, connection));
102✔
235

236
    this.connections.add(connection);
102✔
237

238
    debug("[%s] New connection from client on interface %s (%s)", connection.remoteAddress, connection.networkInterface, connection.localAddress);
102✔
239

240
    this.emit(EventedHTTPServerEvent.CONNECTION_OPENED, connection);
102✔
241

242
    if (this.connections.size >= EventedHTTPServer.CONNECTION_TIMEOUT_LIMIT && !this.connectionIdleTimeout) {
102!
243
      this.scheduleNextConnectionIdleTimeout();
×
244
    }
245
  }
246

247
  private handleConnectionAuthenticated(connection: HAPConnection, username: HAPUsername): void {
248
    const connections: HAPConnection[] | undefined = this.connectionsByUsername.get(username);
45✔
249
    if (!connections) {
45✔
250
      this.connectionsByUsername.set(username, [connection]);
42✔
251
    } else if (!connections.includes(connection)) { // ensure this doesn't get added more than one time
3!
252
      connections.push(connection);
3✔
253
    }
254
  }
255

256
  private handleConnectionClose(connection: HAPConnection): void {
257
    this.emit(EventedHTTPServerEvent.CONNECTION_CLOSED, connection);
96✔
258

259
    this.connections.delete(connection);
96✔
260

261
    if (connection.username) { // aka connection was authenticated
96✔
262
      const connections = this.connectionsByUsername.get(connection.username);
45✔
263
      if (connections) {
45!
264
        const index = connections.indexOf(connection);
45✔
265
        if (index !== -1) {
45!
266
          connections.splice(index, 1);
45✔
267
        }
268

269
        if (connections.length === 0) {
45✔
270
          this.connectionsByUsername.delete(connection.username);
42✔
271
        }
272
      }
273
    }
274
  }
275

276
  /**
277
   * This method is to be called when a given {@link HAPConnection} performs a request that should result in the disconnection
278
   * of all other {@link HAPConnection} with the same {@link HAPUsername}.
279
   *
280
   * The initiator MUST be in the middle of a http request were the response was not served yet.
281
   * Otherwise, the initiator connection might reside in a state where it isn't disconnected and can't make any further requests.
282
   *
283
   * @param initiator - The connection that requested to disconnect all connections of the same username.
284
   * @param username - The username for which all connections shall be closed.
285
   */
286
  public static destroyExistingConnectionsAfterUnpair(initiator: HAPConnection, username: HAPUsername): void {
287
    const connections: HAPConnection[] | undefined = initiator.server.connectionsByUsername.get(username);
3✔
288

289
    if (connections) {
3!
290
      for (const connection of connections) {
3✔
291
        connection.closeConnectionAsOfUnpair(initiator);
6✔
292
      }
293
    }
294
  }
295

296
}
297

298
/**
299
 * @private
300
 * @group HAP Accessory Server
301
 */
302
export const enum HAPConnectionState {
42✔
303
  CONNECTING, // initial state, setup is going on
42✔
304
  FULLY_SET_UP, // internal http server is running and connection is established
42✔
305
  AUTHENTICATED, // encryption is set up
42✔
306
  // above signals represent an alive connection
307

308
  // below states are considered "closed or soon closed"
309
  TO_BE_TEARED_DOWN, // when in this state, connection should be closed down after response was sent out
42✔
310
  CLOSING, // close was called
42✔
311
  CLOSED, // dead
42✔
312
}
313

314
/**
315
 * @group HAP Accessory Server
316
 */
317
export const enum HAPConnectionEvent {
42✔
318
  REQUEST = "request",
42✔
319
  AUTHENTICATED = "authenticated",
42✔
320
  CLOSED = "closed",
42✔
321
}
322

323
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
324
export declare interface HAPConnection {
325
  on(event: "request", listener: (request: IncomingMessage, response: ServerResponse) => void): this;
326
  on(event: "authenticated", listener: (username: HAPUsername) => void): this;
327
  on(event: "closed", listener: () => void): this;
328

329
  emit(event: "request", request: IncomingMessage, response: ServerResponse): boolean;
330
  emit(event: "authenticated", username: HAPUsername): boolean;
331
  emit(event: "closed"): boolean;
332
}
333

334
/**
335
 * Manages a single iOS-initiated HTTP connection during its lifetime.
336
 * @group HAP Accessory Server
337
 */
338
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
339
export class HAPConnection extends EventEmitter {
42✔
340
  /**
341
   * @private file-private API
342
   */
343
  readonly server: EventedHTTPServer;
344

345
  readonly sessionID: SessionIdentifier; // uuid unique to every HAP connection
346
  private state: HAPConnectionState = HAPConnectionState.CONNECTING;
102✔
347
  readonly localAddress: string;
348
  readonly remoteAddress: string; // cache because it becomes undefined in 'onClientSocketClose'
349
  readonly remotePort: number;
350
  readonly networkInterface: string;
351

352
  private readonly tcpSocket: Socket;
353
  private readonly internalHttpServer: http.Server;
354
  private httpSocket?: Socket; // set when in state FULLY_SET_UP
355
  private internalHttpServerPort?: number;
356
  private internalHttpServerAddress?: string;
357

358
  lastSocketOperation: number = new Date().getTime();
102✔
359

360
  private pendingClientSocketData?: Buffer = Buffer.alloc(0); // data received from client before HTTP proxy is fully setup
102✔
361
  private handlingRequest = false; // true while we are composing an HTTP response (so events can wait)
102✔
362

363
  username?: HAPUsername; // username is unique to every user in the home, basically identifies an Apple ID
364
  encryption?: HAPEncryption; // created in handlePairVerifyStepOne
365
  srpServer?: SrpServer;
366
  _pairSetupState?: number; // TODO ensure those two states are always correctly reset?
367
  _pairVerifyState?: number;
368

369
  private registeredEvents: Set<EventName> = new Set();
102✔
370
  private eventsTimer?: NodeJS.Timeout;
371
  private readonly queuedEvents: CharacteristicEventNotification[] = [];
102✔
372
  /**
373
   * If true, the above {@link queuedEvents} contains events which are set to be delivered immediately!
374
   */
375
  private eventsQueuedForImmediateDelivery = false;
102✔
376

377
  timedWritePid?: number;
378
  timedWriteTimeout?: NodeJS.Timeout;
379

380
  constructor(server: EventedHTTPServer, clientSocket: Socket) {
381
    super();
102✔
382

383
    this.server = server;
102✔
384
    this.sessionID = uuid.generate(clientSocket.remoteAddress + ":" + clientSocket.remotePort);
102✔
385
    this.localAddress = clientSocket.localAddress as string;
102✔
386
    this.remoteAddress = clientSocket.remoteAddress!; // cache because it becomes undefined in 'onClientSocketClose'
102✔
387
    this.remotePort = clientSocket.remotePort!;
102✔
388
    this.networkInterface = HAPConnection.getLocalNetworkInterface(clientSocket);
102✔
389

390
    // clientSocket is the socket connected to the actual iOS device
391
    this.tcpSocket = clientSocket;
102✔
392
    this.tcpSocket.on("data", this.onTCPSocketData.bind(this));
102✔
393
    this.tcpSocket.on("close", this.onTCPSocketClose.bind(this));
102✔
394
    // we MUST register for this event, otherwise the error will bubble up to the top and crash the node process entirely.
395
    this.tcpSocket.on("error", this.onTCPSocketError.bind(this));
102✔
396
    this.tcpSocket.setNoDelay(true); // disable Nagle algorithm
102✔
397
    // "HAP accessory servers must not use keepalive messages, which periodically wake up iOS devices".
398
    // Thus, we don't configure any tcp keepalive
399

400
    // create our internal HTTP server for this connection that we will proxy data to and from
401
    this.internalHttpServer = http.createServer();
102✔
402
    this.internalHttpServer.timeout = 0; // clients expect to hold connections open as long as they want
102✔
403
    this.internalHttpServer.keepAliveTimeout = 0; // workaround for https://github.com/nodejs/node/issues/13391
102✔
404
    this.internalHttpServer.on("listening", this.onHttpServerListening.bind(this));
102✔
405
    this.internalHttpServer.on("request", this.handleHttpServerRequest.bind(this));
102✔
406
    this.internalHttpServer.on("error", this.onHttpServerError.bind(this));
102✔
407
    // close event is added later on the "connect" event as possible listen retries would throw unnecessary close events
408
    this.internalHttpServer.listen(0, this.internalHttpServerAddress = getOSLoopbackAddressIfAvailable());
102✔
409
  }
410

411
  private debugListenerRegistration(event: string | symbol, registration = true, beforeCount = -1): void {
630✔
412
    const stackTrace = new Error().stack!.split("\n")[3];
324✔
413
    const eventCount = this.listeners(event).length;
324✔
414

415
    const tabs1 = event === HAPConnectionEvent.AUTHENTICATED ? "\t" : "\t\t";
324✔
416
    const tabs2 = !registration ? "\t" : "\t\t";
324✔
417

418
    // eslint-disable-next-line max-len
419
    debugEvents(`[${this.remoteAddress}] ${registration ? "Registered" : "Unregistered"} event '${String(event).toUpperCase()}' ${tabs1}(total: ${eventCount}${!registration ? " Before: " + beforeCount : ""}) ${tabs2}${stackTrace}`);
324✔
420
  }
421

422
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
423
  on(event: string | symbol, listener: (...args: any[]) => void): this {
424
    const result =  super.on(event, listener);
315✔
425
    this.debugListenerRegistration(event);
315✔
426
    return result;
315✔
427
  }
428

429
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
430
  addListener(event: string | symbol, listener: (...args: any[]) => void): this {
431
    const result = super.addListener(event, listener);
×
432
    this.debugListenerRegistration(event);
×
433
    return result;
×
434
  }
435

436
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
437
  removeListener(event: string | symbol, listener: (...args: any[]) => void): this {
438
    const beforeCount = this.listeners(event).length;
9✔
439
    const result = super.removeListener(event, listener);
9✔
440
    this.debugListenerRegistration(event, false, beforeCount);
9✔
441
    return result;
9✔
442
  }
443

444
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
445
  off(event: string | symbol, listener: (...args: any[]) => void): this {
446
    const result =  super.off(event, listener);
×
447
    const beforeCount = this.listeners(event).length;
×
448
    this.debugListenerRegistration(event, false, beforeCount);
×
449
    return result;
×
450
  }
451

452
  /**
453
   * This method is called once the connection has gone through pair-verify.
454
   * As any HomeKit controller will initiate a pair-verify after the pair-setup procedure, this method gets
455
   * not called on the initial pair-setup.
456
   *
457
   * Once this method has been called, the connection is authenticated and encryption is turned on.
458
   */
459
  public connectionAuthenticated(username: HAPUsername): void {
460
    this.state = HAPConnectionState.AUTHENTICATED;
45✔
461
    this.username = username;
45✔
462

463
    this.emit(HAPConnectionEvent.AUTHENTICATED, username);
45✔
464
  }
465

466
  public isAuthenticated(): boolean {
467
    return this.state === HAPConnectionState.AUTHENTICATED;
78✔
468
  }
469

470
  public close(): void {
471
    if (this.state >= HAPConnectionState.CLOSING) {
384✔
472
      return; // already closed/closing
285✔
473
    }
474

475
    this.state = HAPConnectionState.CLOSING;
99✔
476
    this.tcpSocket.destroy();
99✔
477
  }
478

479
  public closeConnectionAsOfUnpair(initiator: HAPConnection): void {
480
    if (this === initiator) {
6✔
481
      // the initiator of the unpair request is this connection, meaning it unpaired itself.
482
      // we still need to send the response packet to the unpair request.
483
      this.state = HAPConnectionState.TO_BE_TEARED_DOWN;
3✔
484
    } else {
485
      // as HomeKit requires it, destroy any active session which got unpaired
486
      this.close();
3✔
487
    }
488
  }
489

490
  public sendEvent(aid: number, iid: number, value: Nullable<CharacteristicValue>, immediateDelivery?: boolean): void {
491
    assert(aid != null, "HAPConnection.sendEvent: aid must be defined!");
36✔
492
    assert(iid != null, "HAPConnection.sendEvent: iid must be defined!");
36✔
493

494
    const eventName = aid + "." + iid;
36✔
495

496
    if (!this.registeredEvents.has(eventName)) {
36✔
497
      // non verified connections can't register events, so this case is covered!
498
      return;
3✔
499
    }
500

501
    const event: CharacteristicEventNotification = {
33✔
502
      aid: aid,
503
      iid: iid,
504
      value: value,
505
    };
506

507
    if (immediateDelivery) {
33✔
508
      // some characteristics are required to deliver notifications immediately
509
      // we will flush all other events too, on that occasion.
510
      this.queuedEvents.push(event);
6✔
511
      this.eventsQueuedForImmediateDelivery = true;
6✔
512

513
      if (this.eventsTimer) {
6✔
514
        clearTimeout(this.eventsTimer);
3✔
515
        this.eventsTimer = undefined;
3✔
516
      }
517
      this.handleEventsTimeout();
6✔
518
      return;
6✔
519
    }
520

521
    // we search the list of queued events in reverse order.
522
    // if the last element with the same aid and iid has the same value we don't want to send the event notification twice.
523
    // BUT, we do not want to override previous event notifications which have a different value. Automations must be executed!
524
    for (let i = this.queuedEvents.length - 1; i >= 0; i--) {
27✔
525
      const queuedEvent = this.queuedEvents[i];
15✔
526
      if (queuedEvent.aid === aid && queuedEvent.iid === iid) {
15!
527
        if (queuedEvent.value === value) {
15✔
528
          return; // the same event was already queued. do not add it again!
3✔
529
        }
530

531
        break; // we break in any case
12✔
532
      }
533
    }
534

535
    this.queuedEvents.push(event);
24✔
536

537
    // if there is already a timer running we just add it in the queue.
538
    if (!this.eventsTimer) {
24✔
539
      this.eventsTimer = setTimeout(this.handleEventsTimeout.bind(this), 250);
15✔
540
      this.eventsTimer.unref();
15✔
541
    }
542
  }
543

544
  private handleEventsTimeout(): void {
545
    this.eventsTimer = undefined;
15✔
546
    if (this.state > HAPConnectionState.AUTHENTICATED) {
15!
547
      // connection is closed or about to be closed. no need to send any further events
548
      return;
×
549
    }
550

551
    this.writeQueuedEventNotifications();
15✔
552
  }
553

554
  private writeQueuedEventNotifications(): void {
555
    if (this.queuedEvents.length === 0 || this.handlingRequest) {
207✔
556
      return; // don't send empty event notifications or if we are currently handling a request
192✔
557
    }
558

559
    if (this.eventsTimer) {
15✔
560
      // this method might be called when we have enqueued data AND data that is queued for immediate delivery!
561
      clearTimeout(this.eventsTimer);
3✔
562
      this.eventsTimer = undefined;
3✔
563
    }
564

565
    const eventData: EventNotification = {
15✔
566
      characteristics: [],
567
    };
568

569
    for (const queuedEvent of this.queuedEvents) {
15✔
570
      if (!this.registeredEvents.has(queuedEvent.aid + "." + queuedEvent.iid)) {
30!
571
        continue; // client unregistered that event in the meantime
×
572
      }
573

574
      eventData.characteristics.push(queuedEvent);
30✔
575
    }
576

577
    this.queuedEvents.splice(0, this.queuedEvents.length);
15✔
578
    this.eventsQueuedForImmediateDelivery = false;
15✔
579

580
    this.writeEventNotification(eventData);
15✔
581
  }
582

583
  /**
584
   * This will create an EVENT/1.0 notification header with the provided event notification.
585
   * If currently an HTTP request is in progress the assembled packet will be
586
   * added to the pending events list.
587
   *
588
   * @param notification - The event which should be sent out
589
   */
590
  private writeEventNotification(notification: EventNotification): void {
591
    debugCon("[%s] Sending HAP event notifications %o", this.remoteAddress, notification.characteristics);
15✔
592
    assert(!this.handlingRequest, "Can't write event notifications while handling a request!");
15✔
593

594
    // Apple backend processes events in reverse order, so we need to reverse the array
595
    // so that events are processed in chronological order.
596
    notification.characteristics.reverse();
15✔
597

598
    const dataBuffer = Buffer.from(JSON.stringify(notification), "utf8");
15✔
599
    const header = Buffer.from(
15✔
600
      "EVENT/1.0 200 OK\r\n" +
601
      "Content-Type: application/hap+json\r\n" +
602
      "Content-Length: " + dataBuffer.length + "\r\n" +
603
      "\r\n",
604
      "utf8", // buffer encoding
605
    );
606

607
    const buffer = Buffer.concat([header, dataBuffer]);
15✔
608
    this.tcpSocket.write(this.encrypt(buffer), this.handleTCPSocketWriteFulfilled.bind(this));
15✔
609
  }
610

611
  public enableEventNotifications(aid: number, iid: number): void {
612
    this.registeredEvents.add(aid + "." + iid);
3✔
613
  }
614

615
  public disableEventNotifications(aid: number, iid: number): void {
616
    this.registeredEvents.delete(aid + "." + iid);
×
617
  }
618

619
  public hasEventNotifications(aid: number, iid: number): boolean {
620
    return this.registeredEvents.has(aid + "." + iid);
6✔
621
  }
622

623
  public getRegisteredEvents(): Set<EventName> {
624
    return this.registeredEvents;
3✔
625
  }
626

627
  public clearRegisteredEvents(): void {
628
    this.registeredEvents.clear();
×
629
  }
630

631
  private encrypt(data: Buffer): Buffer {
632
    // if accessoryToControllerKey is not empty, then encryption is enabled for this connection. However, we'll
633
    // need to be careful to ensure that we don't encrypt the last few bytes of the response from handlePairVerifyStepTwo.
634
    // Since all communication calls are asynchronous, we could easily receive this 'encrypt' event for those bytes.
635
    // So we want to make sure that we aren't encrypting data until we have *received* some encrypted data from the client first.
636
    if (this.encryption && this.encryption.accessoryToControllerKey.length > 0 && this.encryption.controllerToAccessoryCount > 0) {
213✔
637
      return hapCrypto.layerEncrypt(data, this.encryption);
39✔
638
    }
639
    return data; // otherwise, we don't encrypt and return plaintext
174✔
640
  }
641

642
  private decrypt(data: Buffer): Buffer {
643
    if (this.encryption && this.encryption.controllerToAccessoryKey.length > 0) {
198✔
644
      // below call may throw an error if decryption failed
645
      return hapCrypto.layerDecrypt(data, this.encryption);
39✔
646
    }
647
    return data; // otherwise, we don't decrypt and return plaintext
159✔
648
  }
649

650
  private onHttpServerListening() {
651
    const addressInfo = this.internalHttpServer.address() as AddressInfo; // address() is only a string when listening to unix domain sockets
102✔
652
    const addressString = addressInfo.family === "IPv6"? `[${addressInfo.address}]`: addressInfo.address;
102!
653
    this.internalHttpServerPort = addressInfo.port;
102✔
654

655
    debugCon("[%s] Internal HTTP server listening on %s:%s", this.remoteAddress, addressString, addressInfo.port);
102✔
656

657
    this.internalHttpServer.on("close", this.onHttpServerClose.bind(this));
102✔
658

659
    // now we can establish a connection to this running HTTP server for proxying data
660
    this.httpSocket = net.createConnection(this.internalHttpServerPort, this.internalHttpServerAddress); // previously we used addressInfo.address
102✔
661
    this.httpSocket.setNoDelay(true); // disable Nagle algorithm
102✔
662

663
    this.httpSocket.on("data", this.handleHttpServerResponse.bind(this));
102✔
664
    // we MUST register for this event, otherwise the error will bubble up to the top and crash the node process entirely.
665
    this.httpSocket.on("error", this.onHttpSocketError.bind(this));
102✔
666
    this.httpSocket.on("close", this.onHttpSocketClose.bind(this));
102✔
667
    this.httpSocket.on("connect", () => {
102✔
668
      // we are now fully set up:
669
      //  - clientSocket is connected to the iOS device
670
      //  - serverSocket is connected to the httpServer
671
      //  - ready to proxy data!
672
      this.state = HAPConnectionState.FULLY_SET_UP;
102✔
673
      debugCon("[%s] Internal HTTP socket connected. HAPConnection now fully set up!", this.remoteAddress);
102✔
674

675
      // start by flushing any pending buffered data received from the client while we were setting up
676
      if (this.pendingClientSocketData && this.pendingClientSocketData.length > 0) {
102!
677
        this.httpSocket!.write(this.pendingClientSocketData);
102✔
678
      }
679
      this.pendingClientSocketData = undefined;
102✔
680
    });
681
  }
682

683
  /**
684
   * This event handler is called when we receive data from a HomeKit controller on our tcp socket.
685
   * We store the data if the internal http server is not read yet, or forward it to the http server.
686
   */
687
  private onTCPSocketData(data: Buffer): void {
688
    if (this.state > HAPConnectionState.AUTHENTICATED) {
198!
689
      // don't accept data of a connection which is about to be closed or already closed
690
      return;
×
691
    }
692

693
    this.handlingRequest = true; // reverted to false once response was sent out
198✔
694
    this.lastSocketOperation = new Date().getTime();
198✔
695

696
    try {
198✔
697
      data = this.decrypt(data);
198✔
698
    } catch (error) { // decryption and/or verification failed, disconnect the client
699
      debugCon("[%s] Error occurred trying to decrypt incoming packet: %s", this.remoteAddress, error.message);
×
700
      this.close();
×
701
      return;
×
702
    }
703

704
    if (this.state < HAPConnectionState.FULLY_SET_UP) { // we're not setup yet, so add this data to our intermediate buffer
198✔
705
      this.pendingClientSocketData = Buffer.concat([this.pendingClientSocketData!, data]);
102✔
706
    } else {
707
      this.httpSocket!.write(data); // proxy it along to the HTTP server
96✔
708
    }
709
  }
710

711
  /**
712
   * This event handler is called when the internal http server receives a request.
713
   * Meaning we received data from the HomeKit controller in {@link onTCPSocketData}, which then send the
714
   * data unencrypted to the internal http server. And now it landed here, fully parsed as a http request.
715
   */
716
  private handleHttpServerRequest(request: IncomingMessage, response: ServerResponse): void {
717
    if (this.state > HAPConnectionState.AUTHENTICATED) {
198!
718
      // don't accept data of a connection which is about to be closed or already closed
719
      return;
×
720
    }
721

722
    debugCon("[%s] HTTP request: %s", this.remoteAddress, request.url);
198✔
723

724
    request.socket.setNoDelay(true);
198✔
725

726
    this.emit(HAPConnectionEvent.REQUEST, request, response);
198✔
727
  }
728

729
  /**
730
   * This event handler is called by the socket which is connected to our internal http server.
731
   * It is called with the response returned from the http server.
732
   * In this method we have to encrypt and forward the message back to the HomeKit controller.
733
   */
734
  private handleHttpServerResponse(data: Buffer): void {
735
    data = this.encrypt(data);
198✔
736
    this.tcpSocket.write(data, this.handleTCPSocketWriteFulfilled.bind(this));
198✔
737

738
    debugCon("[%s] HTTP Response is finished", this.remoteAddress);
198✔
739
    this.handlingRequest = false;
198✔
740

741
    if (this.state === HAPConnectionState.TO_BE_TEARED_DOWN) {
198✔
742
      setTimeout(() => this.close(), 10);
3✔
743
    } else if (this.state < HAPConnectionState.TO_BE_TEARED_DOWN) {
195!
744
      if (!this.eventsTimer || this.eventsQueuedForImmediateDelivery) {
195✔
745
        // we deliver events if there is no eventsTimer (meaning it ran out in the meantime)
746
        // or when the queue contains events set to be delivered immediately
747
        this.writeQueuedEventNotifications();
192✔
748
      }
749
    }
750
  }
751

752
  private handleTCPSocketWriteFulfilled(): void {
753
    this.lastSocketOperation = new Date().getTime();
213✔
754
  }
755

756
  private onTCPSocketError(err: Error): void {
757
    debugCon("[%s] Client connection error: %s", this.remoteAddress, err.message);
×
758
    // onTCPSocketClose will be called next
759
  }
760

761
  private onTCPSocketClose(): void {
762
    this.state = HAPConnectionState.CLOSED;
96✔
763

764
    debugCon("[%s] Client connection closed", this.remoteAddress);
96✔
765

766
    if (this.httpSocket) {
96!
767
      this.httpSocket.destroy();
96✔
768
    }
769
    this.internalHttpServer.close();
96✔
770

771
    this.emit(HAPConnectionEvent.CLOSED); // sending final closed event
96✔
772
    this.removeAllListeners(); // cleanup listeners, we are officially dead now
96✔
773
  }
774

775
  private onHttpServerError(err: Error & { code?: string }): void {
776
    debugCon("[%s] HTTP server error: %s", this.remoteAddress, err.message);
×
777
    if (err.code === "EADDRINUSE") {
×
778
      this.internalHttpServerPort = undefined;
×
779

780
      this.internalHttpServer.close();
×
781
      this.internalHttpServer.listen(0, this.internalHttpServerAddress = getOSLoopbackAddressIfAvailable());
×
782
    }
783
  }
784

785
  private onHttpServerClose(): void {
786
    debugCon("[%s] HTTP server was closed", this.remoteAddress);
192✔
787
    // make sure the iOS side is closed as well
788
    this.close();
192✔
789
  }
790

791
  private onHttpSocketError(err: Error): void {
792
    debugCon("[%s] HTTP connection error: ", this.remoteAddress, err.message);
×
793
    // onHttpSocketClose will be called next
794
  }
795

796
  private onHttpSocketClose(): void {
797
    debugCon("[%s] HTTP connection was closed", this.remoteAddress);
96✔
798
    // we only support a single long-lived connection to our internal HTTP server. Since it's closed,
799
    // we'll need to shut it down entirely.
800
    this.internalHttpServer.close();
96✔
801
  }
802

803
  public getLocalAddress(ipVersion: "ipv4" | "ipv6"): string {
804
    const interfaceDetails = os.networkInterfaces()[this.networkInterface];
6✔
805
    if (!interfaceDetails) {
6!
806
      throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface);
×
807
    }
808

809
    // Find our first local IPv4 address.
810
    if (ipVersion === "ipv4") {
6✔
811
      const ipv4Info = interfaceDetails.find(info => info.family === "IPv4");
3✔
812

813
      if (ipv4Info) {
3!
814
        return ipv4Info.address;
3✔
815
      }
816

817
      throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface + ".");
×
818
    }
819

820
    let localUniqueAddress;
821

822
    for (const v6entry of interfaceDetails.filter(entry => entry.family === "IPv6")) {
6✔
823
      if (!v6entry.scopeid) {
3!
824
        return v6entry.address;
3✔
825
      }
826

827
      localUniqueAddress ??= v6entry.address;
×
828
    }
829

830
    if(localUniqueAddress) {
×
831
      return localUniqueAddress;
×
832
    }
833

834
    throw new Error("Could not find " + ipVersion + " address for interface " + this.networkInterface);
×
835
  }
836

837
  private static getLocalNetworkInterface(socket: Socket): string {
838

839
    let localAddress = socket.localAddress;
102✔
840

841
    // Grab the list of network interfaces.
842
    const interfaces = os.networkInterfaces();
102✔
843

844
    // Default to the first non-loopback interface we see.
845
    const defaultInterface = () => Object.entries(interfaces).find(([, addresses]) => addresses?.some(address => !address.internal))?.[0] ?? "unknown";
102!
846

847
    // No local address return our default.
848
    if(!localAddress) {
102!
849
      return defaultInterface();
×
850
    }
851

852
    // Handle IPv4-mapped IPv6 addresses.
853
    localAddress = localAddress.replace(/^::ffff:/i, "");
102✔
854

855
    // Handle edge cases where we have an IPv4-mapped IPv6 address without the requisite prefix.
856
    if(/^::(?:\d{1,3}\.){3}\d{1,3}$/.test(localAddress)) {
102!
857
      localAddress = localAddress.replace(/^::/, "");
×
858
    }
859

860
    // Handle link-local IPv6 addresses.
861
    localAddress = localAddress.split("%")[0];
102✔
862

863
    // Let's find an exact match using the IP.
864
    for (const [name, addresses] of Object.entries(interfaces)) {
102✔
865
      if (addresses?.some(({ address }) => address === localAddress)) {
192!
866
        return name;
102✔
867
      }
868
    }
869

870
    // We couldn't find an interface to match the address from above, so we attempt to match subnets (see https://github.com/homebridge/HAP-NodeJS/issues/847).
871
    const family = net.isIPv4(localAddress)? "IPv4": "IPv6";
×
872

873
    // Let's find a match based on the subnet.
874
    for (const [name, addresses] of Object.entries(interfaces)) {
×
875
      if (addresses?.some(entry => entry.family === family && getNetAddress(localAddress, entry.netmask) === getNetAddress(entry.address, entry.netmask))) {
×
876
        return name;
×
877
      }
878
    }
879

880
    console.log("WARNING: unable to determine which interface to use for socket coming from " + socket.remoteAddress + ":" + socket.remotePort + " to " +
×
881
      socket.localAddress + ".");
882

883
    return defaultInterface();
×
884
  }
885
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc