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

homebridge / HAP-NodeJS / 14017380819

23 Mar 2025 08:58AM UTC coverage: 64.338% (-0.7%) from 64.993%
14017380819

push

github

web-flow
updated dependencies (#1085)

1360 of 2511 branches covered (54.16%)

Branch coverage included in aggregate %.

6237 of 9297 relevant lines covered (67.09%)

312.17 hits per line

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

44.87
/src/lib/controller/CameraController.ts
1
import crypto from "crypto";
18✔
2
import createDebug from "debug";
18✔
3
import { EventEmitter } from "events";
18✔
4
import { CharacteristicValue, SessionIdentifier } from "../../types";
5
import {
18✔
6
  CameraRecordingConfiguration,
7
  CameraRecordingOptions,
8
  CameraStreamingOptions,
9
  EventTriggerOption,
10
  PrepareStreamRequest,
11
  PrepareStreamResponse,
12
  RecordingManagement,
13
  RecordingManagementState,
14
  RecordingPacket,
15
  RTPStreamManagement,
16
  RTPStreamManagementState,
17
  SnapshotRequest,
18
  StreamingRequest,
19
} from "../camera";
20
import { Characteristic, CharacteristicEventTypes, CharacteristicGetCallback, CharacteristicSetCallback } from "../Characteristic";
18✔
21
import { DataStreamManagement, HDSProtocolSpecificErrorReason } from "../datastream";
18✔
22
import {
23
  CameraOperatingMode,
24
  CameraRecordingManagement,
25
  DataStreamTransportManagement,
26
  Doorbell,
27
  Microphone,
28
  MotionSensor,
29
  OccupancySensor,
30
  Speaker,
31
} from "../definitions";
32
import { HAPStatus } from "../HAPServer";
33
import { Service } from "../Service";
18✔
34
import { HapStatusError } from "../util/hapStatusError";
18✔
35
import { ControllerIdentifier, ControllerServiceMap, DefaultControllerType, SerializableController, StateChangeDelegate } from "./Controller";
36

37
const debug = createDebug("HAP-NodeJS:Camera:Controller");
18✔
38

39
/**
40
 * @group Camera
41
 */
42
export interface CameraControllerOptions {
43
  /**
44
   * Amount of parallel camera streams the accessory is capable of running.
45
   * As of the official HAP specification non SecureVideo cameras have a minimum required amount of 2 (but 1 is also fine).
46
   * Secure Video cameras just expose 1 stream.
47
   *
48
   * Default value: 1
49
   */
50
  cameraStreamCount?: number,
51

52
  /**
53
   * Delegate which handles the actual RTP/RTCP video/audio streaming and Snapshot requests.
54
   */
55
  delegate: CameraStreamingDelegate,
56

57
  /**
58
   * Options regarding video/audio streaming
59
   */
60
  streamingOptions: CameraStreamingOptions,
61

62
  /**
63
   * When supplying this option, it will enable support for HomeKit Secure Video.
64
   * This will create the {@link Service.CameraRecordingManagement}, {@link Service.CameraOperatingMode}
65
   * and {@link Service.DataStreamTransportManagement} services.
66
   *
67
   * NOTE: The controller only initializes the required characteristics for the {@link Service.CameraOperatingMode}.
68
   *   You may add optional characteristics, if required, by accessing the service directly `CameraController.recordingManagement.operatingModeService`.
69
   */
70
  recording?: {
71
    /**
72
     * Options regarding Recordings (Secure Video)
73
     */
74
    options: CameraRecordingOptions,
75

76
    /**
77
      * Delegate which handles the audio/video recording data streaming on motion.
78
      */
79
    delegate: CameraRecordingDelegate,
80
  }
81

82
  /**
83
   * This config section configures optional sensors for the camera.
84
   * It e.g. may be used to set up a {@link EventTriggerOption.MOTION} trigger when configuring Secure Video.
85
   *
86
   * You may either specify and provide the desired {@link Service}s or specify their creation and maintenance using a `boolean` flag.
87
   * In this case the controller will create and maintain the service for you.
88
   * Otherwise, when you supply an already created instance of the {@link Service}, you are responsible yourself to manage the service
89
   * (e.g. creating, restoring, adding to the accessory, ...).
90
   *
91
   * The services can be accessed through the documented property after the call to {@link Accessory.configureController} has returned.
92
   */
93
  sensors?: {
94
    /**
95
     * Define if a {@link Service.MotionSensor} should be created/associated with the controller.
96
     *
97
     * You may access the created service via the {@link CameraController.motionService} property to configure listeners.
98
     *
99
     * ## HomeKit Secure Video:
100
     *
101
     * If supplied, this sensor will be used as a {@link EventTriggerOption.MOTION} trigger.
102
     * The characteristic {@link Characteristic.StatusActive} will be added, which is used to enable or disable the sensor.
103
     */
104
    motion?: Service | boolean;
105
    /**
106
     * Define if a {@link Service.OccupancySensor} should be created/associated with the controller.
107
     *
108
     * You may access the created service via the {@link CameraController.occupancyService} property to configure listeners.
109
     *
110
     * ## HomeKit Secure Video:
111
     *
112
     * The characteristic {@link Characteristic.StatusActive} will be added, which is used to enable or disable the sensor.
113
     */
114
    occupancy?: Service | boolean;
115
  }
116
}
117

118
/**
119
 * @group Camera
120
 */
121
export type SnapshotRequestCallback = (error?: Error | HAPStatus, buffer?: Buffer) => void;
122
/**
123
 * @group Camera
124
 */
125
export type PrepareStreamCallback = (error?: Error, response?: PrepareStreamResponse) => void;
126
/**
127
 * @group Camera
128
 */
129
export type StreamRequestCallback = (error?: Error) => void;
130

131
/**
132
 * @group Camera
133
 */
134
export const enum ResourceRequestReason {
18✔
135
  /**
136
   * The reason describes periodic resource requests.
137
   * In the example of camera image snapshots those are the typical preview images every 10 seconds.
138
   */
139
  PERIODIC = 0,
18✔
140
  /**
141
   * The resource request is the result of some event.
142
   * In the example of camera image snapshots, requests are made due to e.g. a motion event or similar.
143
   */
144
  EVENT = 1
18✔
145
}
146

147
/**
148
 * @group Camera
149
 */
150
export interface CameraStreamingDelegate {
151

152
  /**
153
   * This method is called when a HomeKit controller requests a snapshot image for the given camera.
154
   * The handler must respect the desired image height and width given in the {@link SnapshotRequest}.
155
   * The returned Buffer (via the callback) must be encoded in jpeg.
156
   *
157
   * HAP-NodeJS will complain about slow running handlers after 5 seconds and terminate the request after 15 seconds.
158
   *
159
   * @param request - Request containing image size.
160
   * @param callback - Callback supplied with the resulting Buffer
161
   */
162
  handleSnapshotRequest(request: SnapshotRequest, callback: SnapshotRequestCallback): void;
163

164
  prepareStream(request: PrepareStreamRequest, callback: PrepareStreamCallback): void;
165
  handleStreamRequest(request: StreamingRequest, callback: StreamRequestCallback): void;
166

167
}
168

169
/**
170
 * A `CameraRecordingDelegate` is responsible for handling recordings of a HomeKit Secure Video camera.
171
 *
172
 * It is responsible for maintaining the prebuffer (see {@link CameraRecordingOptions.prebufferLength},
173
 * once recording was activated (see {@link updateRecordingActive}).
174
 *
175
 * Before recording is considered enabled two things must happen:
176
 * - Recording must be enabled by the user. Signaled through {@link updateRecordingActive}.
177
 * - Recording configurations must be selected by a HomeKit controller through {@link updateRecordingConfiguration}.
178
 *
179
 * A typical recording event scenario happens as follows:
180
 * - The camera is in idle mode, maintaining the prebuffer (the duration of the prebuffer depends on the selected {@link CameraRecordingConfiguration}).
181
 * - A recording event is triggered (e.g. motion or doorbell button press) and the camera signals it through
182
 *   the respective characteristics (e.g. {@link Characteristic.MotionDetected} or {@link Characteristic.ProgrammableSwitchEvent}).
183
 *   Further, the camera saves the content of the prebuffer and starts recording the video.
184
 *   The camera should continue to store the recording until it runs out of space.
185
 *   In any case the camera should preserve recordings which are nearest to the triggered event.
186
 *   A stored recording might be completely deleted if a stream request wasn't initiated for eight seconds.
187
 * - A HomeKit Controller will open a new recording session to download the next recording.
188
 *   This results in a call to {@link handleRecordingStreamRequest}.
189
 * - Once the recording event is finished the camera will reset the state accordingly
190
 *   (e.g. in the {@link Service.MotionSensor} or {@link Service.Doorbell} service).
191
 *   It will continue to send the remaining fragments of the currently ongoing recording stream request.
192
 * - The camera will either reach the end of the recording (and signal this via {@link RecordingPacket.isLast}. Also see {@link acknowledgeStream})
193
 *   or it will continue to stream til the HomeKit Controller closes
194
 *   the stream {@link closeRecordingStream} with reason {@link HDSProtocolSpecificErrorReason.NORMAL}.
195
 * - The camera goes back into idle mode.
196
 *
197
 * @group Camera
198
 */
199
export interface CameraRecordingDelegate {
200
  /**
201
   * A call to this method notifies the `CameraRecordingDelegate` about a change to the
202
   * `CameraRecordingManagement.Active` characteristic. This characteristic controls
203
   * if the camera should react to recording events.
204
   *
205
   * If recording is disabled the camera can stop maintaining its prebuffer.
206
   * If recording is enabled the camera should start recording into its prebuffer.
207
   *
208
   * A `CameraRecordingDelegate` should assume active to be `false` on startup.
209
   * HAP-NodeJS will persist the state of the `Active` characteristic across reboots
210
   * and will call {@link updateRecordingActive} accordingly on startup, if recording was previously enabled.
211
   *
212
   * NOTE: HAP-NodeJS cannot guarantee that a {@link CameraRecordingConfiguration} is present
213
   * when recording is activated (e.g. the selected configuration might be erased due to changes
214
   * in the supplied {@link CameraRecordingOptions}, but the camera is still `active`; or we can't otherwise
215
   * influence the order which a HomeKit Controller might call those characteristics).
216
   * However, HAP-NodeJS guarantees that if there is a valid {@link CameraRecordingConfiguration},
217
   * {@link updateRecordingConfiguration} is called before {@link updateRecordingActive} (when enabling)
218
   * to avoid any unnecessary and potentially expensive reconfigurations.
219
   *
220
   * @param active - Specifies if recording is active or not.
221
   */
222
  updateRecordingActive(active: boolean): void;
223

224
  /**
225
   * A call to this method signals that the selected (by the HomeKit Controller)
226
   * recording configuration of the camera has changed.
227
   *
228
   * On startup the delegate should assume `configuration = undefined`.
229
   * HAP-NodeJS will persist the state of both across reboots and will call
230
   * {@link updateRecordingConfiguration} on startup if there is a **selected configuration** present.
231
   *
232
   * NOTE: An update to the recording configuration might happen while there is still a running
233
   * recording stream. The camera MUST continue to use the previous configuration for the
234
   * currently running stream and only apply the updated configuration to the next stream.
235
   *
236
   * @param configuration - The {@link CameraRecordingConfiguration}. Reconfigure your recording pipeline accordingly.
237
   *  The parameter might be `undefined` when the selected configuration became invalid. This typically ony happens
238
   *  e.g. due to a factory reset (when all pairings are removed). Disable the recording pipeline in such a case
239
   *  even if recording is still enabled for the camera.
240
   */
241
  updateRecordingConfiguration(configuration: CameraRecordingConfiguration | undefined): void;
242

243
  /**
244
   * This method is called to stream the next recording event.
245
   * It is guaranteed that there is only ever one ongoing recording stream request at a time.
246
   *
247
   * When this method is called return the currently ongoing (or next in case of a potentially queued)
248
   * recording via a `AsyncGenerator`. Every `yield` of the generator represents a complete recording `packet`.
249
   * The first packet MUST always be the {@link PacketDataType.MEDIA_INITIALIZATION} packet.
250
   * Any following packet will transport the actual mp4 fragments in {@link PacketDataType.MEDIA_FRAGMENT} packets,
251
   * starting with the content of the prebuffer. Every {@link PacketDataType.MEDIA_FRAGMENT} starts with a key frame
252
   * and must not be longer than the specified duration set via the `CameraRecordingConfiguration.mediaContainerConfiguration.fragmentLength`
253
   * **selected** by the HomeKit Controller in {@link updateRecordingConfiguration}.
254
   *
255
   * NOTE: You MUST respect the value of {@link Characteristic.RecordingAudioActive} characteristic of the {@link Service.CameraOperatingMode}
256
   *   service. When the characteristic is set to false you MUST NOT include audio in the mp4 fragments. You can access the characteristic via
257
   *   the `CameraController.recordingManagement.operatingModeService` property.
258
   *
259
   * You might throw an error in this method if encountering a non-recoverable state.
260
   * You may throw a {@link HDSProtocolError} to manually define the {@link HDSProtocolSpecificErrorReason} for the `DATA_SEND` `CLOSE` event.
261
   *
262
   * There are three ways an ongoing recording stream can be closed:
263
   * - Closed by the Accessory: There are no further fragments to transmit. The delegate MUST signal this by setting {@link RecordingPacket.isLast}
264
   *   to `true`. Once the HomeKit Controller receives this last fragment it will call {@link acknowledgeStream} to notify the accessory about
265
   *   the successful transmission.
266
   * - Closed by the HomeKit Controller (expectedly): After the event trigger has been reset, the accessory continues to stream fragments.
267
   *   At some point the HomeKit Controller will decide to shut down the stream by calling {@link closeRecordingStream} with a reason
268
   *   of {@link HDSProtocolSpecificErrorReason.NORMAL}.
269
   * - Closed by the HomeKit Controller (unexpectedly): A HomeKit Controller might at any point decide to close a recording stream
270
   *   if it encounters erroneous state. This is signaled by a call to {@link closeRecordingStream} with the respective reason.
271
   *
272
   * Once a close of stream is signaled, the `AsyncGenerator` function must return gracefully.
273
   *
274
   * For more information about `AsyncGenerator`s you might have a look at:
275
   * * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of
276
   *
277
   * NOTE: HAP-NodeJS guarantees that this method is only called with a valid selected {@link CameraRecordingConfiguration}.
278
   *
279
   * NOTE: Don't rely on the streamId for unique identification. Two {@link DataStreamConnection}s might share the same identifier space.
280
   *
281
   * @param streamId - The streamId of the currently ongoing stream.
282
   */
283
  handleRecordingStreamRequest(streamId: number): AsyncGenerator<RecordingPacket>;
284

285
  /**
286
   * This method is called once the HomeKit Controller acknowledges the `endOfStream`.
287
   * A `endOfStream` is sent by the accessory by setting {@link RecordingPacket.isLast} to `true` in the last packet yielded
288
   * by the {@link handleRecordingStreamRequest} `AsyncGenerator`.
289
   *
290
   * @param streamId - The streamId of the acknowledged stream.
291
   */
292
  acknowledgeStream?(streamId: number): void;
293

294
  /**
295
   * This method is called to notify the delegate that a recording stream started via {@link handleRecordingStreamRequest} was closed.
296
   *
297
   * The method is also called if an ongoing recording stream is closed gracefully (using {@link HDSProtocolSpecificErrorReason.NORMAL}).
298
   * In either case, the delegate should stop supplying further fragments to the recording stream.
299
   * The `AsyncGenerator` function must return without yielding any further {@link RecordingPacket}s.
300
   * HAP-NodeJS won't send out any fragments from this point onwards.
301
   *
302
   * @param streamId - The streamId for which the close event was sent.
303
   * @param reason - The reason with which the stream was closed.
304
   *  NOTE: This method is also called in case of a closed connection. This is encoded by setting the `reason` to undefined.
305
   */
306
  closeRecordingStream(streamId: number, reason: HDSProtocolSpecificErrorReason | undefined): void;
307
}
308

309
/**
310
 * @group Camera
311
 */
312
export interface CameraControllerServiceMap extends ControllerServiceMap {
313
  // "streamManagement%d": CameraRTPStreamManagement, // format to map all stream management services; indexed by zero
314

315
  microphone?: Microphone,
316
  speaker?: Speaker,
317

318
  cameraEventRecordingManagement?: CameraRecordingManagement,
319
  cameraOperatingMode?: CameraOperatingMode,
320
  dataStreamTransportManagement?: DataStreamTransportManagement,
321

322
  motionService?: MotionSensor,
323
  occupancyService?: OccupancySensor,
324

325
  // this ServiceMap is also used by the DoorbellController; there is no necessity to declare it,
326
  // but I think its good practice to reserve the namespace
327
  doorbell?: Doorbell;
328
}
329

330
/**
331
 * @group Camera
332
 */
333
export interface CameraControllerState {
334
  streamManagements: RTPStreamManagementState[];
335
  recordingManagement?: RecordingManagementState;
336
}
337

338
/**
339
 * @group Camera
340
 */
341
export const enum CameraControllerEvents {
18✔
342
  /**
343
   *  Emitted when the mute state or the volume changed. The Apple Home App typically does not set those values
344
   *  except the mute state. When you adjust the volume in the Camera view it will reset the muted state if it was set previously.
345
   *  The value of volume has nothing to do with the volume slider in the Camera view of the Home app.
346
   */
347
  MICROPHONE_PROPERTIES_CHANGED = "microphone-change",
18✔
348
  /**
349
   * Emitted when the mute state or the volume changed. The Apple Home App typically does not set those values
350
   * except the mute state. When you unmute the device microphone it will reset the mute state if it was set previously.
351
   */
352
  SPEAKER_PROPERTIES_CHANGED = "speaker-change",
18✔
353
}
354

355
/**
356
 * @group Camera
357
 */
358
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
359
export declare interface CameraController {
360
  on(event: "microphone-change", listener: (muted: boolean, volume: number) => void): this;
361
  on(event: "speaker-change", listener: (muted: boolean, volume: number) => void): this;
362

363
  emit(event: "microphone-change", muted: boolean, volume: number): boolean;
364
  emit(event: "speaker-change", muted: boolean, volume: number): boolean;
365
}
366

367
/**
368
 * Everything needed to expose a HomeKit Camera.
369
 *
370
 * @group Camera
371
 */
372
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
373
export class CameraController extends EventEmitter implements SerializableController<CameraControllerServiceMap, CameraControllerState> {
18✔
374
  private static readonly STREAM_MANAGEMENT = "streamManagement"; // key to index all RTPStreamManagement services
18✔
375

376
  private stateChangeDelegate?: StateChangeDelegate;
377

378
  private readonly streamCount: number;
379
  private readonly delegate: CameraStreamingDelegate;
380
  private readonly streamingOptions: CameraStreamingOptions;
381
  /**
382
   * **Temporary** storage for {@link CameraRecordingOptions} and {@link CameraRecordingDelegate}.
383
   * This property is reset to `undefined` after the CameraController was fully initialized.
384
   * You can still access those values via the {@link CameraController.recordingManagement}.
385
   */
386
  private recording?: {
387
    options: CameraRecordingOptions,
388
    delegate: CameraRecordingDelegate,
389
  };
390
  /**
391
   * Temporary storage for the sensor option.
392
   */
393
  private sensorOptions?: {
394
    motion?: Service | boolean;
395
    occupancy?: Service | boolean;
396
  };
397
  private readonly legacyMode: boolean = false;
213✔
398

399
  /**
400
   * @private
401
   */
402
  streamManagements: RTPStreamManagement[] = [];
213✔
403
  /**
404
   * The {@link RecordingManagement} which is responsible for handling HomeKit Secure Video.
405
   * This property is only present if recording was configured.
406
   */
407
  recordingManagement?: RecordingManagement;
408

409
  private microphoneService?: Microphone;
410
  private speakerService?: Speaker;
411

412
  private microphoneMuted = false;
213✔
413
  private microphoneVolume = 100;
213✔
414
  private speakerMuted = false;
213✔
415
  private speakerVolume = 100;
213✔
416

417
  motionService?: MotionSensor;
418
  private motionServiceExternallySupplied = false;
213✔
419
  occupancyService?: OccupancySensor;
420
  private occupancyServiceExternallySupplied = false;
213✔
421

422
  constructor(options: CameraControllerOptions, legacyMode = false) {
213✔
423
    super();
213✔
424
    this.streamCount = Math.max(1, options.cameraStreamCount || 1);
213!
425
    this.delegate = options.delegate;
213✔
426
    this.streamingOptions = options.streamingOptions;
213✔
427
    this.recording = options.recording;
213✔
428
    this.sensorOptions = options.sensors;
213✔
429

430
    this.legacyMode = legacyMode; // legacy mode will prevent from Microphone and Speaker services to get created to avoid collisions
213✔
431
  }
432

433
  /**
434
   * @private
435
   */
436
  controllerId(): ControllerIdentifier {
437
    return DefaultControllerType.CAMERA;
57✔
438
  }
439

440
  // ----------------------------------- STREAM API ------------------------------------
441

442
  /**
443
   * Call this method if you want to forcefully suspend an ongoing streaming session.
444
   * This would be adequate if the rtp server or media encoding encountered an unexpected error.
445
   *
446
   * @param sessionId - id of the current ongoing streaming session
447
   */
448
  public forceStopStreamingSession(sessionId: SessionIdentifier): void {
449
    this.streamManagements.forEach(management => {
×
450
      if (management.sessionIdentifier === sessionId) {
×
451
        management.forceStop();
×
452
      }
453
    });
454
  }
455

456
  public static generateSynchronisationSource(): number {
457
    const ssrc = crypto.randomBytes(4); // range [-2.14748e+09 - 2.14748e+09]
×
458
    ssrc[0] = 0;
×
459
    return ssrc.readInt32BE(0);
×
460
  }
461

462
  // ----------------------------- MICROPHONE/SPEAKER API ------------------------------
463

464
  public setMicrophoneMuted(muted = true): void {
×
465
    if (!this.microphoneService) {
×
466
      return;
×
467
    }
468

469
    this.microphoneMuted = muted;
×
470
    this.microphoneService.updateCharacteristic(Characteristic.Mute, muted);
×
471
  }
472

473
  public setMicrophoneVolume(volume: number): void {
474
    if (!this.microphoneService) {
×
475
      return;
×
476
    }
477

478
    this.microphoneVolume = volume;
×
479
    this.microphoneService.updateCharacteristic(Characteristic.Volume, volume);
×
480
  }
481

482
  public setSpeakerMuted(muted = true): void {
×
483
    if (!this.speakerService) {
×
484
      return;
×
485
    }
486

487
    this.speakerMuted = muted;
×
488
    this.speakerService.updateCharacteristic(Characteristic.Mute, muted);
×
489
  }
490

491
  public setSpeakerVolume(volume: number): void {
492
    if (!this.speakerService) {
×
493
      return;
×
494
    }
495

496
    this.speakerVolume = volume;
×
497
    this.speakerService.updateCharacteristic(Characteristic.Volume, volume);
×
498
  }
499

500
  private emitMicrophoneChange() {
501
    this.emit(CameraControllerEvents.MICROPHONE_PROPERTIES_CHANGED, this.microphoneMuted, this.microphoneVolume);
×
502
  }
503

504
  private emitSpeakerChange() {
505
    this.emit(CameraControllerEvents.SPEAKER_PROPERTIES_CHANGED, this.speakerMuted, this.speakerVolume);
×
506
  }
507

508
  // -----------------------------------------------------------------------------------
509
  /**
510
   * @private
511
   */
512
  constructServices(): CameraControllerServiceMap {
513
    for (let i = 0; i < this.streamCount; i++) {
216✔
514
      const rtp = new RTPStreamManagement(i, this.streamingOptions, this.delegate, undefined, this.rtpStreamManagementDisabledThroughOperatingMode.bind(this));
432✔
515
      this.streamManagements.push(rtp);
432✔
516
    }
517

518
    if (!this.legacyMode && this.streamingOptions.audio) {
216✔
519
      // In theory the Microphone Service is a necessity. In practice, it's not. lol.
520
      // So we just add it if the user wants to support audio
521
      this.microphoneService = new Service.Microphone("", "");
216✔
522
      this.microphoneService.setCharacteristic(Characteristic.Volume, this.microphoneVolume);
216✔
523

524
      if (this.streamingOptions.audio.twoWayAudio) {
216✔
525
        this.speakerService = new Service.Speaker("", "");
216✔
526
        this.speakerService.setCharacteristic(Characteristic.Volume, this.speakerVolume);
216✔
527
      }
528
    }
529

530
    if (this.recording) {
216✔
531
      this.recordingManagement = new RecordingManagement(this.recording.options, this.recording.delegate, this.retrieveEventTriggerOptions());
213✔
532
    }
533

534

535
    if (this.sensorOptions?.motion) {
216✔
536
      if (typeof this.sensorOptions.motion === "boolean") {
213!
537
        this.motionService = new Service.MotionSensor("", "");
213✔
538
      } else {
539
        this.motionService = this.sensorOptions.motion;
×
540
        this.motionServiceExternallySupplied = true;
×
541
      }
542

543
      this.motionService.setCharacteristic(Characteristic.StatusActive, true);
213✔
544
      this.recordingManagement?.recordingManagementService.addLinkedService(this.motionService);
213✔
545
    }
546

547
    if (this.sensorOptions?.occupancy) {
216✔
548
      if (typeof this.sensorOptions.occupancy === "boolean") {
213!
549
        this.occupancyService = new Service.OccupancySensor("", "");
213✔
550
      } else {
551
        this.occupancyService = this.sensorOptions.occupancy;
×
552
        this.occupancyServiceExternallySupplied = true;
×
553
      }
554

555
      this.occupancyService.setCharacteristic(Characteristic.StatusActive, true);
213✔
556
      this.recordingManagement?.recordingManagementService.addLinkedService(this.occupancyService);
213✔
557
    }
558

559

560
    const serviceMap: CameraControllerServiceMap = {
216✔
561
      microphone: this.microphoneService,
562
      speaker: this.speakerService,
563
      motionService: !this.motionServiceExternallySupplied ? this.motionService : undefined,
216!
564
      occupancyService: !this.occupancyServiceExternallySupplied ? this.occupancyService : undefined,
216!
565
    };
566

567
    if (this.recordingManagement) {
216✔
568
      serviceMap.cameraEventRecordingManagement = this.recordingManagement.recordingManagementService;
213✔
569
      serviceMap.cameraOperatingMode = this.recordingManagement.operatingModeService;
213✔
570
      serviceMap.dataStreamTransportManagement = this.recordingManagement.dataStreamManagement.getService();
213✔
571
    }
572

573
    this.streamManagements.forEach((management, index) => {
216✔
574
      serviceMap[CameraController.STREAM_MANAGEMENT + index] = management.getService();
432✔
575
    });
576

577
    this.recording = undefined;
216✔
578
    this.sensorOptions = undefined;
216✔
579

580
    return serviceMap;
216✔
581
  }
582

583
  /**
584
   * @private
585
   */
586
  initWithServices(serviceMap: CameraControllerServiceMap): void | CameraControllerServiceMap {
587
    const result = this._initWithServices(serviceMap);
×
588

589
    if (result.updated) { // serviceMap must only be returned if anything actually changed
×
590
      return result.serviceMap;
×
591
    }
592
  }
593

594
  protected _initWithServices(serviceMap: CameraControllerServiceMap): { serviceMap: CameraControllerServiceMap, updated: boolean } {
595
    let modifiedServiceMap = false;
×
596

597
    // eslint-disable-next-line no-constant-condition
598
    for (let i = 0; true; i++) {
×
599
      const streamManagementService = serviceMap[CameraController.STREAM_MANAGEMENT + i];
×
600

601
      if (i < this.streamCount) {
×
602
        const operatingModeClosure = this.rtpStreamManagementDisabledThroughOperatingMode.bind(this);
×
603

604
        if (streamManagementService) { // normal init
×
605
          this.streamManagements.push(new RTPStreamManagement(i, this.streamingOptions, this.delegate, streamManagementService, operatingModeClosure));
×
606
        } else { // stream count got bigger, we need to create a new service
607
          const management = new RTPStreamManagement(i, this.streamingOptions, this.delegate, undefined, operatingModeClosure);
×
608

609
          this.streamManagements.push(management);
×
610
          serviceMap[CameraController.STREAM_MANAGEMENT + i] = management.getService();
×
611

612
          modifiedServiceMap = true;
×
613
        }
614
      } else {
615
        if (streamManagementService) { // stream count got reduced, we need to remove old service
×
616
          delete serviceMap[CameraController.STREAM_MANAGEMENT + i];
×
617
          modifiedServiceMap = true;
×
618
        } else {
619
          break; // we finished counting, and we got no saved service; we are finished
×
620
        }
621
      }
622
    }
623

624
    // MICROPHONE
625
    if (!this.legacyMode && this.streamingOptions.audio) { // microphone should be present
×
626
      if (serviceMap.microphone) {
×
627
        this.microphoneService = serviceMap.microphone;
×
628
      } else {
629
        // microphone wasn't created yet => create a new one
630
        this.microphoneService = new Service.Microphone("", "");
×
631
        this.microphoneService.setCharacteristic(Characteristic.Volume, this.microphoneVolume);
×
632

633
        serviceMap.microphone = this.microphoneService;
×
634
        modifiedServiceMap = true;
×
635
      }
636
    } else if (serviceMap.microphone) { // microphone service supplied, though settings seemed to have changed
×
637
      // we need to remove it
638
      delete serviceMap.microphone;
×
639
      modifiedServiceMap = true;
×
640
    }
641

642
    // SPEAKER
643
    if (!this.legacyMode && this.streamingOptions.audio?.twoWayAudio) { // speaker should be present
×
644
      if (serviceMap.speaker) {
×
645
        this.speakerService = serviceMap.speaker;
×
646
      } else {
647
        // speaker wasn't created yet => create a new one
648
        this.speakerService = new Service.Speaker("", "");
×
649
        this.speakerService.setCharacteristic(Characteristic.Volume, this.speakerVolume);
×
650

651
        serviceMap.speaker = this.speakerService;
×
652
        modifiedServiceMap = true;
×
653
      }
654
    } else if (serviceMap.speaker) { // speaker service supplied, though settings seemed to have changed
×
655
      // we need to remove it
656
      delete serviceMap.speaker;
×
657
      modifiedServiceMap = true;
×
658
    }
659

660
    // RECORDING
661
    if (this.recording) {
×
662
      const eventTriggers = this.retrieveEventTriggerOptions();
×
663

664
      // RECORDING MANAGEMENT
665
      if (serviceMap.cameraEventRecordingManagement && serviceMap.cameraOperatingMode && serviceMap.dataStreamTransportManagement) {
×
666
        this.recordingManagement = new RecordingManagement(
×
667
          this.recording.options,
668
          this.recording.delegate,
669
          eventTriggers,
670
          {
671
            recordingManagement: serviceMap.cameraEventRecordingManagement,
672
            operatingMode: serviceMap.cameraOperatingMode,
673
            dataStreamManagement: new DataStreamManagement(serviceMap.dataStreamTransportManagement),
674
          },
675
        );
676
      } else {
677
        this.recordingManagement = new RecordingManagement(
×
678
          this.recording.options,
679
          this.recording.delegate,
680
          eventTriggers,
681
        );
682

683
        serviceMap.cameraEventRecordingManagement = this.recordingManagement.recordingManagementService;
×
684
        serviceMap.cameraOperatingMode = this.recordingManagement.operatingModeService;
×
685
        serviceMap.dataStreamTransportManagement = this.recordingManagement.dataStreamManagement.getService();
×
686
        modifiedServiceMap = true;
×
687
      }
688
    } else {
689
      if (serviceMap.cameraEventRecordingManagement) {
×
690
        delete serviceMap.cameraEventRecordingManagement;
×
691
        modifiedServiceMap = true;
×
692
      }
693
      if (serviceMap.cameraOperatingMode) {
×
694
        delete serviceMap.cameraOperatingMode;
×
695
        modifiedServiceMap = true;
×
696
      }
697
      if (serviceMap.dataStreamTransportManagement) {
×
698
        delete serviceMap.dataStreamTransportManagement;
×
699
        modifiedServiceMap = true;
×
700
      }
701
    }
702

703
    // MOTION SENSOR
704
    if (this.sensorOptions?.motion) {
×
705
      if (typeof this.sensorOptions.motion === "boolean") {
×
706
        if (serviceMap.motionService) {
×
707
          this.motionService = serviceMap.motionService;
×
708
        } else {
709
          // it could be the case that we previously had a manually supplied motion service
710
          // at this point we can't remove the iid from the list of linked services from the recording management!
711
          this.motionService = new Service.MotionSensor("", "");
×
712
        }
713
      } else {
714
        this.motionService = this.sensorOptions.motion;
×
715
        this.motionServiceExternallySupplied = true;
×
716

717
        if (serviceMap.motionService) { // motion service previously supplied as bool option
×
718
          this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.motionService);
×
719
          delete serviceMap.motionService;
×
720
          modifiedServiceMap = true;
×
721
        }
722
      }
723

724
      this.motionService.setCharacteristic(Characteristic.StatusActive, true);
×
725
      this.recordingManagement?.recordingManagementService.addLinkedService(this.motionService);
×
726
    } else {
727
      if (serviceMap.motionService) {
×
728
        this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.motionService);
×
729
        delete serviceMap.motionService;
×
730
        modifiedServiceMap = true;
×
731
      }
732
    }
733

734
    // OCCUPANCY SENSOR
735
    if (this.sensorOptions?.occupancy) {
×
736
      if (typeof this.sensorOptions.occupancy === "boolean") {
×
737
        if (serviceMap.occupancyService) {
×
738
          this.occupancyService = serviceMap.occupancyService;
×
739
        } else {
740
          // it could be the case that we previously had a manually supplied occupancy service
741
          // at this point we can't remove the iid from the list of linked services from the recording management!
742
          this.occupancyService = new Service.OccupancySensor("", "");
×
743
        }
744
      } else {
745
        this.occupancyService = this.sensorOptions.occupancy;
×
746
        this.occupancyServiceExternallySupplied = true;
×
747

748
        if (serviceMap.occupancyService) { // occupancy service previously supplied as bool option
×
749
          this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.occupancyService);
×
750
          delete serviceMap.occupancyService;
×
751
          modifiedServiceMap = true;
×
752
        }
753
      }
754

755
      this.occupancyService.setCharacteristic(Characteristic.StatusActive, true);
×
756
      this.recordingManagement?.recordingManagementService.addLinkedService(this.occupancyService);
×
757
    } else {
758
      if (serviceMap.occupancyService) {
×
759
        this.recordingManagement?.recordingManagementService.removeLinkedService(serviceMap.occupancyService);
×
760
        delete serviceMap.occupancyService;
×
761
        modifiedServiceMap = true;
×
762
      }
763
    }
