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

homebridge / HAP-NodeJS / 9884254174

11 Jul 2024 02:00AM UTC coverage: 64.289%. First build
9884254174

Pull #1042

github

web-flow
Merge branch 'latest' into beta-0.12.3
Pull Request #1042: v1.0.0

1362 of 2513 branches covered (54.2%)

Branch coverage included in aggregate %.

28 of 51 new or added lines in 10 files covered. (54.9%)

6219 of 9279 relevant lines covered (67.02%)

310.73 hits per line

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

58.12
/src/lib/camera/RecordingManagement.ts
1
import crypto from "crypto";
18✔
2
import createDebug from "debug";
18✔
3
import { EventEmitter } from "events";
18✔
4
import { AudioBitrate, VideoCodecType } from ".";
5
import { Access, Characteristic, CharacteristicEventTypes } from "../Characteristic";
18✔
6
import { CameraRecordingDelegate, StateChangeDelegate } from "../controller";
7
import {
18✔
8
  DataStreamConnection,
9
  DataStreamConnectionEvent,
10
  DataStreamManagement,
11
  DataStreamProtocolHandler,
12
  EventHandler,
13
  HDSConnectionError,
14
  HDSConnectionErrorType,
15
  HDSProtocolError,
16
  HDSProtocolSpecificErrorReason,
17
  HDSStatus,
18
  Protocols,
19
  RequestHandler,
20
  Topics,
21
} from "../datastream";
22
import { CameraOperatingMode, CameraRecordingManagement } from "../definitions";
23
import { HAPStatus } from "../HAPServer";
24
import { Service } from "../Service";
18✔
25
import { HapStatusError } from "../util/hapStatusError";
18✔
26
import * as tlv from "../util/tlv";
18✔
27
import { H264CodecParameters, H264Level, H264Profile, Resolution } from "./RTPStreamManagement";
28

29
const debug = createDebug("HAP-NodeJS:Camera:RecordingManagement");
18✔
30

31
/**
32
 * Describes options passed to the {@link RecordingManagement}.
33
 *
34
 * @group Camera
35
 */
36
export interface CameraRecordingOptions {
37
  /**
38
   * The size of the prebuffer in milliseconds. It must be at least 4000 ms.
39
   * A sensible value for this property is in the interval [4000, 8000].
40
   *
41
   * In order to provide some context to recording event, it is a good user experience
42
   * to also have the recording of a few seconds before the event occurs.
43
   * This exactly is the prebuffer. A camera will constantly store the last
44
   * x seconds (the `prebufferLength`) to provide more context to a given event.
45
   */
46
  prebufferLength: number;
47

48
  /**
49
   * This property can be used to override the automatic heuristic of the {@link CameraController}
50
   * which derives the {@link EventTriggerOption}s from application state.
51
   *
52
   * {@link EventTriggerOption}s are derived automatically as follows:
53
   * * {@link EventTriggerOption.MOTION} is enabled when a {@link Service.MotionSensor} is configured (via {@link CameraControllerOptions.sensors}).
54
   * * {@link EventTriggerOption.DOORBELL} is enabled when the {@link DoorbellController} is used.
55
   *
56
   * Note: This property is **ADDITIVE**. Meaning if the {@link CameraController} decides to add
57
   * a certain {@link EventTriggerOption} it will still do so. This option can only be used to
58
   * add **additional** {@link EventTriggerOption}s!
59
   */
60
  overrideEventTriggerOptions?: EventTriggerOption[]
61

62
  /**
63
   * List of supported media {@link MediaContainerConfiguration}s (or a single one).
64
   */
65
  mediaContainerConfiguration: MediaContainerConfiguration | MediaContainerConfiguration[];
66

67
  video: VideoRecordingOptions,
68
  audio: AudioRecordingOptions,
69
}
70

71
/**
72
 * Describes the Event trigger.
73
 *
74
 * @group Camera
75
 */
76
export const enum EventTriggerOption {
18✔
77
  /**
78
   * The Motion trigger. If enabled motion should trigger the start of a recording.
79
   */
80
  MOTION = 0x01,
18✔
81
  /**
82
   * The Doorbell trigger. If enabled a doorbell button press should trigger the start of a recording.
83
   *
84
   * Note: While the doorbell is defined by the HomeKit specification and HAP-NodeJS supports (and the
85
   * {@link RecordingManagement} advertises support for it), HomeKit HomeHubs will (as of now, iOS 15-16)
86
   * never enable Doorbell triggers. Seemingly this is currently unsupported by Apple.
87
   * See https://github.com/homebridge/HAP-NodeJS/issues/976#issuecomment-1280301989.
88
   */
89
  DOORBELL = 0x02,
18✔
90
}
91

92
/**
93
 * @group Camera
94
 */
95
export const enum MediaContainerType {
18✔
96
  FRAGMENTED_MP4 = 0x00
18✔
97
}
98

99
/**
100
 * @group Camera
101
 */
102
export interface MediaContainerConfiguration {
103
  /**
104
   * The type of media container.
105
   */
106
  type: MediaContainerType;
107
  /**
108
   * The length in milliseconds of every individual recording fragment.
109
   * A typical value of HomeKit Secure Video cameras is 4000ms.
110
   */
111
  fragmentLength: number;
112
}
113

114
/**
115
 * @group Camera
116
 */
117
export interface VideoRecordingOptions {
118
  type: VideoCodecType;
119
  parameters: H264CodecParameters;
120
  /**
121
   * Required resolutions to be supported are:
122
   * * 1920x1080
123
   * * 1280x720
124
   *
125
   * The following frame rates are required to be supported:
126
   * * 15 fps
127
   * * 24fps or 30fps
128
   */
129
  resolutions: Resolution[];
130
}
131

132
/**
133
 * @group Camera
134
 */
135
export type AudioRecordingOptions = {
136
  /**
137
   * List (or single entry) of supported {@link AudioRecordingCodec}s.
138
   */
139
  codecs: AudioRecordingCodec | AudioRecordingCodec[],
140
}
141

142
/**
143
 * @group Camera
144
 */
145
export type AudioRecordingCodec = {
146
  type: AudioRecordingCodecType,
147
  /**
148
   * The count of audio channels. Must be at least `1`.
149
   * Defaults to `1`.
150
   */
151
  audioChannels?: number,
152
  /**
153
   * The supported bitrate mode. Defaults to {@link AudioBitrate.VARIABLE}.
154
   */
155
  bitrateMode?: AudioBitrate,
156
  samplerate: AudioRecordingSamplerate[] | AudioRecordingSamplerate,
157
}
158

159
/**
160
 * This type describes the SelectedCameraRecordingConfiguration (written by the device to {@link Characteristic.SelectedCameraRecordingConfiguration}).
161
 *
162
 * @group Camera
163
 */
164
export interface CameraRecordingConfiguration {
165
  /**
166
   * The size of the prebuffer in milliseconds.
167
   * This value is less or equal of the value advertised in the {@link Characteristic.SupportedCameraRecordingConfiguration}.
168
   */
169
  prebufferLength: number;
170
  /**
171
   * List of the enabled {@link EventTriggerOption}s.
172
   */
173
  eventTriggerTypes: EventTriggerOption[];
174
  /**
175
   * The selected {@link MediaContainerConfiguration}.
176
   */
177
  mediaContainerConfiguration: MediaContainerConfiguration;
178

179
  /**
180
   * The selected video codec configuration.
181
   */
182
  videoCodec: {
183
    type: VideoCodecType.H264;
184
    parameters: SelectedH264CodecParameters,
185
    resolution: Resolution,
186
  },
187

188
  /**
189
   * The selected audio codec configuration.
190
   */
191
  audioCodec: AudioRecordingCodec & {
192
    bitrate: number,
193
    samplerate: AudioRecordingSamplerate,
194
  },
195
}
196

197
/**
198
 * @group Camera
199
 */
200
export interface SelectedH264CodecParameters {
201
  profile: H264Profile,
202
  level: H264Level,
203
  bitRate: number,
204
  /**
205
   * The selected i-frame interval in milliseconds.
206
   */
207
  iFrameInterval: number,
208
}
209

210

211
const enum VideoCodecConfigurationTypes {
18✔
212
  CODEC_TYPE = 0x01,
18✔
213
  CODEC_PARAMETERS = 0x02,
18✔
214
  ATTRIBUTES = 0x03,
18✔
215
}
216

217
const enum VideoCodecParametersTypes {
18✔
218
  PROFILE_ID = 0x01,
18✔
219
  LEVEL = 0x02,
18✔
220
  BITRATE = 0x03,
18✔
221
  IFRAME_INTERVAL = 0x04,
18✔
222
}
223

224
const enum VideoAttributesTypes {
18✔
225
  IMAGE_WIDTH = 0x01,
18✔
226
  IMAGE_HEIGHT = 0x02,
18✔
227
  FRAME_RATE = 0x03,
18✔
228
}
229

230
const enum SelectedCameraRecordingConfigurationTypes {
18✔
231
  SELECTED_RECORDING_CONFIGURATION = 0x01,
18✔
232
  SELECTED_VIDEO_CONFIGURATION = 0x02,
18✔
233
  SELECTED_AUDIO_CONFIGURATION = 0x03,
18✔
234
}
235

236
/**
237
 * @group Camera
238
 */
239
export const enum AudioRecordingCodecType {
18✔
240
  AAC_LC = 0,
18✔
241
  AAC_ELD = 1,
18✔
242
}
243

244
/**
245
 * @group Camera
246
 */
247
export const enum AudioRecordingSamplerate {
18✔
248
  KHZ_8 = 0,
18✔
249
  KHZ_16 = 1,
18✔
250
  KHZ_24 = 2,
18✔
251
  KHZ_32 = 3,
18✔
252
  KHZ_44_1 = 4,
18✔
253
  KHZ_48 = 5,
18✔
254
}
255

256
const enum SupportedVideoRecordingConfigurationTypes {
18✔
257
  VIDEO_CODEC_CONFIGURATION = 0x01,
18✔
258
}
259

260
const enum SupportedCameraRecordingConfigurationTypes {
18✔
261
  PREBUFFER_LENGTH = 0x01,
18✔
262
  EVENT_TRIGGER_OPTIONS = 0x02,
18✔
263
  MEDIA_CONTAINER_CONFIGURATIONS = 0x03
18✔
264
}
265

266
const enum MediaContainerConfigurationTypes {
18✔
267
  MEDIA_CONTAINER_TYPE = 0x01,
18✔
268
  MEDIA_CONTAINER_PARAMETERS = 0x02,
18✔
269
}
270

271
const enum MediaContainerParameterTypes {
18✔
272
  FRAGMENT_LENGTH = 0x01,
18✔
273
}
274

275
const enum AudioCodecParametersTypes {
18✔
276
  CHANNEL = 0x01,
18✔
277
  BIT_RATE = 0x02,
18✔
278
  SAMPLE_RATE = 0x03,
18✔
279
  MAX_AUDIO_BITRATE = 0x04 // only present in selected audio codec parameters tlv
18✔
280
}
281

282
const enum AudioCodecConfigurationTypes {
18✔
283
  CODEC_TYPE = 0x01,
18✔
284
  CODEC_PARAMETERS = 0x02,
18✔
285
}
286

287
const enum SupportedAudioRecordingConfigurationTypes {
18✔
288
  AUDIO_CODEC_CONFIGURATION = 0x01,
18✔
289
}
290

291
/**
292
 * @group Camera
293
 */
294
export const enum PacketDataType {
18✔
295
  /**
296
   * mp4 moov box
297
   */
298
  MEDIA_INITIALIZATION = "mediaInitialization",
18✔
299
  /**
300
   * mp4 moof + mdat boxes
301
   */
302
  MEDIA_FRAGMENT = "mediaFragment",
18✔
303
}
304

305
interface DataSendDataEvent {
306
  streamId: number;
307
  packets: {
308
    data: Buffer;
309
    metadata: {
310
      dataType: PacketDataType,
311
      dataSequenceNumber: number,
312
      isLastDataChunk: boolean,
313
      dataChunkSequenceNumber: number,
314
      dataTotalSize?: number,
315
    }
316
  }[];
317
  endOfStream?: boolean;
318
}
319

320
/**
321
 * @group Camera
322
 */
323
export interface RecordingPacket {
324
  /**
325
   * The `Buffer` containing the data of the packet.
326
   */
327
  data: Buffer;
328
  /**
329
   * Defines if this `RecordingPacket` is the last one in the recording stream.
330
   * If `true` this will signal an end of stream and closes the recording stream.
331
   */
332
  isLast: boolean;
333
}
334

335

336
/**
337
 * @group Camera
338
 */
339
export interface RecordingManagementServices {
340
  recordingManagement: CameraRecordingManagement;
341
  operatingMode: CameraOperatingMode;
342
  dataStreamManagement: DataStreamManagement;
343
}
344

345
/**
346
 * @group Camera
347
 */
348
export interface RecordingManagementState {
349
  /**
350
   * This property stores a hash of the supported configurations (recording, video and audio) of
351
   * the recording management. We use this to determine if the configuration was changed by the user.
352
   * If it was changed, we need to discard the `selectedConfiguration` to signify to HomeKit Controllers
353
   * that they might reconsider their decision based on the updated configuration.
354
   */
355
  configurationHash: {
356
    algorithm: "sha256";
357
    hash: string;
358
  };
359

360
  /**
361
   * The base64 encoded tlv of the {@link CameraRecordingConfiguration}.
362
   * This value MIGHT be `undefined` if no HomeKit controller has yet selected a configuration.
363
   */
364
  selectedConfiguration?: string;
365

366
  /**
367
   * Service `CameraRecordingManagement`; Characteristic `Active`
368
   */
369
  recordingActive: boolean;
370
  /**
371
   * Service `CameraRecordingManagement`; Characteristic `RecordingAudioActive`
372
   */
373
  recordingAudioActive: boolean;
374

375
  /**
376
   * Service `CameraOperatingMode`; Characteristic `EventSnapshotsActive`
377
   */
378
  eventSnapshotsActive: boolean;
379
  /**
380
   * Service `CameraOperatingMode`; Characteristic `HomeKitCameraActive`
381
   */
382
  homeKitCameraActive: boolean;
383
  /**
384
   * Service `CameraOperatingMode`; Characteristic `PeriodicSnapshotsActive`
385
   */
386
  periodicSnapshotsActive: boolean;
387
}
388