764

765
    if (this.migrateFromDoorbell(serviceMap)) {
×
766
      modifiedServiceMap = true;
×
767
    }
768

769
    this.recording = undefined;
×
770
    this.sensorOptions = undefined;
×
771

772
    return {
×
773
      serviceMap: serviceMap,
774
      updated: modifiedServiceMap,
775
    };
776
  }
777

778
  // overwritten in DoorbellController (to avoid cyclic dependencies, I hate typescript for that)
779
  protected migrateFromDoorbell(serviceMap: ControllerServiceMap): boolean {
780
    if (serviceMap.doorbell) { // See NOTICE in DoorbellController
×
781
      delete serviceMap.doorbell;
×
782
      return true;
×
783
    }
784

785
    return false;
×
786
  }
787

788
  protected retrieveEventTriggerOptions(): Set<EventTriggerOption> {
789
    if (!this.recording) {
216!
790
      return new Set();
×
791
    }
792

793
    const triggerOptions = new Set<EventTriggerOption>();
216✔
794

795
    if (this.recording.options.overrideEventTriggerOptions) {
216✔
796
      for (const option of this.recording.options.overrideEventTriggerOptions) {
156✔
797
        triggerOptions.add(option);
156✔
798
      }
799
    }
800

801
    if (this.sensorOptions?.motion) {
216✔
802
      triggerOptions.add(EventTriggerOption.MOTION);
216✔
803
    }
804

805
    // this method is overwritten by the `DoorbellController` to automatically configure EventTriggerOption.DOORBELL
806

807
    return triggerOptions;
216✔
808
  }