389
/**
390
 * @group Camera
391
 */
392
export class RecordingManagement {
18✔
393
  readonly options: CameraRecordingOptions;
394
  readonly delegate: CameraRecordingDelegate;
395

396
  private stateChangeDelegate?: StateChangeDelegate;
397

398
  private readonly supportedCameraRecordingConfiguration: string;
399
  private readonly supportedVideoRecordingConfiguration: string;
400
  private readonly supportedAudioRecordingConfiguration: string;
401

402
  /**
403
   * 32 bit mask of enabled {@link EventTriggerOption}s.
404
   */
405
  private readonly eventTriggerOptions: number;
406

407
  readonly recordingManagementService: CameraRecordingManagement;
408
  readonly operatingModeService: CameraOperatingMode;
409
  readonly dataStreamManagement: DataStreamManagement;
410

411
  /**
412
   * The currently active recording stream.
413
   * Any camera only supports one stream at a time.
414
   */
415
  private recordingStream?: CameraRecordingStream;
416
  private selectedConfiguration?: {
417
    /**
418
     * The parsed configuration structure.
419
     */
420
    parsed: CameraRecordingConfiguration,
421
    /**
422
     * The rawValue representation. TLV8 data encoded as base64 string.
423
     */
424
    base64: string,
425
  };
426

427
  /**
428
   * Array of sensor services (e.g. {@link Service.MotionSensor} or {@link Service.OccupancySensor}).
429
   * Any service in this array owns a {@link Characteristic.StatusActive} characteristic.
430
   * The value of the {@link Characteristic.HomeKitCameraActive} is mirrored towards the {@link Characteristic.StatusActive} characteristic.
431
   * The array is initialized my the caller shortly after calling the constructor.
432
   */
433
  sensorServices: Service[] = [];
219✔
434

435
  /**
436
   * Defines if recording is enabled for this recording management.
437
   */
438
  private recordingActive = false;
219✔
439

440
  constructor(
441
    options: CameraRecordingOptions,
442
    delegate: CameraRecordingDelegate,
443
    eventTriggerOptions: Set<EventTriggerOption>,
444
    services?: RecordingManagementServices,
445
  ) {
446
    this.options = options;
219✔
447
    this.delegate = delegate;
219✔
448

449
    const recordingServices = services || this.constructService();
219✔
450
    this.recordingManagementService = recordingServices.recordingManagement;
219✔
451
    this.operatingModeService = recordingServices.operatingMode;
219✔
452
    this.dataStreamManagement = recordingServices.dataStreamManagement;
219✔
453

454
    this.eventTriggerOptions = 0;
219✔
455
    for (const option of eventTriggerOptions) {
219✔
456
      this.eventTriggerOptions |= option; // OR
378✔
457
    }
458

459
    this.supportedCameraRecordingConfiguration = this._supportedCameraRecordingConfiguration(options);
219✔
460
    this.supportedVideoRecordingConfiguration = this._supportedVideoRecordingConfiguration(options.video);
219✔
461
    this.supportedAudioRecordingConfiguration = this._supportedAudioStreamConfiguration(options.audio);
219✔
462

463
    this.setupServiceHandlers();
219✔
464
  }
465

466
  private constructService(): RecordingManagementServices {
467
    const recordingManagement = new Service.CameraRecordingManagement("", "");
219✔
468
    recordingManagement.setCharacteristic(Characteristic.Active, false);
219✔
469
    recordingManagement.setCharacteristic(Characteristic.RecordingAudioActive, false);
219✔
470

471
    const operatingMode = new Service.CameraOperatingMode("", "");
219✔
472
    operatingMode.setCharacteristic(Characteristic.EventSnapshotsActive, true);
219✔
473
    operatingMode.setCharacteristic(Characteristic.HomeKitCameraActive, true);
219✔
474
    operatingMode.setCharacteristic(Characteristic.PeriodicSnapshotsActive, true);
219✔
475

476
    const dataStreamManagement = new DataStreamManagement();
219✔
477
    recordingManagement.addLinkedService(dataStreamManagement.getService());
219✔
478

479
    return {
219✔
480
      recordingManagement: recordingManagement,
481
      operatingMode: operatingMode,
482
      dataStreamManagement: dataStreamManagement,
483
    };
484
  }
485

486
  private setupServiceHandlers() {
487
    // update the current configuration values to the current state.
488
    this.recordingManagementService.setCharacteristic(Characteristic.SupportedCameraRecordingConfiguration, this.supportedCameraRecordingConfiguration);
219✔
489
    this.recordingManagementService.setCharacteristic(Characteristic.SupportedVideoRecordingConfiguration, this.supportedVideoRecordingConfiguration);
219✔
490
    this.recordingManagementService.setCharacteristic(Characteristic.SupportedAudioRecordingConfiguration, this.supportedAudioRecordingConfiguration);
219✔
491

492
    this.recordingManagementService.getCharacteristic(Characteristic.SelectedCameraRecordingConfiguration)
219✔
493
      .onGet(this.handleSelectedCameraRecordingConfigurationRead.bind(this))
494
      .onSet(this.handleSelectedCameraRecordingConfigurationWrite.bind(this))
495
      .setProps({ adminOnlyAccess: [Access.WRITE] });
496

497
    this.recordingManagementService.getCharacteristic(Characteristic.Active)
219✔
498
      .onSet(value => {
499
        if (!!value === this.recordingActive) {
×
500
          return; // skip delegate call if state didn't change!
×
501
        }
502

503
        this.recordingActive = !!value;
×
504
        this.delegate.updateRecordingActive(this.recordingActive);
×
505
      })
506
      .on(CharacteristicEventTypes.CHANGE, () => this.stateChangeDelegate?.())
48✔
507
      .setProps({ adminOnlyAccess: [Access.WRITE] });
508

509
    this.recordingManagementService.getCharacteristic(Characteristic.RecordingAudioActive)
219✔
510
      .on(CharacteristicEventTypes.CHANGE, () => this.stateChangeDelegate?.());
48✔
511

512
    this.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive)
219✔
513
      .on(CharacteristicEventTypes.CHANGE, change => {
514
        for (const service of this.sensorServices) {
60✔
515
          service.setCharacteristic(Characteristic.StatusActive, !!change.newValue);
120✔
516
        }
517

518
        if (!change.newValue && this.recordingStream) {
60!
519
          this.recordingStream.close(HDSProtocolSpecificErrorReason.NOT_ALLOWED);
×
520
        }
521

522
        this.stateChangeDelegate?.();
60✔
523
      })
524
      .setProps({ adminOnlyAccess: [Access.WRITE] });
525

526
    this.operatingModeService.getCharacteristic(Characteristic.EventSnapshotsActive)
219✔
527
      .on(CharacteristicEventTypes.CHANGE, () => this.stateChangeDelegate?.())
60✔
528
      .setProps({ adminOnlyAccess: [Access.WRITE] });
529

530
    this.operatingModeService.getCharacteristic(Characteristic.PeriodicSnapshotsActive)
219✔
531
      .on(CharacteristicEventTypes.CHANGE, () => this.stateChangeDelegate?.())
120✔
532
      .setProps({ adminOnlyAccess: [Access.WRITE] });
533

534
    this.dataStreamManagement
219✔
535
      .onRequestMessage(Protocols.DATA_SEND, Topics.OPEN, this.handleDataSendOpen.bind(this));
536
  }
537

538
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
539
  private handleDataSendOpen(connection: DataStreamConnection, id: number, message: Record<any, any>) {
540
    // for message fields see https://github.com/Supereg/secure-video-specification#41-start
541
    const streamId: number = message.streamId;
×
542
    const type: string = message.type;
×
543
    const target: string = message.target;
×
544
    const reason: string = message.reason;
×
545

546
    if (target !== "controller" || type !== "ipcamera.recording") {
×
547
      debug("[HDS %s] Received data send with unexpected target: %s or type: %d. Rejecting...",
×
548
        connection.remoteAddress, target, type);
549
      connection.sendResponse(Protocols.DATA_SEND, Topics.OPEN, id, HDSStatus.PROTOCOL_SPECIFIC_ERROR, {
×
550
        status: HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE,
551
      });
552
      return;
×
553
    }
554

555
    if (!this.recordingActive) {
×
556
      connection.sendResponse(Protocols.DATA_SEND, Topics.OPEN, id, HDSStatus.PROTOCOL_SPECIFIC_ERROR, {
×
557
        status: HDSProtocolSpecificErrorReason.NOT_ALLOWED,
558
      });
559
      return;
×
560
    }
561

562
    if (!this.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).value) {
×
563
      connection.sendResponse(Protocols.DATA_SEND, Topics.OPEN, id, HDSStatus.PROTOCOL_SPECIFIC_ERROR, {
×
564
        status: HDSProtocolSpecificErrorReason.NOT_ALLOWED,
565
      });
566
      return;
×
567
    }
568

569
    if (this.recordingStream) {
×
570
      debug("[HDS %s] Rejecting DATA_SEND OPEN as another stream (%s) is already recording with streamId %d!",
×
571
        connection.remoteAddress, this.recordingStream.connection.remoteAddress, this.recordingStream.streamId);
572
      // there is already a recording stream running.
573
      connection.sendResponse(Protocols.DATA_SEND, Topics.OPEN, id, HDSStatus.PROTOCOL_SPECIFIC_ERROR, {
×
574
        status: HDSProtocolSpecificErrorReason.BUSY,
575
      });
576
      return;
×
577
    }
578

579
    if (!this.selectedConfiguration) {
×
580
      connection.sendResponse(Protocols.DATA_SEND, Topics.OPEN, id, HDSStatus.PROTOCOL_SPECIFIC_ERROR, {
×
581
        status: HDSProtocolSpecificErrorReason.INVALID_CONFIGURATION,
582
      });
583
      return;
×
584
    }
585

586
    debug("[HDS %s] HDS DATA_SEND Open with reason '%s'.", connection.remoteAddress, reason);
×
587

588
    // eslint-disable-next-line @typescript-eslint/no-use-before-define
589
    this.recordingStream = new CameraRecordingStream(connection, this.delegate, id, streamId);
×
590
    this.recordingStream.on(CameraRecordingStreamEvents.CLOSED, () => {
×
591
      debug("[HDS %s] Removing active recoding session from recording management!", connection.remoteAddress);
×
592
      this.recordingStream = undefined;
×
593
    });
594

595
    this.recordingStream.startStreaming();
×
596
  }
597

598
  private handleSelectedCameraRecordingConfigurationRead(): string {
599
    if (!this.selectedConfiguration) {
3!
600
      throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
×
601
    }
602

603
    return this.selectedConfiguration.base64;
3✔
604
  }
605

606
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
607
  private handleSelectedCameraRecordingConfigurationWrite(value: any): void {
608
    const configuration = this.parseSelectedConfiguration(value);
3✔
609

610
    const changed = this.selectedConfiguration?.base64 !== value;
3✔
611

612
    this.selectedConfiguration = {
3✔
613
      parsed: configuration,
614
      base64: value,
615
    };
616

617
    if (changed) {
3✔
618
      this.delegate.updateRecordingConfiguration(this.selectedConfiguration.parsed);
3✔
619

620
      // notify controller storage about updated values!
621
      this.stateChangeDelegate?.();
3✔
622
    }
623
  }
624