809

810
  /**
811
   * @private
812
   */
813
  configureServices(): void {
814
    if (this.microphoneService) {
216✔
815
      this.microphoneService.getCharacteristic(Characteristic.Mute)!
216✔
816
        .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => {
817
          callback(undefined, this.microphoneMuted);
×
818
        })
819
        .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
820
          this.microphoneMuted = value as boolean;
×
821
          callback();
×
822
          this.emitMicrophoneChange();
×
823
        });
824
      this.microphoneService.getCharacteristic(Characteristic.Volume)!
216✔
825
        .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => {
826
          callback(undefined, this.microphoneVolume);
×
827
        })
828
        .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
829
          this.microphoneVolume = value as number;
×
830
          callback();
×
831
          this.emitMicrophoneChange();
×
832
        });
833
    }
834

835
    if (this.speakerService) {
216✔
836
      this.speakerService.getCharacteristic(Characteristic.Mute)!
216✔
837
        .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => {
838
          callback(undefined, this.speakerMuted);
×
839
        })
840
        .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
841
          this.speakerMuted = value as boolean;
×
842
          callback();
×
843
          this.emitSpeakerChange();
×
844
        });
845
      this.speakerService.getCharacteristic(Characteristic.Volume)!
216✔
846
        .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => {
847
          callback(undefined, this.speakerVolume);
×
848
        })