625
  private parseSelectedConfiguration(value: string): CameraRecordingConfiguration {
626
    const decoded = tlv.decode(Buffer.from(value, "base64"));
3✔
627

628
    const recording = tlv.decode(decoded[SelectedCameraRecordingConfigurationTypes.SELECTED_RECORDING_CONFIGURATION]);
3✔
629
    const video = tlv.decode(decoded[SelectedCameraRecordingConfigurationTypes.SELECTED_VIDEO_CONFIGURATION]);
3✔
630
    const audio = tlv.decode(decoded[SelectedCameraRecordingConfigurationTypes.SELECTED_AUDIO_CONFIGURATION]);
3✔
631

632
    const prebufferLength = recording[SupportedCameraRecordingConfigurationTypes.PREBUFFER_LENGTH].readInt32LE(0);
3✔
633
    let eventTriggerOptions = recording[SupportedCameraRecordingConfigurationTypes.EVENT_TRIGGER_OPTIONS].readInt32LE(0);
3✔
634
    const mediaContainerConfiguration = tlv.decode(recording[SupportedCameraRecordingConfigurationTypes.MEDIA_CONTAINER_CONFIGURATIONS]);
3✔
635
    const containerType = mediaContainerConfiguration[MediaContainerConfigurationTypes.MEDIA_CONTAINER_TYPE][0];
3✔
636
    const mediaContainerParameters = tlv.decode(mediaContainerConfiguration[MediaContainerConfigurationTypes.MEDIA_CONTAINER_PARAMETERS]);
3✔
637
    const fragmentLength = mediaContainerParameters[MediaContainerParameterTypes.FRAGMENT_LENGTH].readInt32LE(0);
3✔
638

639
    const videoCodec = video[VideoCodecConfigurationTypes.CODEC_TYPE][0];
3✔
640
    const videoParameters = tlv.decode(video[VideoCodecConfigurationTypes.CODEC_PARAMETERS]);
3✔
641
    const videoAttributes = tlv.decode(video[VideoCodecConfigurationTypes.ATTRIBUTES]);
3✔
642

643
    const profile = videoParameters[VideoCodecParametersTypes.PROFILE_ID][0];
3✔
644
    const level = videoParameters[VideoCodecParametersTypes.LEVEL][0];
3✔
645
    const videoBitrate = videoParameters[VideoCodecParametersTypes.BITRATE].readInt32LE(0);
3✔
646
    const iFrameInterval = videoParameters[VideoCodecParametersTypes.IFRAME_INTERVAL].readInt32LE(0);
3✔
647

648
    const width = videoAttributes[VideoAttributesTypes.IMAGE_WIDTH].readInt16LE(0);
3✔
649
    const height = videoAttributes[VideoAttributesTypes.IMAGE_HEIGHT].readInt16LE(0);
3✔
650
    const framerate = videoAttributes[VideoAttributesTypes.FRAME_RATE][0];
3✔
651

652
    const audioCodec = audio[AudioCodecConfigurationTypes.CODEC_TYPE][0];
3✔
653
    const audioParameters = tlv.decode(audio[AudioCodecConfigurationTypes.CODEC_PARAMETERS]);
3✔
654

655
    const audioChannels = audioParameters[AudioCodecParametersTypes.CHANNEL][0];
3✔
656
    const samplerate = audioParameters[AudioCodecParametersTypes.SAMPLE_RATE][0];
3✔
657
    const audioBitrateMode = audioParameters[AudioCodecParametersTypes.BIT_RATE][0];
3✔
658
    const audioBitrate = audioParameters[AudioCodecParametersTypes.MAX_AUDIO_BITRATE].readUInt32LE(0);
3✔
659

660
    const typedEventTriggers: EventTriggerOption[] = [];
3✔
661
    let bit_index = 0;
3✔
662
    while (eventTriggerOptions > 0) {
3✔
663
      if (eventTriggerOptions & 0x01) { // of the lowest bit is set add the next event trigger option
3✔
664
        typedEventTriggers.push(1 << bit_index);
3✔
665
      }
666
      eventTriggerOptions = eventTriggerOptions >> 1; // shift to right till we reach zero.
3✔
667
      bit_index += 1; // count our current bit index
3✔
668
    }
669

670
    return {
3✔
671
      prebufferLength: prebufferLength,
672
      eventTriggerTypes: typedEventTriggers,
673
      mediaContainerConfiguration: {
674
        type: containerType,
675
        fragmentLength,
676
      },
677
      videoCodec: {
678
        type: videoCodec,
679
        parameters: {
680
          profile: profile,
681
          level: level,
682
          bitRate: videoBitrate,
683
          iFrameInterval: iFrameInterval,
684
        },
685
        resolution: [width, height, framerate],
686
      },
687
      audioCodec: {
688
        audioChannels,
689
        type: audioCodec,
690
        samplerate,
691
        bitrateMode: audioBitrateMode,
692
        bitrate: audioBitrate,
693
      },
694
    };
695
  }
696

697
  private _supportedCameraRecordingConfiguration(options: CameraRecordingOptions): string {
698
    const mediaContainers = Array.isArray(options.mediaContainerConfiguration)
219✔
699
      ? options.mediaContainerConfiguration
700
      : [options.mediaContainerConfiguration];
701

702
    const prebufferLength = Buffer.alloc(4);
219✔
703
    const eventTriggerOptions = Buffer.alloc(8);
219✔
704

705
    prebufferLength.writeInt32LE(options.prebufferLength, 0);
219✔
706
    eventTriggerOptions.writeInt32LE(this.eventTriggerOptions, 0);
219✔
707

708
    return tlv.encode(
219✔
709
      SupportedCameraRecordingConfigurationTypes.PREBUFFER_LENGTH, prebufferLength,
710
      SupportedCameraRecordingConfigurationTypes.EVENT_TRIGGER_OPTIONS, eventTriggerOptions,
711
      SupportedCameraRecordingConfigurationTypes.MEDIA_CONTAINER_CONFIGURATIONS, mediaContainers.map(config => {
712
        const fragmentLength = Buffer.alloc(4);
219✔
713

714
        fragmentLength.writeInt32LE(config.fragmentLength, 0);
219✔
715

716
        return tlv.encode(
219✔
717
          MediaContainerConfigurationTypes.MEDIA_CONTAINER_TYPE, config.type,
718
          MediaContainerConfigurationTypes.MEDIA_CONTAINER_PARAMETERS, tlv.encode(
719
            MediaContainerParameterTypes.FRAGMENT_LENGTH, fragmentLength,
720
          ),
721
        );
722
      }),
723
    ).toString("base64");
724
  }
725

726
  private _supportedVideoRecordingConfiguration(videoOptions: VideoRecordingOptions): string {
727
    if (!videoOptions.parameters) {
219!
728
      throw new Error("Video parameters cannot be undefined");
×
729
    }
730
    if (!videoOptions.resolutions) {
219!
731
      throw new Error("Video resolutions cannot be undefined");
×
732
    }
733

734
    const codecParameters = tlv.encode(
219✔
735
      VideoCodecParametersTypes.PROFILE_ID, videoOptions.parameters.profiles,
736
      VideoCodecParametersTypes.LEVEL, videoOptions.parameters.levels,
737
    );
738

739
    const videoStreamConfiguration = tlv.encode(
219✔
740
      VideoCodecConfigurationTypes.CODEC_TYPE, videoOptions.type,
741
      VideoCodecConfigurationTypes.CODEC_PARAMETERS, codecParameters,
742
      VideoCodecConfigurationTypes.ATTRIBUTES, videoOptions.resolutions.map(resolution => {
743
        if (resolution.length !== 3) {
2,424!
744
          throw new Error("Unexpected video resolution");
×
745
        }
746

747
        const width = Buffer.alloc(2);
2,424✔
748
        const height = Buffer.alloc(2);
2,424✔
749
        const frameRate = Buffer.alloc(1);
2,424✔
750

751
        width.writeUInt16LE(resolution[0], 0);
2,424✔
752
        height.writeUInt16LE(resolution[1], 0);
2,424✔
753
        frameRate.writeUInt8(resolution[2], 0);
2,424✔
754

755
        return tlv.encode(
2,424✔
756
          VideoAttributesTypes.IMAGE_WIDTH, width,
757
          VideoAttributesTypes.IMAGE_HEIGHT, height,
758
          VideoAttributesTypes.FRAME_RATE, frameRate,
759
        );
760
      }),
761
    );
762

763
    return tlv.encode(
219✔
764
      SupportedVideoRecordingConfigurationTypes.VIDEO_CODEC_CONFIGURATION, videoStreamConfiguration,
765
    ).toString("base64");
766
  }
767