849
        .on(CharacteristicEventTypes.SET, (value: CharacteristicValue, callback: CharacteristicSetCallback) => {
850
          this.speakerVolume = value as number;
×
851
          callback();
×
852
          this.emitSpeakerChange();
×
853
        });
854
    }
855

856
    // make the sensor services available to the RecordingManagement.
857
    if (this.motionService) {
216✔
858
      this.recordingManagement?.sensorServices.push(this.motionService);
216✔
859
    }
860
    if (this.occupancyService) {
216✔
861
      this.recordingManagement?.sensorServices.push(this.occupancyService);
216✔
862
    }
863
  }
864

865
  private rtpStreamManagementDisabledThroughOperatingMode(): boolean {
866
    return this.recordingManagement
×
867
      ? !this.recordingManagement.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).value
868
      : false;
869
  }
870

871

872
  /**
873
   * @private
874
   */
875
  handleControllerRemoved(): void {
876
    this.handleFactoryReset();
6✔
877

878
    for (const management of this.streamManagements) {
6✔
879
      management.destroy();
12✔
880
    }
881
    this.streamManagements.splice(0, this.streamManagements.length);
6✔
882

883
    this.microphoneService = undefined;
6✔
884
    this.speakerService = undefined;
6✔
885

886
    this.recordingManagement?.destroy();
6✔
887
    this.recordingManagement = undefined;
6✔
888

889
    this.removeAllListeners();
6✔
890
  }