768
  private _supportedAudioStreamConfiguration(audioOptions: AudioRecordingOptions): string {
769
    const audioCodecs = Array.isArray(audioOptions.codecs)
219!
770
      ? audioOptions.codecs
771
      : [audioOptions.codecs];
772

773
    if (audioCodecs.length === 0) {
219!
774
      throw Error("CameraRecordingOptions.audio: At least one audio codec configuration must be specified!");
×
775
    }
776

777
    const codecConfigurations: Buffer[] = audioCodecs.map(codec => {
219✔
778
      const providedSamplerates = Array.isArray(codec.samplerate)
219!
779
        ? codec.samplerate
780
        : [codec.samplerate];
781

782
      if (providedSamplerates.length === 0) {
219!
783
        throw new Error("CameraRecordingOptions.audio.codecs: Audio samplerate cannot be empty!");
×
784
      }
785

786
      const audioParameters = tlv.encode(
219✔
787
        AudioCodecParametersTypes.CHANNEL, Math.max(1, codec.audioChannels || 1),
219!
788
        AudioCodecParametersTypes.BIT_RATE, codec.bitrateMode || AudioBitrate.VARIABLE,
438✔
789
        AudioCodecParametersTypes.SAMPLE_RATE, providedSamplerates,
790
      );
791

792
      return tlv.encode(
219✔
793
        AudioCodecConfigurationTypes.CODEC_TYPE, codec.type,
794
        AudioCodecConfigurationTypes.CODEC_PARAMETERS, audioParameters,
795
      );
796
    });
797

798
    return tlv.encode(
219✔
799
      SupportedAudioRecordingConfigurationTypes.AUDIO_CODEC_CONFIGURATION, codecConfigurations,
800
    ).toString("base64");
801
  }
802

803
  private computeConfigurationHash(algorithm = "sha256"): string {
×
804
    const configurationHash = crypto.createHash(algorithm);
72✔
805
    configurationHash.update(this.supportedCameraRecordingConfiguration);
72✔
806
    configurationHash.update(this.supportedVideoRecordingConfiguration);
72✔
807
    configurationHash.update(this.supportedAudioRecordingConfiguration);
72✔
808
    return configurationHash.digest().toString("hex");
72✔
809
  }
810

811
  /**
812
   * @private
813
   */
814
  serialize(): RecordingManagementState | undefined {
815
    return {
48✔
816
      configurationHash: {
817
        algorithm: "sha256",
818
        hash: this.computeConfigurationHash("sha256"),
819
      },
820
      selectedConfiguration: this.selectedConfiguration?.base64,
821

822
      recordingActive: this.recordingActive,
823
      recordingAudioActive: !!this.recordingManagementService.getCharacteristic(Characteristic.RecordingAudioActive).value,
824

825
      eventSnapshotsActive: !!this.operatingModeService.getCharacteristic(Characteristic.EventSnapshotsActive).value,
826
      homeKitCameraActive: !!this.operatingModeService.getCharacteristic(Characteristic.HomeKitCameraActive).value,
827
      periodicSnapshotsActive: !!this.operatingModeService.getCharacteristic(Characteristic.PeriodicSnapshotsActive).value,
828
    };
829
  }
830

831
  /**
832
   * @private
833
   */
834
  deserialize(serialized: RecordingManagementState): void {
835
    let changedState = false;
24✔
836

837
    // we only restore the `selectedConfiguration` if our supported configuration hasn't changed.
838
    const currentConfigurationHash = this.computeConfigurationHash(serialized.configurationHash.algorithm);
24✔
839
    if (serialized.selectedConfiguration) {
24✔
840
      if (currentConfigurationHash === serialized.configurationHash.hash) {
12!
841
        this.selectedConfiguration = {
×
842
          base64: serialized.selectedConfiguration,
843
          parsed: this.parseSelectedConfiguration(serialized.selectedConfiguration),
844
        };
845
      } else {
846
        changedState = true;
12✔
847
      }
848
    }
849

850
    this.recordingActive = serialized.recordingActive;
24✔
851
    this.recordingManagementService.updateCharacteristic(Characteristic.Active, serialized.recordingActive);
24✔
852
    this.recordingManagementService.updateCharacteristic(Characteristic.RecordingAudioActive, serialized.recordingAudioActive);
24✔
853

854
    this.operatingModeService.updateCharacteristic(Characteristic.EventSnapshotsActive, serialized.eventSnapshotsActive);
24✔
855
    this.operatingModeService.updateCharacteristic(Characteristic.PeriodicSnapshotsActive, serialized.periodicSnapshotsActive);
24✔
856

857
    this.operatingModeService.updateCharacteristic(Characteristic.HomeKitCameraActive, serialized.homeKitCameraActive);
24✔
858
    for (const service of this.sensorServices) {
24✔
859
      service.setCharacteristic(Characteristic.StatusActive, serialized.homeKitCameraActive);
48✔
860
    }
861

862
    try {
24✔
863
      if (this.selectedConfiguration) {
24!
864
        this.delegate.updateRecordingConfiguration(this.selectedConfiguration.parsed);
×
865
      }
866
      if (serialized.recordingActive) {
24✔
867
        this.delegate.updateRecordingActive(serialized.recordingActive);
12✔
868
      }
869
    } catch (error) {
870
      console.error("Failed to properly initialize CameraRecordingDelegate from persistent storage: " + error.stack);
×
871
    }
872

873
    if (changedState) {
24✔
874
      this.stateChangeDelegate?.();
12✔
875
    }
876
  }
877

878
  /**
879
   * @private
880
   */
881
  setupStateChangeDelegate(delegate?: StateChangeDelegate): void {
882
    this.stateChangeDelegate = delegate;
24✔
883
  }
884

885
  destroy(): void {
886
    this.dataStreamManagement.destroy();
6✔
887
  }
888

889
  handleFactoryReset(): void {
890
    this.selectedConfiguration = undefined;
24✔
891
    this.recordingManagementService.updateCharacteristic(Characteristic.Active, false);
24✔
892
    this.recordingManagementService.updateCharacteristic(Characteristic.RecordingAudioActive, false);
24✔
893

894
    this.operatingModeService.updateCharacteristic(Characteristic.EventSnapshotsActive, true);
24✔
895
    this.operatingModeService.updateCharacteristic(Characteristic.PeriodicSnapshotsActive, true);
24✔
896

897
    this.operatingModeService.updateCharacteristic(Characteristic.HomeKitCameraActive, true);
24✔
898
    for (const service of this.sensorServices) {
24✔
899
      service.setCharacteristic(Characteristic.StatusActive, true);
48✔
900
    }
901

902
    try {
24✔
903
      // notifying the delegate about the updated state
904
      this.delegate.updateRecordingActive(false);
24✔
905
      this.delegate.updateRecordingConfiguration(undefined);
24✔
906
    } catch (error) {
907
      console.error("CameraRecordingDelegate failed to update state after handleFactoryReset: " + error.stack);
×
908
    }
909
  }
910
}
911

912