891

892
  /**
893
   * @private
894
   */
895
  handleFactoryReset(): void {
896
    this.streamManagements.forEach(management => management.handleFactoryReset());
48✔
897
    this.recordingManagement?.handleFactoryReset();
24✔
898

899
    this.microphoneMuted = false;
24✔
900
    this.microphoneVolume = 100;
24✔
901
    this.speakerMuted = false;
24✔
902
    this.speakerVolume = 100;
24✔
903
  }
904

905
  /**
906
   * @private
907
   */
908
  serialize(): CameraControllerState | undefined {
909
    const streamManagementStates: RTPStreamManagementState[] = [];
48✔
910

911
    for (const management of this.streamManagements) {
48✔
912
      const serializedState = management.serialize();
96✔
913
      if (serializedState) {
96✔
914
        streamManagementStates.push(serializedState);
96✔
915
      }
916
    }
917

918
    return {
48✔
919
      streamManagements: streamManagementStates,
920
      recordingManagement: this.recordingManagement?.serialize(),
921
    };
922
  }
923

924
  /**
925
   * @private
926
   */
927
  deserialize(serialized: CameraControllerState): void {
928
    for (const streamManagementState of serialized.streamManagements) {
24✔
929
      const streamManagement = this.streamManagements[streamManagementState.id];
36✔
930
      if (streamManagement) {
36✔
931
        streamManagement.deserialize(streamManagementState);
36✔
932
      }
933
    }
934

935
    if (serialized.recordingManagement) {
24✔
936
      if (this.recordingManagement) {
24!
937
        this.recordingManagement.deserialize(serialized.recordingManagement);
24✔
938
      } else {
939
        // Active characteristic cannot be controlled if removing HSV, ensure they are all active!
940
        for (const streamManagement of this.streamManagements) {
×
941
          streamManagement.service.updateCharacteristic(Characteristic.Active, true);
×
942
        }
943

944
        this.stateChangeDelegate?.();
×
945
      }
946
    }
947
  }
948

949
  /**
950
   * @private
951
   */
952
  setupStateChangeDelegate(delegate?: StateChangeDelegate): void {
953
    this.stateChangeDelegate = delegate;
27✔
954

955
    for (const streamManagement of this.streamManagements) {
27✔
956
      streamManagement.setupStateChangeDelegate(delegate);
54✔
957
    }
958

959
    this.recordingManagement?.setupStateChangeDelegate(delegate);
27✔
960
  }
961

962
  /**
963
   * @private
964
   */
965
  handleSnapshotRequest(height: number, width: number, accessoryName?: string, reason?: ResourceRequestReason): Promise<Buffer> {
966
    // first step is to verify that the reason is applicable to our current policy
967
    const streamingDisabled = this.streamManagements
174✔
968
      .map(management => !management.getService().getCharacteristic(Characteristic.Active).value)
348✔
969
      .reduce((previousValue, currentValue) => previousValue && currentValue);
174✔
970
    if (streamingDisabled) {
174✔
971
      debug("[%s] Rejecting snapshot as streaming is disabled.", accessoryName);
36✔
972
      return Promise.reject(HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE);
36✔
973
    }
974

975
    if (this.recordingManagement) {
138✔
976
      const operatingModeService = this.recordingManagement.operatingModeService;
135✔
977
      if (!operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).value) {
135✔
978
        debug("[%s] Rejecting snapshot as HomeKit camera is disabled.", accessoryName);
39✔
979
        return Promise.reject(HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE);
39✔
980
      }
981

982
      const eventSnapshotsActive = operatingModeService
96✔
983
        .getCharacteristic(Characteristic.EventSnapshotsActive)
984
        .value;
985
      if (!eventSnapshotsActive) {
96✔
986
        if (reason == null) {
36✔
987
          debug("[%s] Rejecting snapshot as reason is required due to disabled event snapshots.", accessoryName);
12✔
988
          return Promise.reject(HAPStatus.INSUFFICIENT_PRIVILEGES);
12✔
989
        } else if (reason === ResourceRequestReason.EVENT) {
24✔
990
          debug("[%s] Rejecting snapshot as even snapshots are disabled.", accessoryName);
12✔
991
          return Promise.reject(HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE);
12✔
992
        }
993
      }
994

995
      const periodicSnapshotsActive = operatingModeService
72✔
996
        .getCharacteristic(Characteristic.PeriodicSnapshotsActive)
997
        .value;
998
      if (!periodicSnapshotsActive) {
72✔
999
        if (reason == null) {
36✔
1000
          debug("[%s] Rejecting snapshot as reason is required due to disabled periodic snapshots.", accessoryName);
12✔
1001
          return Promise.reject(HAPStatus.INSUFFICIENT_PRIVILEGES);
12✔
1002
        } else if (reason === ResourceRequestReason.PERIODIC) {
24✔
1003
          debug("[%s] Rejecting snapshot as periodic snapshots are disabled.", accessoryName);
12✔
1004
          return Promise.reject(HAPStatus.NOT_ALLOWED_IN_CURRENT_STATE);
12✔
1005
        }
1006
      }
1007
    }