913
/**
914
 * @group Camera
915
 */
916
const enum CameraRecordingStreamEvents {
18✔
917
  /**
918
   * This event is fired when the recording stream is closed.
919
   * Either due to a normal exit (e.g. the HomeKit Controller acknowledging the stream)
920
   * or due to an erroneous exit (e.g. HDS connection getting closed).
921
   */
922
  CLOSED = "closed",
18✔
923
}
924

925
/**
926
 * @group Camera
927
 */
928
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
929
declare interface CameraRecordingStream {
930
  on(event: "closed", listener: () => void): this;
931

932
  emit(event: "closed"): boolean;
933
}
934

935
/**
936
 * A `CameraRecordingStream` represents an ongoing stream request for a HomeKit Secure Video recording.
937
 * A single camera can only support one ongoing recording at a time.
938
 *
939
 * @group Camera
940
 */
941
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
942
class CameraRecordingStream extends EventEmitter implements DataStreamProtocolHandler {
943
  readonly connection: DataStreamConnection;
944
  readonly delegate: CameraRecordingDelegate;
945
  readonly hdsRequestId: number;
946
  readonly streamId: number;
947
  private closed = false;
×
948

949
  eventHandler?: Record<string, EventHandler> = {
×
950
    [Topics.CLOSE]: this.handleDataSendClose.bind(this),
951
    [Topics.ACK]: this.handleDataSendAck.bind(this),
952
  };
953
  requestHandler?: Record<string, RequestHandler> = undefined;
×
954

955
  private readonly closeListener: () => void;
956

957
  private generator?: AsyncGenerator<RecordingPacket>;
958
  /**
959
   * This timeout is used to detect non-returning generators.
960
   * When we signal the delegate that it is being closed its generator must return withing 10s.
961
   */
962
  private generatorTimeout?: NodeJS.Timeout;
963
  /**
964
   * This timer is used to check if the stream is properly closed when we expect it to do so.
965
   * When we expect a close signal from the remote, we wait 12s for it. Otherwise, we abort and close it ourselves.
966
   * This ensures memory is freed, and that we recover fast from erroneous states.
967
   */
968
  private closingTimeout?: NodeJS.Timeout;
969

970
  constructor(connection: DataStreamConnection, delegate: CameraRecordingDelegate, requestId: number, streamId: number) {
971
    super();
×
972
    this.connection = connection;
×
973
    this.delegate = delegate;
×
974
    this.hdsRequestId = requestId;
×
975
    this.streamId = streamId;
×
976

977
    this.connection.on(DataStreamConnectionEvent.CLOSED, this.closeListener = this.handleDataStreamConnectionClosed.bind(this));
×
978
    this.connection.addProtocolHandler(Protocols.DATA_SEND, this);
×
979
  }
980

981
  startStreaming() {
982
    // noinspection JSIgnoredPromiseFromCall
983
    this._startStreaming();
×
984
  }
985

986
  private async _startStreaming() {
987
    debug("[HDS %s] Sending DATA_SEND OPEN response for streamId %d", this.connection.remoteAddress, this.streamId);
×
988
    this.connection.sendResponse(Protocols.DATA_SEND, Topics.OPEN, this.hdsRequestId, HDSStatus.SUCCESS, {
×
989
      status: HDSStatus.SUCCESS,
990
    });
991

992
    // 256 KiB (1KiB to 900 KiB)
993
    const maxChunk = 0x40000;
×
994

995
    // The first buffer which we receive from the generator is always the `mediaInitialization` packet (mp4 `moov` box).
996
    let initialization = true;
×
997
    let dataSequenceNumber = 1;
×
998

999
    // tracks if the last received RecordingPacket was yielded with `isLast=true`.
1000
    let lastFragmentWasMarkedLast = false;
×
1001

1002
    try {
×
1003
      this.generator = this.delegate.handleRecordingStreamRequest(this.streamId);
×
1004

1005
      for await (const packet of this.generator) {
×
1006
        if (this.closed) {
×
1007
          console.error(`[HDS ${this.connection.remoteAddress}] Delegate yielded fragment after stream ${this.streamId} was already closed!`);
×
1008
          break;
×
1009
        }
1010

1011
        if (lastFragmentWasMarkedLast) {
×
1012
          console.error(`[HDS ${this.connection.remoteAddress}] Delegate yielded fragment for stream ${this.streamId} after already signaling end of stream!`);
×
1013
          break;
×
1014
        }
1015

1016
        const fragment = packet.data;
×
1017

1018
        let offset = 0;
×
1019
        let dataChunkSequenceNumber = 1;
×
1020
        while (offset < fragment.length) {
×
NEW
1021
          if (this.closed) {
×
NEW
1022
            break;
×
1023
          }
1024

1025
          const data = fragment.slice(offset, offset + maxChunk);
×
1026
          offset += data.length;
×
1027

1028
          // see https://github.com/Supereg/secure-video-specification#42-binary-data
1029
          const event: DataSendDataEvent = {
×
1030
            streamId: this.streamId,
1031
            packets: [{
1032
              data: data,
1033
              metadata: {
1034
                dataType: initialization ? PacketDataType.MEDIA_INITIALIZATION : PacketDataType.MEDIA_FRAGMENT,
×
1035
                dataSequenceNumber: dataSequenceNumber,
1036
                dataChunkSequenceNumber: dataChunkSequenceNumber,
1037
                isLastDataChunk: offset >= fragment.length,
1038
                dataTotalSize: dataChunkSequenceNumber === 1 ? fragment.length : undefined,
×
1039
              },
1040
            }],
1041
            endOfStream: offset >= fragment.length ? Boolean(packet.isLast).valueOf() : undefined,
×
1042
          };
1043

1044
          debug("[HDS %s] Sending DATA_SEND DATA for stream %d with metadata: %o and length %d; EoS: %s",
×
1045
            this.connection.remoteAddress, this.streamId, event.packets[0].metadata, data.length, event.endOfStream);
1046
          this.connection.sendEvent(Protocols.DATA_SEND, Topics.DATA, event);
×
1047

1048
          dataChunkSequenceNumber++;
×
1049
          initialization = false;
×
1050
        }
1051

1052
        lastFragmentWasMarkedLast = packet.isLast;
×
1053

1054
        if (packet.isLast) {
×
1055
          break;
×
1056
        }
1057

1058
        dataSequenceNumber++;
×
1059
      }
1060

1061
      if (!lastFragmentWasMarkedLast && !this.closed) {
×
1062
        // Delegate violates the contract. Exited normally on a non-closed stream without properly setting `isLast`.
1063
        console.warn(`[HDS ${this.connection.remoteAddress}] Delegate finished streaming for ${this.streamId} without setting RecordingPacket.isLast. ` +
×
1064
        "Can't notify Controller about endOfStream!");
1065
      }
1066
    } catch (error) {
1067
      if (this.closed) {
×
1068
        console.warn(`[HDS ${this.connection.remoteAddress}] Encountered unexpected error on already closed recording stream ${this.streamId}: ${error.stack}`);
×
1069
      } else {
1070
        let closeReason = HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE;
×
1071

1072
        if (error instanceof HDSProtocolError) {
×
1073
          closeReason = error.reason;
×
1074
          debug("[HDS %s] Delegate signaled to close the recording stream %d.", this.connection.remoteAddress, this.streamId);
×
1075
        } else if (error instanceof HDSConnectionError && error.type === HDSConnectionErrorType.CLOSED_SOCKET) {
×
1076
          // we are probably on a shutdown or just late. Connection is dead. End the stream!
1077
          debug("[HDS %s] Exited recording stream due to closed HDS socket: stream id %d.", this.connection.remoteAddress, this.streamId);
×
1078
          return; // execute finally and then exit (we want to skip the `sendEvent` below)
×
1079
        } else {
1080
          console.error(`[HDS ${this.connection.remoteAddress}] Encountered unexpected error for recording stream ${this.streamId}: ${error.stack}`);
×
1081
        }
1082

1083
        // call close to go through standard close routine!
1084
        this.close(closeReason);
×
1085
      }
1086
      return;
×
1087
    } finally {
1088
      this.generator = undefined;
×
1089

1090
      if (this.generatorTimeout) {
×
1091
        clearTimeout(this.generatorTimeout);
×
1092
      }
1093

1094
      if (!this.closed) {
×
1095
        // e.g. when returning with `endOfStream` we rely on the HomeHub to send an ACK event to close the recording.
1096
        // With this timer we ensure that the HomeHub has the chance to close the stream gracefully but at the same time
1097
        // ensure that if something fails the recording stream is freed nonetheless.
1098
        this.kickOffCloseTimeout();
×
1099
      }
1100
    }
1101

1102
    debug("[HDS %s] Finished DATA_SEND transmission for stream %d!", this.connection.remoteAddress, this.streamId);
×
1103
  }
1104

1105
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1106
  private handleDataSendAck(message: Record<any, any>) {
1107
    const streamId: string = message.streamId;
×
1108
    const endOfStream: boolean = message.endOfStream;
×
1109

1110
    // The HomeKit Controller will send a DATA_SEND ACK if we set the `endOfStream` flag in the last packet
1111
    // of our DATA_SEND DATA packet.
1112
    // To my testing the session is then considered complete and the HomeKit controller will close the HDS Connection after 5 seconds.
1113

1114
    debug("[HDS %s] Received DATA_SEND ACK packet for streamId %s. Acknowledged %s.", this.connection.remoteAddress, streamId, endOfStream);
×
1115

1116
    this.handleClosed(() => this.delegate.acknowledgeStream?.(this.streamId));
×
1117
  }
1118

1119
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1120
  private handleDataSendClose(message: Record<any, any>) {
1121
    // see https://github.com/Supereg/secure-video-specification#43-close
1122
    const streamId: number = message.streamId;
×
1123
    const reason: HDSProtocolSpecificErrorReason = message.reason;
×
1124

1125
    if (streamId !== this.streamId) {
×
1126
      return;
×
1127
    }
1128

1129
    debug("[HDS %s] Received DATA_SEND CLOSE for streamId %d with reason %s",
×
1130
      // @ts-expect-error: forceConsistentCasingInFileNames compiler option
1131
      this.connection.remoteAddress, streamId, HDSProtocolSpecificErrorReason[reason]);
1132

1133
    this.handleClosed(() => this.delegate.closeRecordingStream(streamId, reason));
×
1134
  }
1135

1136
  private handleDataStreamConnectionClosed() {
1137
    debug("[HDS %s] The HDS connection of the stream %d closed.", this.connection.remoteAddress, this.streamId);
×
1138

1139
    this.handleClosed(() => this.delegate.closeRecordingStream(this.streamId, undefined));
×
1140
  }
1141

1142
  private handleClosed(closure: () => void): void {
1143
    this.closed = true;
×
1144

1145
    if (this.closingTimeout) {
×
1146
      clearTimeout(this.closingTimeout);
×
1147
      this.closingTimeout = undefined;
×
1148
    }
1149

1150
    this.connection.removeProtocolHandler(Protocols.DATA_SEND, this);
×
1151
    this.connection.removeListener(DataStreamConnectionEvent.CLOSED, this.closeListener);
×
1152

1153
    if (this.generator) {
×
1154
      // when this variable is defined, the generator hasn't returned yet.
1155
      // we start a timeout to uncover potential programming mistakes where we await forever and can't free resources.
1156
      this.generatorTimeout = setTimeout(() => {
×
1157
        console.error("[HDS %s] Recording download stream %d is still awaiting generator although stream was closed 10s ago! " +
×
1158
          "This is a programming mistake by the camera implementation which prevents freeing up resources.", this.connection.remoteAddress, this.streamId);
1159
      }, 10000);
1160
    }
1161

1162
    try {
×
1163
      closure();
×
1164
    } catch (error) {
1165
      console.error(`[HDS ${this.connection.remoteAddress}] CameraRecordingDelegated failed to handle closing the stream ${this.streamId}: ${error.stack}`);
×
1166
    }
1167

1168
    this.emit(CameraRecordingStreamEvents.CLOSED);
×
1169
  }
1170

1171
  /**
1172
   * This method can be used to close a recording session from the outside.
1173
   * @param reason - The reason to close the stream with.
1174
   */
1175
  close(reason: HDSProtocolSpecificErrorReason): void {
1176
    if (this.closed) {
×
1177
      return;
×
1178
    }
1179

1180
    debug("[HDS %s] Recording stream %d was closed manually with reason %s.",
×
1181
      // @ts-expect-error: forceConsistentCasingInFileNames compiler option
1182
      this.connection.remoteAddress, this.streamId, reason ? HDSProtocolSpecificErrorReason[reason] : "CLOSED");
×
1183

1184
    // the `isConsideredClosed` check just ensures that the won't ever throw here and that `handledClosed` is always executed.
1185
    if (!this.connection.isConsideredClosed()) {
×
1186
      this.connection.sendEvent(Protocols.DATA_SEND, Topics.CLOSE, {
×
1187
        streamId: this.streamId,
1188
        reason: reason,
1189
      });
1190
    }
1191

1192
    this.handleClosed(() => this.delegate.closeRecordingStream(this.streamId, reason));
×
1193
  }
1194

1195
  private kickOffCloseTimeout(): void {
1196
    if (this.closingTimeout) {
×
1197
      clearTimeout(this.closingTimeout);
×
1198
    }
1199

1200
    this.closingTimeout = setTimeout(() => {
×
1201
      if (this.closed) {
×
1202
        return;
×
1203
      }
1204

1205
      debug("[HDS %s] Recording stream %d took longer than expected to fully close. Force closing now!", this.connection.remoteAddress, this.streamId);
×
1206
      this.close(HDSProtocolSpecificErrorReason.CANCELLED);
×
1207
    }, 12000);
1208
  }
1209
}
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