1008

1009
    // now do the actual snapshot request.
1010
    return new Promise((resolve, reject) => {
51✔
1011
      // TODO test and make timeouts configurable!
1012
      let timeout: NodeJS.Timeout | undefined = setTimeout(() => {
51✔
1013
        console.warn(
×
1014
          `[${accessoryName}] The image snapshot handler for the given accessory is slow to respond! See https://homebridge.io/w/JtMGR for more info.`,
1015
        );
1016

1017
        timeout = setTimeout(() => {
×
1018
          timeout = undefined;
×
1019

1020
          console.warn(
×
1021
            `[${accessoryName}] The image snapshot handler for the given accessory didn't respond at all! See https://homebridge.io/w/JtMGR for more info.`,
1022
          );
1023

1024
          reject(HAPStatus.OPERATION_TIMED_OUT);
×
1025
        }, 17000);
1026
        timeout.unref();
×
1027
      }, 8000);
1028
      timeout.unref();
51✔
1029

1030
      try {
51✔
1031
        this.delegate.handleSnapshotRequest({
51✔
1032
          height: height,
1033
          width: width,
1034
          reason: reason,
1035
        }, (error, buffer) => {
1036
          if (!timeout) {
51!
1037
            return;
×
1038
          } else {
1039
            clearTimeout(timeout);
51✔
1040
            timeout = undefined;
51✔
1041
          }
1042

1043
          if (error) {
51!
1044
            if (typeof error === "number") {
×
1045
              reject(error);
×
1046
            } else {
1047
              debug("[%s] Error getting snapshot: %s", accessoryName, error.stack);
×
1048
              reject(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
×
1049
            }
1050
            return;
×
1051
          }
1052

1053
          if (!buffer || buffer.length === 0) {
51!
1054
            console.warn(`[${accessoryName}] Snapshot request handler provided empty image buffer!`);
×
1055
            reject(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
×
1056
          } else {
1057
            resolve(buffer);
51✔
1058
          }
1059
        });
1060
      } catch (error) {
1061
        if (!timeout) {
×
1062
          return;
×
1063
        } else {
1064
          clearTimeout(timeout);
×
1065
          timeout = undefined;
×
1066
        }
1067

1068
        console.warn(`[${accessoryName}] Unhandled error thrown inside snapshot request handler: ${error.stack}`);
×
1069
        reject(error instanceof HapStatusError ? error.hapStatus : HAPStatus.SERVICE_COMMUNICATION_FAILURE);
×
1070
      }
1071
    });
1072
  }
1073
}
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