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

homebridge / HAP-NodeJS / 17775229178

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

push

github

bwp91
v2.0.2

1743 of 3286 branches covered (53.04%)

Branch coverage included in aggregate %.

6250 of 9314 relevant lines covered (67.1%)

312.83 hits per line

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

17.28
/src/lib/controller/RemoteController.ts
1
import assert from "assert";
18✔
2
import createDebug from "debug";
18✔
3
import { EventEmitter } from "events";
18✔
4
import { CharacteristicValue } from "../../types";
5
import { AudioBitrate, AudioSamplerate } from "../camera";
6
import {
18✔
7
  Characteristic,
8
  CharacteristicEventTypes,
9
  CharacteristicGetCallback,
10
  CharacteristicSetCallback,
11
} from "../Characteristic";
12
import {
18✔
13
  HDSProtocolSpecificErrorReason,
14
  DataStreamConnection,
15
  DataStreamConnectionEvent,
16
  DataStreamManagement,
17
  DataStreamProtocolHandler,
18
  DataStreamServerEvent,
19
  EventHandler,
20
  Float32,
21
  HDSStatus,
22
  Int64,
23
  Protocols,
24
  RequestHandler,
25
  Topics,
26
} from "../datastream";
27
import type {
28
  AudioStreamManagement,
29
  DataStreamTransportManagement,
30
  Siri,
31
  TargetControl,
32
  TargetControlManagement,
33
} from "../definitions";
34
import { HAPStatus } from "../HAPServer";
35
import { Service } from "../Service";
18✔
36
import { HAPConnection, HAPConnectionEvent } from "../util/eventedhttp";
37
import * as tlv from "../util/tlv";
18✔
38
import {
39
  ControllerIdentifier,
40
  ControllerServiceMap,
41
  DefaultControllerType,
42
  SerializableController,
43
  StateChangeDelegate,
44
} from "./Controller";
45

46
const debug = createDebug("HAP-NodeJS:Remote:Controller");
18✔
47

48
const enum TargetControlCommands {
18✔
49
  MAXIMUM_TARGETS = 0x01,
18✔
50
  TICKS_PER_SECOND = 0x02,
18✔
51
  SUPPORTED_BUTTON_CONFIGURATION = 0x03,
18✔
52
  TYPE = 0x04
18✔
53
}
54

55
const enum SupportedButtonConfigurationTypes {
18✔
56
  BUTTON_ID = 0x01,
18✔
57
  BUTTON_TYPE = 0x02
18✔
58
}
59

60
/**
61
 * @group Apple TV Remote
62
 */
63
export const enum ButtonType {
18✔
64
  // noinspection JSUnusedGlobalSymbols
65
  UNDEFINED = 0x00,
18✔
66
  MENU = 0x01,
18✔
67
  PLAY_PAUSE = 0x02,
18✔
68
  TV_HOME = 0x03,
18✔
69
  SELECT = 0x04,
18✔
70
  ARROW_UP = 0x05,
18✔
71
  ARROW_RIGHT = 0x06,
18✔
72
  ARROW_DOWN = 0x07,
18✔
73
  ARROW_LEFT = 0x08,
18✔
74
  VOLUME_UP = 0x09,
18✔
75
  VOLUME_DOWN = 0x0A,
18✔
76
  SIRI = 0x0B,
18✔
77
  POWER = 0x0C,
18✔
78
  GENERIC = 0x0D
18✔
79
}
80

81

82
const enum TargetControlList {
18✔
83
  OPERATION = 0x01,
18✔
84
  TARGET_CONFIGURATION = 0x02
18✔
85
}
86

87
enum Operation {
18✔
88
  // noinspection JSUnusedGlobalSymbols
89
  UNDEFINED = 0x00,
18✔
90
  LIST = 0x01,
18✔
91
  ADD = 0x02,
18✔
92
  REMOVE = 0x03,
18✔
93
  RESET = 0x04,
18✔
94
  UPDATE = 0x05
18✔
95
}
96

97
const enum TargetConfigurationTypes {
18✔
98
  TARGET_IDENTIFIER = 0x01,
18✔
99
  TARGET_NAME = 0x02,
18✔
100
  TARGET_CATEGORY = 0x03,
18✔
101
  BUTTON_CONFIGURATION = 0x04
18✔
102
}
103

104
/**
105
 * @group Apple TV Remote
106
 */
107
export const enum TargetCategory {
18✔
108
  // noinspection JSUnusedGlobalSymbols
109
  UNDEFINED = 0x00,
18✔
110
  APPLE_TV = 0x18
18✔
111
}
112

113
const enum ButtonConfigurationTypes {
18✔
114
  BUTTON_ID = 0x01,
18✔
115
  BUTTON_TYPE = 0x02,
18✔
116
  BUTTON_NAME = 0x03,
18✔
117
}
118

119
const enum ButtonEvent {
18✔
120
  BUTTON_ID = 0x01,
18✔
121
  BUTTON_STATE = 0x02,
18✔
122
  TIMESTAMP = 0x03,
18✔
123
  ACTIVE_IDENTIFIER = 0x04,
18✔
124
}
125

126
/**
127
 * @group Apple TV Remote
128
 */
129
export const enum ButtonState {
18✔
130
  UP = 0x00,
18✔
131
  DOWN = 0x01
18✔
132
}
133

134

135
/**
136
 * @group Apple TV Remote
137
 */
138
export type SupportedConfiguration = {
139
  maximumTargets: number,
140
  ticksPerSecond: number,
141
  supportedButtonConfiguration: SupportedButtonConfiguration[],
142
  hardwareImplemented: boolean
143
}
144

145
/**
146
 * @group Apple TV Remote
147
 */
148
export type SupportedButtonConfiguration = {
149
  buttonID: number,
150
  buttonType: ButtonType
151
}
152

153
/**
154
 * @group Apple TV Remote
155
 */
156
export type TargetConfiguration = {
157
  targetIdentifier: number,
158
  targetName?: string, // on Operation.UPDATE targetName is left out
159
  targetCategory?: TargetCategory, // on Operation.UPDATE targetCategory is left out
160
  buttonConfiguration: Record<number, ButtonConfiguration> // button configurations indexed by their ID
161
}
162

163
/**
164
 * @group Apple TV Remote
165
 */
166
export type ButtonConfiguration = {
167
  buttonID: number,
168
  buttonType: ButtonType,
169
  buttonName?: string
170
}
171

172

173
const enum SelectedAudioInputStreamConfigurationTypes {
18✔
174
  SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION = 0x01,
18✔
175
}
176

177
// ----------
178

179
const enum SupportedAudioStreamConfigurationTypes {
18✔
180
  // noinspection JSUnusedGlobalSymbols
181
  AUDIO_CODEC_CONFIGURATION = 0x01,
18✔
182
  COMFORT_NOISE_SUPPORT = 0x02,
18✔
183
}
184

185
const enum AudioCodecConfigurationTypes {
18✔
186
  CODEC_TYPE = 0x01,
18✔
187
  CODEC_PARAMETERS = 0x02,
18✔
188
}
189

190
/**
191
 * @group Camera
192
 */
193
export const enum AudioCodecTypes { // only really by HAP supported codecs are AAC-ELD and OPUS
18✔
194
  // noinspection JSUnusedGlobalSymbols
195
  PCMU = 0x00,
18✔
196
  PCMA = 0x01,
18✔
197
  AAC_ELD = 0x02,
18✔
198
  OPUS = 0x03,
18✔
199
  MSBC = 0x04, // mSBC is a bluetooth codec (lol)
18✔
200
  AMR = 0x05,
18✔
201
  AMR_WB = 0x06,
18✔
202
}
203

204
const enum AudioCodecParametersTypes {
18✔
205
  CHANNEL = 0x01,
18✔
206
  BIT_RATE = 0x02,
18✔
207
  SAMPLE_RATE = 0x03,
18✔
208
  PACKET_TIME = 0x04 // only present in selected audio codec parameters tlv
18✔
209
}
210

211
// ----------
212

213
type SupportedAudioStreamConfiguration = {
214
  audioCodecConfiguration: AudioCodecConfiguration,
215
}
216

217
type SelectedAudioStreamConfiguration = {
218
  audioCodecConfiguration: AudioCodecConfiguration,
219
}
220

221
/**
222
 * @group Apple TV Remote
223
 */
224
export type AudioCodecConfiguration = {
225
  codecType: AudioCodecTypes,
226
  parameters: AudioCodecParameters,
227
}
228

229
/**
230
 * @group Apple TV Remote
231
 */
232
export type AudioCodecParameters = {
233
  channels: number, // number of audio channels, default is 1
234
  bitrate: AudioBitrate,
235
  samplerate: AudioSamplerate,
236
  rtpTime?: RTPTime, // only present in SelectedAudioCodecParameters TLV
237
}
238

239
/**
240
 * @group Apple TV Remote
241
 */
242
export type RTPTime = 20 | 30 | 40 | 60;
243

244

245
const enum SiriAudioSessionState {
18✔
246
  STARTING = 0, // we are currently waiting for a response for the start request
18✔
247
  SENDING = 1, // we are sending data
18✔
248
  CLOSING = 2, // we are currently waiting for the acknowledgment event
18✔
249
  CLOSED = 3, // the close event was sent
18✔
250
}
251

252
type DataSendMessageData = {
253
  packets: AudioFramePacket[],
254
  streamId: Int64,
255
  endOfStream: boolean,
256
}
257

258
/**
259
 * @group Apple TV Remote
260
 */
261
export type AudioFrame = {
262
  data: Buffer,
263
  rms: number, // root-mean-square
264
}
265

266
type AudioFramePacket = {
267
  data: Buffer,
268
  metadata: {
269
    rms: Float32, // root-mean-square
270
    sequenceNumber: Int64,
271
  },
272
}
273

274

275
/**
276
 * @group Apple TV Remote
277
 */
278
export type FrameHandler = (frame: AudioFrame) => void;
279
/**
280
 * @group Apple TV Remote
281
 */
282
export type ErrorHandler = (error: HDSProtocolSpecificErrorReason) => void;
283

284
/**
285
 * @group Apple TV Remote
286
 */
287
export interface SiriAudioStreamProducer {
288

289
  startAudioProduction(selectedAudioConfiguration: AudioCodecConfiguration): void;
290

291
  stopAudioProduction(): void;
292

293
}
294

295
/**
296
 * @group Apple TV Remote
297
 */
298
export interface SiriAudioStreamProducerConstructor {
299

300
  /**
301
   * Creates a new instance of a SiriAudioStreamProducer
302
   *
303
   * @param frameHandler - called for every opus frame recorded
304
   * @param errorHandler - should be called with an appropriate reason when the producing process errored
305
   * @param options - optional parameter for passing any configuration related options
306
   */
307
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
308
  new(frameHandler: FrameHandler, errorHandler: ErrorHandler, options?: any): SiriAudioStreamProducer;
309

310
}
311

312
/**
313
 * @group Apple TV Remote
314
 */
315
export const enum TargetUpdates {
18✔
316
  NAME,
18✔
317
  CATEGORY,
18✔
318
  UPDATED_BUTTONS,
18✔
319
  REMOVED_BUTTONS,
18✔
320
}
321

322
/**
323
 * @group Apple TV Remote
324
 */
325
export const enum RemoteControllerEvents {
18✔
326
  /**
327
   * This event is emitted when the active state of the remote has changed.
328
   * active = true indicates that there is currently an Apple TV listening of button presses and audio streams.
329
   */
330
  ACTIVE_CHANGE = "active-change",
18✔
331
  /**
332
   * This event is emitted when the currently selected target has changed.
333
   * Possible reasons for a changed active identifier: manual change via api call, first target configuration
334
   * gets added, active target gets removed, accessory gets unpaired, reset request was sent.
335
   * An activeIdentifier of 0 indicates that no target is selected.
336
   */
337
  ACTIVE_IDENTIFIER_CHANGE = "active-identifier-change",
18✔
338

339
  /**
340
   * This event is emitted when a new target configuration is received. As we currently do not persistently store
341
   * configured targets, this will be called at every startup for every Apple TV configured in the home.
342
   */
343
  TARGET_ADDED = "target-add",
18✔
344
  /**
345
   * This event is emitted when an existing target was updated.
346
   * The 'updates' array indicates what exactly was changed for the target.
347
   */
348
  TARGET_UPDATED = "target-update",
18✔
349
  /**
350
   * This event is emitted when an existing configuration for a target was removed.
351
   */
352
  TARGET_REMOVED = "target-remove",
18✔
353
  /**
354
   * This event is emitted when a reset of the target configuration is requested.
355
   * With this event every configuration made should be reset. This event is also called
356
   * when the accessory gets unpaired.
357
   */
358
  TARGETS_RESET = "targets-reset",
18✔
359
}
360

361
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
362
export declare interface RemoteController {
363
  on(event: "active-change", listener: (active: boolean) => void): this;
364
  on(event: "active-identifier-change", listener: (activeIdentifier: number) => void): this;
365

366
  on(event: "target-add", listener: (targetConfiguration: TargetConfiguration) => void): this;
367
  on(event: "target-update", listener: (targetConfiguration: TargetConfiguration, updates: TargetUpdates[]) => void): this;
368
  on(event: "target-remove", listener: (targetIdentifier: number) => void): this;
369
  on(event: "targets-reset", listener: () => void): this;
370

371
  emit(event: "active-change", active: boolean): boolean;
372
  emit(event: "active-identifier-change", activeIdentifier: number): boolean;
373

374
  emit(event: "target-add", targetConfiguration: TargetConfiguration): boolean;
375
  emit(event: "target-update", targetConfiguration: TargetConfiguration, updates: TargetUpdates[]): boolean;
376
  emit(event: "target-remove", targetIdentifier: number): boolean;
377
  emit(event: "targets-reset"): boolean;
378
}
379

380
/**
381
 * @group Apple TV Remote
382
 */
383
export interface RemoteControllerServiceMap extends ControllerServiceMap {
384
  targetControlManagement: TargetControlManagement,
385
  targetControl: TargetControl,
386

387
  siri?: Siri,
388
  audioStreamManagement?: AudioStreamManagement,
389
  dataStreamTransportManagement?: DataStreamTransportManagement
390
}
391

392
/**
393
 * @group Apple TV Remote
394
 */
395
export interface SerializedControllerState {
396
  activeIdentifier: number,
397
  targetConfigurations: Record<number, TargetConfiguration>;
398
}
399

400
/**
401
 * Handles everything needed to implement a fully working HomeKit remote controller.
402
 *
403
 * @group Apple TV Remote
404
 */
405
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
406
export class RemoteController extends EventEmitter
18✔
407
  implements SerializableController<RemoteControllerServiceMap, SerializedControllerState>, DataStreamProtocolHandler {
408
  private stateChangeDelegate?: StateChangeDelegate;
409

410
  private readonly audioSupported: boolean;
411
  private readonly audioProducerConstructor?: SiriAudioStreamProducerConstructor;
412
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
413
  private readonly audioProducerOptions?: any;
414

415
  private targetControlManagementService?: TargetControlManagement;
416
  private targetControlService?: TargetControl;
417

418
  private siriService?: Siri;
419
  private audioStreamManagementService?: AudioStreamManagement;
420
  private dataStreamManagement?: DataStreamManagement;
421

422
  private buttons: Record<number, number> = {}; // internal mapping of buttonId to buttonType for supported buttons
×
423
  private readonly supportedConfiguration: string;
424
  targetConfigurations: Map<number, TargetConfiguration> = new Map();
×
425
  private targetConfigurationsString = "";
×
426

427
  private lastButtonEvent = "";
×
428

429
  activeIdentifier = 0; // id of 0 means no device selected
×
430
  private activeConnection?: HAPConnection; // session which marked this remote as active and listens for events and siri
431
  private activeConnectionDisconnectListener?: () => void;
432

433
  private readonly supportedAudioConfiguration: string;
434
  private selectedAudioConfiguration: AudioCodecConfiguration;
435
  private selectedAudioConfigurationString: string;
436

437
  private dataStreamConnections: Map<number, DataStreamConnection> = new Map(); // maps targetIdentifiers to active data stream connections
×
438
  private activeAudioSession?: SiriAudioSession;
439
  private nextAudioSession?: SiriAudioSession;
440

441
  /**
442
   * @private
443
   */
444
  eventHandler?: Record<string, EventHandler>;
445
  /**
446
   * @private
447
   */
448
  requestHandler?: Record<string, RequestHandler>;
449

450
  /**
451
   * Creates a new RemoteController.
452
   * If siri voice input is supported the constructor to an SiriAudioStreamProducer needs to be supplied.
453
   * Otherwise, a remote without voice support will be created.
454
   *
455
   * For every audio session a new SiriAudioStreamProducer will be constructed.
456
   *
457
   * @param audioProducerConstructor - constructor for a SiriAudioStreamProducer
458
   * @param producerOptions - if supplied this argument will be supplied as third argument of the SiriAudioStreamProducer
459
   *                          constructor. This should be used to supply configurations to the stream producer.
460
   */
461
  // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types
462
  public constructor(audioProducerConstructor?: SiriAudioStreamProducerConstructor, producerOptions?: any) {
463
    super();
×
464
    this.audioSupported = audioProducerConstructor !== undefined;
×
465
    this.audioProducerConstructor = audioProducerConstructor;
×
466
    this.audioProducerOptions = producerOptions;
×
467

468
    const configuration: SupportedConfiguration = this.constructSupportedConfiguration();
×
469
    this.supportedConfiguration = this.buildTargetControlSupportedConfigurationTLV(configuration);
×
470

471
    const audioConfiguration: SupportedAudioStreamConfiguration = this.constructSupportedAudioConfiguration();
×
472
    this.supportedAudioConfiguration = RemoteController.buildSupportedAudioConfigurationTLV(audioConfiguration);
×
473

474
    this.selectedAudioConfiguration = { // set the required defaults
×
475
      codecType: AudioCodecTypes.OPUS,
476
      parameters: {
477
        channels: 1,
478
        bitrate: AudioBitrate.VARIABLE,
479
        samplerate: AudioSamplerate.KHZ_16,
480
        rtpTime: 20,
481
      },
482
    };
483
    this.selectedAudioConfigurationString = RemoteController.buildSelectedAudioConfigurationTLV({
×
484
      audioCodecConfiguration: this.selectedAudioConfiguration,
485
    });
486
  }
487

488
  /**
489
   * @private
490
   */
491
  controllerId(): ControllerIdentifier {
492
    return DefaultControllerType.REMOTE;
×
493
  }
494

495
  /**
496
   * Set a new target as active target. A value of 0 indicates that no target is selected currently.
497
   *
498
   * @param activeIdentifier - target identifier
499
   */
500
  public setActiveIdentifier(activeIdentifier: number): void {
501
    if (activeIdentifier === this.activeIdentifier) {
×
502
      return;
×
503
    }
504

505
    if (activeIdentifier !== 0 && !this.targetConfigurations.has(activeIdentifier)) {
×
506
      throw Error("Tried setting unconfigured targetIdentifier to active");
×
507
    }
508

509
    debug("%d is now the active target", activeIdentifier);
×
510
    this.activeIdentifier = activeIdentifier;
×
511
    this.targetControlService!.getCharacteristic(Characteristic.ActiveIdentifier)!.updateValue(activeIdentifier);
×
512

513
    if (this.activeAudioSession) {
×
514
      this.handleSiriAudioStop();
×
515
    }
516

517
    setTimeout(() => this.emit(RemoteControllerEvents.ACTIVE_IDENTIFIER_CHANGE, activeIdentifier), 0);
×
518
    this.setInactive();
×
519
  }
520

521
  /**
522
   * @returns if the current target is active, meaning the active device is listening for button events or audio sessions
523
   */
524
  public isActive(): boolean {
525
    return !!this.activeConnection;
×
526
  }
527

528
  /**
529
   * Checks if the supplied targetIdentifier is configured.
530
   *
531
   * @param targetIdentifier - The target identifier.
532
   */
533
  public isConfigured(targetIdentifier: number): boolean {
534
    return this.targetConfigurations.has(targetIdentifier);
×
535
  }
536

537
  /**
538
   * Returns the targetIdentifier for a give device name
539
   *
540
   * @param name - The name of the device.
541
   * @returns The targetIdentifier of the device or undefined if not existent.
542
   */
543
  public getTargetIdentifierByName(name: string): number | undefined {
544
    for (const [ activeIdentifier, configuration ] of Object.entries(this.targetConfigurations)) {
×
545
      if (configuration.targetName === name) {
×
546
        return parseInt(activeIdentifier, 10);
×
547
      }
548
    }
549

550
    return undefined;
×
551
  }
552

553
  /**
554
   * Sends a button event to press the supplied button.
555
   *
556
   * @param button - button to be pressed
557
   */
558
  public pushButton(button: ButtonType): void {
559
    this.sendButtonEvent(button, ButtonState.DOWN);
×
560
  }
561

562
  /**
563
   * Sends a button event that the supplied button was released.
564
   *
565
   * @param button - button which was released
566
   */
567
  public releaseButton(button: ButtonType): void {
568
    this.sendButtonEvent(button, ButtonState.UP);
×
569
  }
570

571
  /**
572
   * Presses a supplied button for a given time.
573
   *
574
   * @param button - button to be pressed and released
575
   * @param time - time in milliseconds (defaults to 200ms)
576
   */
577
  public pushAndReleaseButton(button: ButtonType, time = 200): void {
×
578
    this.pushButton(button);
×
579
    setTimeout(() => this.releaseButton(button), time);
×
580
  }
581

582
  // ---------------------------------- CONFIGURATION ----------------------------------
583
  // override methods if you would like to change anything (but should not be necessary most likely)
584

585
  protected constructSupportedConfiguration(): SupportedConfiguration {
586
    const configuration: SupportedConfiguration = {
×
587
      maximumTargets: 10, // some random number. (ten should be okay?)
588
      ticksPerSecond: 1000, // we rely on unix timestamps
589
      supportedButtonConfiguration: [],
590
      hardwareImplemented: this.audioSupported, // siri is only allowed for hardware implemented remotes
591
    };
592

593
    const supportedButtons = [
×
594
      ButtonType.MENU, ButtonType.PLAY_PAUSE, ButtonType.TV_HOME, ButtonType.SELECT,
595
      ButtonType.ARROW_UP, ButtonType.ARROW_RIGHT, ButtonType.ARROW_DOWN, ButtonType.ARROW_LEFT,
596
      ButtonType.VOLUME_UP, ButtonType.VOLUME_DOWN, ButtonType.POWER, ButtonType.GENERIC,
597
    ];
598
    if (this.audioSupported) { // add siri button if this remote supports it
×
599
      supportedButtons.push(ButtonType.SIRI);
×
600
    }
601

602
    supportedButtons.forEach(button => {
×
603
      const buttonConfiguration: SupportedButtonConfiguration = {
×
604
        buttonID: 100 + button,
605
        buttonType: button,
606
      };
607
      configuration.supportedButtonConfiguration.push(buttonConfiguration);
×
608
      this.buttons[button] = buttonConfiguration.buttonID; // also saving mapping of type to id locally
×
609
    });
610

611
    return configuration;
×
612
  }
613

614
  protected constructSupportedAudioConfiguration(): SupportedAudioStreamConfiguration {
615
    // the following parameters are expected from HomeKit for a remote
616
    return {
×
617
      audioCodecConfiguration: {
618
        codecType: AudioCodecTypes.OPUS,
619
        parameters: {
620
          channels: 1,
621
          bitrate: AudioBitrate.VARIABLE,
622
          samplerate: AudioSamplerate.KHZ_16,
623
        },
624
      },
625
    };
626
  }
627

628
  // --------------------------------- TARGET CONTROL ----------------------------------
629

630
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
631
  private handleTargetControlWrite(value: any, callback: CharacteristicSetCallback): void {
632
    const data = Buffer.from(value, "base64");
×
633
    const objects = tlv.decode(data);
×
634

635
    const operation = objects[TargetControlList.OPERATION][0] as Operation;
×
636

637
    let targetConfiguration: TargetConfiguration | undefined = undefined;
×
638
    if (objects[TargetControlList.TARGET_CONFIGURATION]) { // if target configuration was sent, parse it
×
639
      targetConfiguration = this.parseTargetConfigurationTLV(objects[TargetControlList.TARGET_CONFIGURATION]);
×
640
    }
641

642
    debug("Received TargetControl write operation %s", Operation[operation]);
×
643

644
    let handler: (targetConfiguration?: TargetConfiguration) => HAPStatus;
645
    switch (operation) {
×
646
    case Operation.ADD:
647
      handler = this.handleAddTarget.bind(this);
×
648
      break;
×
649
    case Operation.UPDATE:
650
      handler = this.handleUpdateTarget.bind(this);
×
651
      break;
×
652
    case Operation.REMOVE:
653
      handler = this.handleRemoveTarget.bind(this);
×
654
      break;
×
655
    case Operation.RESET:
656
      handler = this.handleResetTargets.bind(this);
×
657
      break;
×
658
    case Operation.LIST:
659
      handler = this.handleListTargets.bind(this);
×
660
      break;
×
661
    default:
662
      callback(HAPStatus.INVALID_VALUE_IN_REQUEST, undefined);
×
663
      return;
×
664
    }
665

666
    const status = handler(targetConfiguration);
×
667
    if (status === HAPStatus.SUCCESS) {
×
668
      callback(undefined, this.targetConfigurationsString); // passing value for write response
×
669

670
      if (operation === Operation.ADD && this.activeIdentifier === 0) {
×
671
        this.setActiveIdentifier(targetConfiguration!.targetIdentifier);
×
672
      }
673
    } else {
674
      callback(new Error(status + ""));
×
675
    }
676
  }
677

678
  private handleAddTarget(targetConfiguration?: TargetConfiguration): HAPStatus {
679
    if (!targetConfiguration) {
×
680
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
681
    }
682

683
    this.targetConfigurations.set(targetConfiguration.targetIdentifier, targetConfiguration);
×
684

685
    debug("Configured new target '" + targetConfiguration.targetName + "' with targetIdentifier '" + targetConfiguration.targetIdentifier + "'");
×
686

687
    setTimeout(() => this.emit(RemoteControllerEvents.TARGET_ADDED, targetConfiguration), 0);
×
688

689
    this.updatedTargetConfiguration(); // set response
×
690
    return HAPStatus.SUCCESS;
×
691
  }
692

693
  private handleUpdateTarget(targetConfiguration?: TargetConfiguration): HAPStatus {
694
    if (!targetConfiguration) {
×
695
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
696
    }
697

698
    const updates: TargetUpdates[] = [];
×
699

700
    const configuredTarget = this.targetConfigurations.get(targetConfiguration.targetIdentifier);
×
701
    if (!configuredTarget) {
×
702
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
703
    }
704

705
    if (targetConfiguration.targetName) {
×
706
      debug("Target name was updated '%s' => '%s' (%d)",
×
707
        configuredTarget.targetName, targetConfiguration.targetName, configuredTarget.targetIdentifier);
708

709
      configuredTarget.targetName = targetConfiguration.targetName;
×
710
      updates.push(TargetUpdates.NAME);
×
711
    }
712
    if (targetConfiguration.targetCategory) {
×
713
      debug("Target category was updated '%d' => '%d' for target '%s' (%d)",
×
714
        configuredTarget.targetCategory, targetConfiguration.targetCategory,
715
        configuredTarget.targetName, configuredTarget.targetIdentifier);
716

717
      configuredTarget.targetCategory = targetConfiguration.targetCategory;
×
718
      updates.push(TargetUpdates.CATEGORY);
×
719
    }
720
    if (targetConfiguration.buttonConfiguration) {
×
721
      debug("%d button configurations were updated for target '%s' (%d)",
×
722
        Object.keys(targetConfiguration.buttonConfiguration).length,
723
        configuredTarget.targetName, configuredTarget.targetIdentifier);
724

725
      for (const configuration of Object.values(targetConfiguration.buttonConfiguration)) {
×
726
        const savedConfiguration = configuredTarget.buttonConfiguration[configuration.buttonID];
×
727

728
        savedConfiguration.buttonType = configuration.buttonType;
×
729
        savedConfiguration.buttonName = configuration.buttonName;
×
730
      }
731
      updates.push(TargetUpdates.UPDATED_BUTTONS);
×
732
    }
733

734
    setTimeout(() => this.emit(RemoteControllerEvents.TARGET_UPDATED, targetConfiguration, updates), 0);
×
735

736
    this.updatedTargetConfiguration(); // set response
×
737
    return HAPStatus.SUCCESS;
×
738
  }
739

740
  private handleRemoveTarget(targetConfiguration?: TargetConfiguration): HAPStatus {
741
    if (!targetConfiguration) {
×
742
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
743
    }
744

745
    const configuredTarget = this.targetConfigurations.get(targetConfiguration.targetIdentifier);
×
746
    if (!configuredTarget) {
×
747
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
748
    }
749

750
    if (targetConfiguration.buttonConfiguration) {
×
751
      for (const key in targetConfiguration.buttonConfiguration) {
×
752
        if (Object.prototype.hasOwnProperty.call(targetConfiguration.buttonConfiguration, key)) {
×
753
          delete configuredTarget.buttonConfiguration[key];
×
754
        }
755
      }
756

757
      debug("Removed %d button configurations of target '%s' (%d)",
×
758
        Object.keys(targetConfiguration.buttonConfiguration).length, configuredTarget.targetName, configuredTarget.targetIdentifier);
759
      setTimeout(() => this.emit(RemoteControllerEvents.TARGET_UPDATED, configuredTarget, [TargetUpdates.REMOVED_BUTTONS]), 0);
×
760
    } else {
761
      this.targetConfigurations.delete(targetConfiguration.targetIdentifier);
×
762

763
      debug ("Target '%s' (%d) was removed", configuredTarget.targetName, configuredTarget.targetIdentifier);
×
764
      setTimeout(() => this.emit(RemoteControllerEvents.TARGET_REMOVED, targetConfiguration.targetIdentifier), 0);
×
765

766
      const keys = Object.keys(this.targetConfigurations);
×
767
      this.setActiveIdentifier(keys.length === 0? 0: parseInt(keys[0], 10)); // switch to next available remote
×
768
    }
769

770
    this.updatedTargetConfiguration(); // set response
×
771
    return HAPStatus.SUCCESS;
×
772
  }
773

774
  private handleResetTargets(targetConfiguration?: TargetConfiguration): HAPStatus {
775
    if (targetConfiguration) {
×
776
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
777
    }
778

779
    debug("Resetting all target configurations");
×
780
    this.targetConfigurations = new Map();
×
781
    this.updatedTargetConfiguration(); // set response
×
782

783
    setTimeout(() => this.emit(RemoteControllerEvents.TARGETS_RESET), 0);
×
784
    this.setActiveIdentifier(0); // resetting active identifier (also sets active to false)
×
785

786
    return HAPStatus.SUCCESS;
×
787
  }
788

789
  private handleListTargets(targetConfiguration?: TargetConfiguration): HAPStatus {
790
    if (targetConfiguration) {
×
791
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
792
    }
793

794
    // this.targetConfigurationsString is updated after each change, so we basically don't need to do anything here
795
    debug("Returning " + Object.keys(this.targetConfigurations).length + " target configurations");
×
796
    return HAPStatus.SUCCESS;
×
797
  }
798

799
  private handleActiveWrite(value: CharacteristicValue, callback: CharacteristicSetCallback, connection: HAPConnection): void {
800
    if (this.activeIdentifier === 0) {
×
801
      debug("Tried to change active state. There is no active target set though");
×
802
      callback(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
803
      return;
×
804
    }
805

806
    if (this.activeConnection) {
×
807
      this.activeConnection.removeListener(HAPConnectionEvent.CLOSED, this.activeConnectionDisconnectListener!);
×
808
      this.activeConnection = undefined;
×
809
      this.activeConnectionDisconnectListener = undefined;
×
810
    }
811

812
    this.activeConnection = value? connection: undefined;
×
813
    if (this.activeConnection) { // register listener when hap connection disconnects
×
814
      this.activeConnectionDisconnectListener = this.handleActiveSessionDisconnected.bind(this, this.activeConnection);
×
815
      this.activeConnection.on(HAPConnectionEvent.CLOSED, this.activeConnectionDisconnectListener);
×
816
    }
817

818
    const activeTarget = this.targetConfigurations.get(this.activeIdentifier);
×
819
    if (!activeTarget) {
×
820
      callback(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
821
      return;
×
822
    }
823

824
    debug("Remote with activeTarget '%s' (%d) was set to %s", activeTarget.targetName, this.activeIdentifier, value ? "ACTIVE" : "INACTIVE");
×
825

826
    callback();
×
827

828
    this.emit(RemoteControllerEvents.ACTIVE_CHANGE, value as boolean);
×
829
  }
830

831
  private setInactive(): void {
832
    if (this.activeConnection === undefined) {
×
833
      return;
×
834
    }
835

836
    this.activeConnection.removeListener(HAPConnectionEvent.CLOSED, this.activeConnectionDisconnectListener!);
×
837
    this.activeConnection = undefined;
×
838
    this.activeConnectionDisconnectListener = undefined;
×
839

840
    this.targetControlService!.getCharacteristic(Characteristic.Active)!.updateValue(false);
×
841
    debug("Remote was set to INACTIVE");
×
842

843
    setTimeout(() => this.emit(RemoteControllerEvents.ACTIVE_CHANGE, false), 0);
×
844
  }
845

846
  private handleActiveSessionDisconnected(connection: HAPConnection): void {
847
    if (connection !== this.activeConnection) {
×
848
      return;
×
849
    }
850

851
    debug("Active hap session disconnected!");
×
852
    this.setInactive();
×
853
  }
854

855
  private sendButtonEvent(button: ButtonType, buttonState: ButtonState) {
856
    const buttonID = this.buttons[button];
×
857
    if (buttonID === undefined || buttonID === 0) {
×
858
      throw new Error("Tried sending button event for unsupported button (" + button + ")");
×
859
    }
860

861
    if (this.activeIdentifier === 0) { // cannot press button if no device is selected
×
862
      throw new Error("Tried sending button event although no target was selected");
×
863
    }
864

865
    if (!this.isActive()) { // cannot press button if device is not active (aka no Apple TV is listening)
×
866
      throw new Error("Tried sending button event although target was not marked as active");
×
867
    }
868

869
    if (button === ButtonType.SIRI && this.audioSupported) {
×
870
      if (buttonState === ButtonState.DOWN) { // start streaming session
×
871
        this.handleSiriAudioStart();
×
872
      } else if (buttonState === ButtonState.UP) { // stop streaming session
×
873
        this.handleSiriAudioStop();
×
874
      }
875
      return;
×
876
    }
877

878
    const buttonIdTlv = tlv.encode(
×
879
      ButtonEvent.BUTTON_ID, buttonID,
880
    );
881

882
    const buttonStateTlv = tlv.encode(
×
883
      ButtonEvent.BUTTON_STATE, buttonState,
884
    );
885

886
    const timestampTlv = tlv.encode(
×
887
      ButtonEvent.TIMESTAMP, tlv.writeVariableUIntLE(new Date().getTime()),
888
      // timestamp should be uint64. bigint though is only supported by node 10.4.0 and above
889
      // thus we just interpret timestamp as a regular number
890
    );
891

892
    const activeIdentifierTlv = tlv.encode(
×
893
      ButtonEvent.ACTIVE_IDENTIFIER, tlv.writeUInt32(this.activeIdentifier),
894
    );
895

896
    this.lastButtonEvent = Buffer.concat([
×
897
      buttonIdTlv, buttonStateTlv, timestampTlv, activeIdentifierTlv,
898
    ]).toString("base64");
899
    this.targetControlService!.getCharacteristic(Characteristic.ButtonEvent)!.sendEventNotification(this.lastButtonEvent);
×
900
  }
901

902
  private parseTargetConfigurationTLV(data: Buffer): TargetConfiguration {
903
    const configTLV = tlv.decode(data);
×
904

905
    const identifier = tlv.readUInt32(configTLV[TargetConfigurationTypes.TARGET_IDENTIFIER]);
×
906

907
    let name = undefined;
×
908
    if (configTLV[TargetConfigurationTypes.TARGET_NAME]) {
×
909
      name = configTLV[TargetConfigurationTypes.TARGET_NAME].toString();
×
910
    }
911

912
    let category = undefined;
×
913
    if (configTLV[TargetConfigurationTypes.TARGET_CATEGORY]) {
×
914
      category = tlv.readUInt16(configTLV[TargetConfigurationTypes.TARGET_CATEGORY]);
×
915
    }
916

917
    const buttonConfiguration: Record<number, ButtonConfiguration> = {};
×
918

919
    if (configTLV[TargetConfigurationTypes.BUTTON_CONFIGURATION]) {
×
920
      const buttonConfigurationTLV = tlv.decodeList(configTLV[TargetConfigurationTypes.BUTTON_CONFIGURATION], ButtonConfigurationTypes.BUTTON_ID);
×
921
      buttonConfigurationTLV.forEach(entry => {
×
922
        const buttonId = entry[ButtonConfigurationTypes.BUTTON_ID][0];
×
923
        const buttonType = tlv.readUInt16(entry[ButtonConfigurationTypes.BUTTON_TYPE]);
×
924
        let buttonName;
925
        if (entry[ButtonConfigurationTypes.BUTTON_NAME]) {
×
926
          buttonName = entry[ButtonConfigurationTypes.BUTTON_NAME].toString();
×
927
        } else {
928
          // @ts-expect-error: forceConsistentCasingInFileNames compiler option
929
          buttonName = ButtonType[buttonType as ButtonType];
×
930
        }
931

932
        buttonConfiguration[buttonId] = {
×
933
          buttonID: buttonId,
934
          buttonType: buttonType,
935
          buttonName: buttonName,
936
        };
937
      });
938
    }
939

940
    return {
×
941
      targetIdentifier: identifier,
942
      targetName: name,
943
      targetCategory: category,
944
      buttonConfiguration: buttonConfiguration,
945
    };
946
  }
947

948
  private updatedTargetConfiguration(): void {
949
    const bufferList = [];
×
950
    for (const configuration of Object.values(this.targetConfigurations)) {
×
951
      const targetIdentifier = tlv.encode(
×
952
        TargetConfigurationTypes.TARGET_IDENTIFIER, tlv.writeUInt32(configuration.targetIdentifier),
953
      );
954

955
      const targetName = tlv.encode(
×
956
        TargetConfigurationTypes.TARGET_NAME, configuration.targetName!,
957
      );
958

959
      const targetCategory = tlv.encode(
×
960
        TargetConfigurationTypes.TARGET_CATEGORY, tlv.writeUInt16(configuration.targetCategory!),
961
      );
962

963
      const buttonConfigurationBuffers: Buffer[] = [];
×
964
      for (const value of configuration.buttonConfiguration.values()) {
×
965
        let tlvBuffer = tlv.encode(
×
966
          ButtonConfigurationTypes.BUTTON_ID, value.buttonID,
967
          ButtonConfigurationTypes.BUTTON_TYPE, tlv.writeUInt16(value.buttonType),
968
        );
969

970
        if (value.buttonName) {
×
971
          tlvBuffer = Buffer.concat([
×
972
            tlvBuffer,
973
            tlv.encode(
974
              ButtonConfigurationTypes.BUTTON_NAME, value.buttonName,
975
            ),
976
          ]);
977
        }
978

979
        buttonConfigurationBuffers.push(tlvBuffer);
×
980
      }
981

982
      const buttonConfiguration = tlv.encode(
×
983
        TargetConfigurationTypes.BUTTON_CONFIGURATION, Buffer.concat(buttonConfigurationBuffers),
984
      );
985

986
      const targetConfiguration = Buffer.concat(
×
987
        [targetIdentifier, targetName, targetCategory, buttonConfiguration],
988
      );
989

990
      bufferList.push(tlv.encode(TargetControlList.TARGET_CONFIGURATION, targetConfiguration));
×
991
    }
992

993
    this.targetConfigurationsString = Buffer.concat(bufferList).toString("base64");
×
994
    this.stateChangeDelegate?.();
×
995
  }
996

997
  private buildTargetControlSupportedConfigurationTLV(configuration: SupportedConfiguration): string {
998
    const maximumTargets = tlv.encode(
×
999
      TargetControlCommands.MAXIMUM_TARGETS, configuration.maximumTargets,
1000
    );
1001

1002
    const ticksPerSecond = tlv.encode(
×
1003
      TargetControlCommands.TICKS_PER_SECOND, tlv.writeVariableUIntLE(configuration.ticksPerSecond),
1004
    );
1005

1006
    const supportedButtonConfigurationBuffers: Uint8Array[] = [];
×
1007
    configuration.supportedButtonConfiguration.forEach(value => {
×
1008
      const tlvBuffer = tlv.encode(
×
1009
        SupportedButtonConfigurationTypes.BUTTON_ID, value.buttonID,
1010
        SupportedButtonConfigurationTypes.BUTTON_TYPE, tlv.writeUInt16(value.buttonType),
1011
      );
1012
      supportedButtonConfigurationBuffers.push(tlvBuffer);
×
1013
    });
1014
    const supportedButtonConfiguration = tlv.encode(
×
1015
      TargetControlCommands.SUPPORTED_BUTTON_CONFIGURATION, Buffer.concat(supportedButtonConfigurationBuffers),
1016
    );
1017

1018
    const type = tlv.encode(TargetControlCommands.TYPE, configuration.hardwareImplemented ? 1 : 0);
×
1019

1020
    return Buffer.concat(
×
1021
      [maximumTargets, ticksPerSecond, supportedButtonConfiguration, type],
1022
    ).toString("base64");
1023
  }
1024

1025
  // --------------------------------- SIRI/DATA STREAM --------------------------------
1026

1027
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1028
  private handleTargetControlWhoAmI(connection: DataStreamConnection, message: Record<any, any>): void {
1029
    const targetIdentifier = message.identifier;
×
1030
    this.dataStreamConnections.set(targetIdentifier, connection);
×
1031
    debug("Discovered HDS connection for targetIdentifier %s", targetIdentifier);
×
1032

1033
    connection.addProtocolHandler(Protocols.DATA_SEND, this);
×
1034
  }
1035

1036
  private handleSiriAudioStart(): void {
1037
    if (!this.audioSupported) {
×
1038
      throw new Error("Cannot start siri stream on remote where siri is not supported");
×
1039
    }
1040

1041
    if (!this.isActive()) {
×
1042
      debug("Tried opening Siri audio stream, however no controller is connected!");
×
1043
      return;
×
1044
    }
1045

1046
    if (this.activeAudioSession && (!this.activeAudioSession.isClosing() || this.nextAudioSession)) {
×
1047
      // there is already a session running, which is not in closing state and/or there is even already a
1048
      // nextAudioSession running. ignoring start request
1049
      debug("Tried opening Siri audio stream, however there is already one in progress");
×
1050
      return;
×
1051
    }
1052

1053
    const connection = this.dataStreamConnections.get(this.activeIdentifier); // get connection for current target
×
1054
    if (connection === undefined) { // target seems not connected, ignore it
×
1055
      debug("Tried opening Siri audio stream however target is not connected via HDS");
×
1056
      return;
×
1057
    }
1058

1059
    // eslint-disable-next-line @typescript-eslint/no-use-before-define
1060
    const audioSession = new SiriAudioSession(connection, this.selectedAudioConfiguration, this.audioProducerConstructor!, this.audioProducerOptions);
×
1061
    if (!this.activeAudioSession) {
×
1062
      this.activeAudioSession = audioSession;
×
1063
    } else {
1064
      // we checked above that this only happens if the activeAudioSession is in closing state,
1065
      // so no collision with the input device can happen
1066
      this.nextAudioSession = audioSession;
×
1067
    }
1068

1069
    audioSession.on(SiriAudioSessionEvents.CLOSE, this.handleSiriAudioSessionClosed.bind(this, audioSession));
×
1070
    audioSession.start();
×
1071
  }
1072

1073
  private handleSiriAudioStop(): void {
1074
    if (this.activeAudioSession) {
×
1075
      if (!this.activeAudioSession.isClosing()) {
×
1076
        this.activeAudioSession.stop();
×
1077
        return;
×
1078
      } else if (this.nextAudioSession && !this.nextAudioSession.isClosing()) {
×
1079
        this.nextAudioSession.stop();
×
1080
        return;
×
1081
      }
1082
    }
1083

1084
    debug("handleSiriAudioStop called although no audio session was started");
×
1085
  }
1086

1087
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1088
  private handleDataSendAckEvent(message: Record<any, any>): void { // transfer was successful
1089
    const streamId = message.streamId;
×
1090
    const endOfStream = message.endOfStream;
×
1091

1092
    if (this.activeAudioSession && this.activeAudioSession.streamId === streamId) {
×
1093
      this.activeAudioSession.handleDataSendAckEvent(endOfStream);
×
1094
    } else if (this.nextAudioSession && this.nextAudioSession.streamId === streamId) {
×
1095
      this.nextAudioSession.handleDataSendAckEvent(endOfStream);
×
1096
    } else {
1097
      debug("Received dataSend acknowledgment event for unknown streamId '%s'", streamId);
×
1098
    }
1099
  }
1100

1101
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1102
  private handleDataSendCloseEvent(message: Record<any, any>): void { // controller indicates he can't handle audio request currently
1103
    const streamId = message.streamId;
×
1104
    const reason: HDSProtocolSpecificErrorReason = message.reason;
×
1105

1106
    if (this.activeAudioSession && this.activeAudioSession.streamId === streamId) {
×
1107
      this.activeAudioSession.handleDataSendCloseEvent(reason);
×
1108
    } else if (this.nextAudioSession && this.nextAudioSession.streamId === streamId) {
×
1109
      this.nextAudioSession.handleDataSendCloseEvent(reason);
×
1110
    } else {
1111
      debug("Received dataSend close event for unknown streamId '%s'", streamId);
×
1112
    }
1113
  }
1114

1115
  private handleSiriAudioSessionClosed(session: SiriAudioSession): void {
1116
    if (session === this.activeAudioSession) {
×
1117
      this.activeAudioSession = this.nextAudioSession;
×
1118
      this.nextAudioSession = undefined;
×
1119
    } else if (session === this.nextAudioSession) {
×
1120
      this.nextAudioSession = undefined;
×
1121
    }
1122
  }
1123

1124
  private handleDataStreamConnectionClosed(connection: DataStreamConnection): void {
1125
    for (const [ targetIdentifier, connection0 ] of this.dataStreamConnections) {
×
1126
      if (connection === connection0) {
×
1127
        debug("HDS connection disconnected for targetIdentifier %s", targetIdentifier);
×
1128
        this.dataStreamConnections.delete(targetIdentifier);
×
1129
        break;
×
1130
      }
1131
    }
1132
  }
1133

1134
  // ------------------------------- AUDIO CONFIGURATION -------------------------------
1135

1136
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1137
  private handleSelectedAudioConfigurationWrite(value: any, callback: CharacteristicSetCallback): void {
1138
    const data = Buffer.from(value, "base64");
×
1139
    const objects = tlv.decode(data);
×
1140

1141
    const selectedAudioStreamConfiguration = tlv.decode(
×
1142
      objects[SelectedAudioInputStreamConfigurationTypes.SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION],
1143
    );
1144

1145
    const codec = selectedAudioStreamConfiguration[AudioCodecConfigurationTypes.CODEC_TYPE][0];
×
1146
    const parameters = tlv.decode(selectedAudioStreamConfiguration[AudioCodecConfigurationTypes.CODEC_PARAMETERS]);
×
1147

1148
    const channels = parameters[AudioCodecParametersTypes.CHANNEL][0];
×
1149
    const bitrate = parameters[AudioCodecParametersTypes.BIT_RATE][0];
×
1150
    const samplerate = parameters[AudioCodecParametersTypes.SAMPLE_RATE][0];
×
1151

1152
    this.selectedAudioConfiguration = {
×
1153
      codecType: codec,
1154
      parameters: {
1155
        channels: channels,
1156
        bitrate: bitrate,
1157
        samplerate: samplerate,
1158
        rtpTime: 20,
1159
      },
1160
    };
1161
    this.selectedAudioConfigurationString = RemoteController.buildSelectedAudioConfigurationTLV({
×
1162
      audioCodecConfiguration: this.selectedAudioConfiguration,
1163
    });
1164

1165
    callback();
×
1166
  }
1167

1168
  private static buildSupportedAudioConfigurationTLV(configuration: SupportedAudioStreamConfiguration): string {
1169
    const codecConfigurationTLV = RemoteController.buildCodecConfigurationTLV(configuration.audioCodecConfiguration);
×
1170

1171
    const supportedAudioStreamConfiguration = tlv.encode(
×
1172
      SupportedAudioStreamConfigurationTypes.AUDIO_CODEC_CONFIGURATION, codecConfigurationTLV,
1173
    );
1174
    return supportedAudioStreamConfiguration.toString("base64");
×
1175
  }
1176

1177
  private static buildSelectedAudioConfigurationTLV(configuration: SelectedAudioStreamConfiguration): string {
1178
    const codecConfigurationTLV = RemoteController.buildCodecConfigurationTLV(configuration.audioCodecConfiguration);
×
1179

1180
    const supportedAudioStreamConfiguration = tlv.encode(
×
1181
      SelectedAudioInputStreamConfigurationTypes.SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION, codecConfigurationTLV,
1182
    );
1183
    return supportedAudioStreamConfiguration.toString("base64");
×
1184
  }
1185

1186
  private static buildCodecConfigurationTLV(codecConfiguration: AudioCodecConfiguration): Buffer {
1187
    const parameters = codecConfiguration.parameters;
×
1188

1189
    let parametersTLV = tlv.encode(
×
1190
      AudioCodecParametersTypes.CHANNEL, parameters.channels,
1191
      AudioCodecParametersTypes.BIT_RATE, parameters.bitrate,
1192
      AudioCodecParametersTypes.SAMPLE_RATE, parameters.samplerate,
1193
    );
1194
    if (parameters.rtpTime) {
×
1195
      parametersTLV = Buffer.concat([
×
1196
        parametersTLV,
1197
        tlv.encode(AudioCodecParametersTypes.PACKET_TIME, parameters.rtpTime),
1198
      ]);
1199
    }
1200

1201
    return tlv.encode(
×
1202
      AudioCodecConfigurationTypes.CODEC_TYPE, codecConfiguration.codecType,
1203
      AudioCodecConfigurationTypes.CODEC_PARAMETERS, parametersTLV,
1204
    );
1205
  }
1206

1207
  // -----------------------------------------------------------------------------------
1208

1209
  /**
1210
   * @private
1211
   */
1212
  constructServices(): RemoteControllerServiceMap {
1213
    this.targetControlManagementService = new Service.TargetControlManagement("", "");
×
1214
    this.targetControlManagementService.setCharacteristic(Characteristic.TargetControlSupportedConfiguration, this.supportedConfiguration);
×
1215
    this.targetControlManagementService.setCharacteristic(Characteristic.TargetControlList, this.targetConfigurationsString);
×
1216
    this.targetControlManagementService.setPrimaryService();
×
1217

1218
    // you can also expose multiple TargetControl services to control multiple apple tvs simultaneously.
1219
    // should we extend this class to support multiple TargetControl services or should users just create a second accessory?
1220
    this.targetControlService = new Service.TargetControl("", "");
×
1221
    this.targetControlService.setCharacteristic(Characteristic.ActiveIdentifier, 0);
×
1222
    this.targetControlService.setCharacteristic(Characteristic.Active, false);
×
1223
    this.targetControlService.setCharacteristic(Characteristic.ButtonEvent, this.lastButtonEvent);
×
1224

1225
    if (this.audioSupported) {
×
1226
      this.siriService = new Service.Siri("", "");
×
1227
      this.siriService.setCharacteristic(Characteristic.SiriInputType, Characteristic.SiriInputType.PUSH_BUTTON_TRIGGERED_APPLE_TV);
×
1228

1229
      this.audioStreamManagementService = new Service.AudioStreamManagement("", "");
×
1230
      this.audioStreamManagementService.setCharacteristic(Characteristic.SupportedAudioStreamConfiguration, this.supportedAudioConfiguration);
×
1231
      this.audioStreamManagementService.setCharacteristic(Characteristic.SelectedAudioStreamConfiguration, this.selectedAudioConfigurationString);
×
1232

1233
      this.dataStreamManagement = new DataStreamManagement();
×
1234

1235
      this.siriService.addLinkedService(this.dataStreamManagement!.getService());
×
1236
      this.siriService.addLinkedService(this.audioStreamManagementService!);
×
1237
    }
1238

1239
    return {
×
1240
      targetControlManagement: this.targetControlManagementService,
1241
      targetControl: this.targetControlService,
1242

1243
      siri: this.siriService,
1244
      audioStreamManagement: this.audioStreamManagementService,
1245
      dataStreamTransportManagement: this.dataStreamManagement?.getService(),
1246
    };
1247
  }
1248

1249
  /**
1250
   * @private
1251
   */
1252
  initWithServices(serviceMap: RemoteControllerServiceMap): void | RemoteControllerServiceMap {
1253
    this.targetControlManagementService = serviceMap.targetControlManagement;
×
1254
    this.targetControlService = serviceMap.targetControl;
×
1255

1256
    this.siriService = serviceMap.siri;
×
1257
    this.audioStreamManagementService = serviceMap.audioStreamManagement;
×
1258
    this.dataStreamManagement = new DataStreamManagement(serviceMap.dataStreamTransportManagement);
×
1259
  }
1260

1261
  /**
1262
   * @private
1263
   */
1264
  configureServices(): void {
1265
    if (!this.targetControlManagementService || !this.targetControlService) {
×
1266
      throw new Error("Unexpected state: Services not configured!"); // playing it save
×
1267
    }
1268

1269
    this.targetControlManagementService.getCharacteristic(Characteristic.TargetControlList)!
×
1270
      .on(CharacteristicEventTypes.GET, callback => {
1271
        callback(null, this.targetConfigurationsString);
×
1272
      })
1273
      .on(CharacteristicEventTypes.SET, this.handleTargetControlWrite.bind(this));
1274

1275
    this.targetControlService.getCharacteristic(Characteristic.ActiveIdentifier)!
×
1276
      .on(CharacteristicEventTypes.GET, callback => {
1277
        callback(undefined, this.activeIdentifier);
×
1278
      });
1279
    this.targetControlService.getCharacteristic(Characteristic.Active)!
×
1280
      .on(CharacteristicEventTypes.GET, callback => {
1281
        callback(undefined, this.isActive());
×
1282
      })
1283
      .on(CharacteristicEventTypes.SET, (value, callback, context, connection) => {
1284
        if (!connection) {
×
1285
          debug("Set event handler for Remote.Active cannot be called from plugin. Connection undefined!");
×
1286
          callback(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
1287
          return;
×
1288
        }
1289
        this.handleActiveWrite(value, callback, connection);
×
1290
      });
1291
    this.targetControlService.getCharacteristic(Characteristic.ButtonEvent)!
×
1292
      .on(CharacteristicEventTypes.GET, (callback: CharacteristicGetCallback) => {
1293
        callback(undefined, this.lastButtonEvent);
×
1294
      });
1295

1296
    if (this.audioSupported) {
×
1297
      this.audioStreamManagementService!.getCharacteristic(Characteristic.SelectedAudioStreamConfiguration)!
×
1298
        .on(CharacteristicEventTypes.GET, callback => {
1299
          callback(null, this.selectedAudioConfigurationString);
×
1300
        })
1301
        .on(CharacteristicEventTypes.SET, this.handleSelectedAudioConfigurationWrite.bind(this))
1302
        .updateValue(this.selectedAudioConfigurationString);
1303

1304
      this.dataStreamManagement!
×
1305
        .onEventMessage(Protocols.TARGET_CONTROL, Topics.WHOAMI, this.handleTargetControlWhoAmI.bind(this))
1306
        .onServerEvent(DataStreamServerEvent.CONNECTION_CLOSED, this.handleDataStreamConnectionClosed.bind(this));
1307

1308
      this.eventHandler = { // eventHandlers which gets subscribed to on open connections on whoami
×
1309
        [Topics.ACK]: this.handleDataSendAckEvent.bind(this),
1310
        [Topics.CLOSE]: this.handleDataSendCloseEvent.bind(this),
1311
      };
1312
    }
1313
  }
1314

1315
  /**
1316
   * @private
1317
   */
1318
  handleControllerRemoved(): void {
1319
    this.targetControlManagementService = undefined;
×
1320
    this.targetControlService = undefined;
×
1321
    this.siriService = undefined;
×
1322
    this.audioStreamManagementService = undefined;
×
1323

1324
    this.eventHandler = undefined;
×
1325
    this.requestHandler = undefined;
×
1326

1327
    this.dataStreamManagement?.destroy();
×
1328
    this.dataStreamManagement = undefined;
×
1329

1330
    // the call to dataStreamManagement.destroy will close any open data stream connection
1331
    // which will result in a call to this.handleDataStreamConnectionClosed, cleaning up this.dataStreamConnections.
1332
    // It will also result in a call to SiriAudioSession.handleDataStreamConnectionClosed (if there are any open session)
1333
    // which again results in a call to this.handleSiriAudioSessionClosed,cleaning up this.activeAudioSession and this.nextAudioSession.
1334
  }
1335

1336
  /**
1337
   * @private
1338
   */
1339
  handleFactoryReset(): void {
1340
    debug("Running factory reset. Resetting targets...");
×
1341
    this.handleResetTargets(undefined);
×
1342
    this.lastButtonEvent = "";
×
1343
  }
1344

1345
  /**
1346
   * @private
1347
   */
1348
  serialize(): SerializedControllerState | undefined {
1349
    if (!this.activeIdentifier && Object.keys(this.targetConfigurations).length === 0) {
×
1350
      return undefined;
×
1351
    }
1352

1353
    return {
×
1354
      activeIdentifier: this.activeIdentifier,
1355
      targetConfigurations: [...this.targetConfigurations].reduce((obj: Record<number, TargetConfiguration>, [ key, value ]) => {
1356
        obj[key] = value;
×
1357
        return obj;
×
1358
      }, {}),
1359
    };
1360
  }
1361

1362
  /**
1363
   * @private
1364
   */
1365
  deserialize(serialized: SerializedControllerState): void {
1366
    this.activeIdentifier = serialized.activeIdentifier;
×
1367
    this.targetConfigurations = Object.entries(serialized.targetConfigurations).reduce((map: Map<number, TargetConfiguration>, [ key, value ]) => {
×
1368
      const identifier = parseInt(key, 10);
×
1369
      map.set(identifier, value);
×
1370
      return map;
×
1371
    }, new Map());
1372
    this.updatedTargetConfiguration();
×
1373
  }
1374

1375
  /**
1376
   * @private
1377
   */
1378
  setupStateChangeDelegate(delegate?: StateChangeDelegate): void {
1379
    this.stateChangeDelegate = delegate;
×
1380
  }
1381

1382
}
1383

1384
/**
1385
 * @group Apple TV Remote
1386
 */
1387
export const enum SiriAudioSessionEvents {
18✔
1388
  CLOSE = "close",
18✔
1389
}
1390

1391
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
1392
export declare interface SiriAudioSession {
1393
  on(event: "close", listener: () => void): this;
1394

1395
  emit(event: "close"): boolean;
1396
}
1397

1398
/**
1399
 * Represents an ongoing audio transmission
1400
 * @group Apple TV Remote
1401
 */
1402
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
1403
export class SiriAudioSession extends EventEmitter {
18✔
1404
  readonly connection: DataStreamConnection;
1405
  private readonly selectedAudioConfiguration: AudioCodecConfiguration;
1406

1407
  private readonly producer: SiriAudioStreamProducer;
1408
  private producerRunning = false; // indicates if the producer is running
×
1409
  private producerTimer?: NodeJS.Timeout; // producer has a 3s timeout to produce the first frame, otherwise transmission will be cancelled
1410

1411
  /**
1412
   * @private file private API
1413
   */
1414
  state: SiriAudioSessionState = SiriAudioSessionState.STARTING;
×
1415
  streamId?: number; // present when state >= SENDING
1416
  endOfStream = false;
×
1417

1418
  private audioFrameQueue: AudioFrame[] = [];
×
1419
  private readonly maxQueueSize = 1024;
×
1420
  private sequenceNumber = 0;
×
1421

1422
  private readonly closeListener: () => void;
1423

1424
  constructor(
1425
    connection: DataStreamConnection,
1426
    selectedAudioConfiguration: AudioCodecConfiguration,
1427
    producerConstructor: SiriAudioStreamProducerConstructor,
1428
    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any
1429
    producerOptions?: any,
1430
  ) {
1431
    super();
×
1432
    this.connection = connection;
×
1433
    this.selectedAudioConfiguration = selectedAudioConfiguration;
×
1434

1435
    this.producer = new producerConstructor(this.handleSiriAudioFrame.bind(this), this.handleProducerError.bind(this), producerOptions);
×
1436

1437
    this.connection.on(DataStreamConnectionEvent.CLOSED, this.closeListener = this.handleDataStreamConnectionClosed.bind(this));
×
1438
  }
1439

1440
  /**
1441
   * Called when siri button is pressed
1442
   */
1443
  start(): void {
1444
    debug("Sending request to start siri audio stream");
×
1445

1446
    // opening dataSend
1447
    this.connection.sendRequest(Protocols.DATA_SEND, Topics.OPEN, {
×
1448
      target: "controller",
1449
      type: "audio.siri",
1450
    }, (error, status, message) => {
1451
      if (this.state === SiriAudioSessionState.CLOSED) {
×
1452
        debug("Ignoring dataSend open response as the session is already closed");
×
1453
        return;
×
1454
      }
1455

1456
      assert.strictEqual(this.state, SiriAudioSessionState.STARTING);
×
1457
      this.state = SiriAudioSessionState.SENDING;
×
1458

1459
      if (error || status) {
×
1460
        if (error) { // errors get produced by hap-nodejs
×
1461
          debug("Error occurred trying to start siri audio stream: %s", error.message);
×
1462
        } else if (status) { // status codes are those returned by the hds response
×
1463
          debug("Controller responded with non-zero status code: %s", HDSStatus[status]);
×
1464
        }
1465
        this.closed();
×
1466
      } else {
1467
        this.streamId = message.streamId;
×
1468

1469
        if (!this.producerRunning) { // audio producer errored in the meantime
×
1470
          this.sendDataSendCloseEvent(HDSProtocolSpecificErrorReason.CANCELLED);
×
1471
        } else {
1472
          debug("Successfully setup siri audio stream with streamId %d", this.streamId);
×
1473
        }
1474
      }
1475
    });
1476

1477
    this.startAudioProducer(); // start audio producer and queue frames in the meantime
×
1478
  }
1479

1480
  /**
1481
   * @returns if the audio session is closing
1482
   */
1483
  isClosing(): boolean {
1484
    return this.state >= SiriAudioSessionState.CLOSING;
×
1485
  }
1486

1487
  /**
1488
   * Called when siri button is released (or active identifier is changed to another device)
1489
   */
1490
  stop(): void {
1491
    assert(this.state <= SiriAudioSessionState.SENDING, "state was higher than SENDING");
×
1492

1493
    debug("Stopping siri audio stream with streamId %d", this.streamId);
×
1494

1495
    this.endOfStream = true; // mark as endOfStream
×
1496
    this.stopAudioProducer();
×
1497

1498
    if (this.state === SiriAudioSessionState.SENDING) {
×
1499
      this.handleSiriAudioFrame(undefined); // send out last few audio frames with endOfStream property set
×
1500

1501
      this.state = SiriAudioSessionState.CLOSING; // we are waiting for an acknowledgment (triggered by endOfStream property)
×
1502
    } else { // if state is not SENDING (aka state is STARTING) the callback for DATA_SEND OPEN did not yet return (or never will)
1503
      this.closed();
×
1504
    }
1505
  }
1506

1507
  private startAudioProducer() {
1508
    this.producer.startAudioProduction(this.selectedAudioConfiguration);
×
1509
    this.producerRunning = true;
×
1510

1511
    this.producerTimer = setTimeout(() => { // producer has 3s to start producing audio frames
×
1512
      debug("Didn't receive any frames from audio producer for stream with streamId %s. Canceling the stream now.", this.streamId);
×
1513
      this.producerTimer = undefined;
×
1514
      this.handleProducerError(HDSProtocolSpecificErrorReason.CANCELLED);
×
1515
    }, 3000);
1516
    this.producerTimer.unref();
×
1517
  }
1518

1519
  private stopAudioProducer() {
1520
    this.producer.stopAudioProduction();
×
1521
    this.producerRunning = false;
×
1522

1523
    if (this.producerTimer) {
×
1524
      clearTimeout(this.producerTimer);
×
1525
      this.producerTimer = undefined;
×
1526
    }
1527
  }
1528

1529
  private handleSiriAudioFrame(frame?: AudioFrame): void { // called from audio producer
1530
    if (this.state >= SiriAudioSessionState.CLOSING) {
×
1531
      return;
×
1532
    }
1533

1534
    if (this.producerTimer) { // if producerTimer is defined, then this is the first frame we are receiving
×
1535
      clearTimeout(this.producerTimer);
×
1536
      this.producerTimer = undefined;
×
1537
    }
1538

1539
    if (frame && this.audioFrameQueue.length < this.maxQueueSize) { // add frame to queue whilst it is not full
×
1540
      this.audioFrameQueue.push(frame);
×
1541
    }
1542

1543
    if (this.state !== SiriAudioSessionState.SENDING) { // dataSend isn't open yet
×
1544
      return;
×
1545
    }
1546

1547
    let queued;
1548
    while ((queued = this.popSome()) !== null) { // send packets
×
1549
      const packets: AudioFramePacket[] = [];
×
1550
      queued.forEach(frame => {
×
1551
        const packetData: AudioFramePacket = {
×
1552
          data: frame.data,
1553
          metadata: {
1554
            rms: new Float32(frame.rms),
1555
            sequenceNumber: new Int64(this.sequenceNumber++),
1556
          },
1557
        };
1558
        packets.push(packetData);
×
1559
      });
1560

1561
      const message: DataSendMessageData = {
×
1562
        packets: packets,
1563
        streamId: new Int64(this.streamId!),
1564
        endOfStream: this.endOfStream,
1565
      };
1566

1567
      try {
×
1568
        this.connection.sendEvent(Protocols.DATA_SEND, Topics.DATA, message);
×
1569
      } catch (error) {
1570
        debug("Error occurred when trying to send audio frame of hds connection: %s", error.message);
×
1571

1572
        this.stopAudioProducer();
×
1573
        this.closed();
×
1574
      }
1575

1576
      if (this.endOfStream) {
×
1577
        break; // popSome() returns empty list if endOfStream=true
×
1578
      }
1579
    }
1580
  }
1581

1582
  private handleProducerError(error: HDSProtocolSpecificErrorReason): void { // called from audio producer
1583
    if (this.state >= SiriAudioSessionState.CLOSING) {
×
1584
      return;
×
1585
    }
1586

1587
    this.stopAudioProducer(); // ensure backend is closed
×
1588
    if (this.state === SiriAudioSessionState.SENDING) { // if state is less than sending dataSend isn't open (yet)
×
1589
      this.sendDataSendCloseEvent(error); // cancel submission
×
1590
    }
1591
  }
1592

1593
  handleDataSendAckEvent(endOfStream: boolean): void { // transfer was successful
1594
    assert.strictEqual(endOfStream, true);
×
1595

1596
    debug("Received acknowledgment for siri audio stream with streamId %s, closing it now", this.streamId);
×
1597

1598
    this.sendDataSendCloseEvent(HDSProtocolSpecificErrorReason.NORMAL);
×
1599
  }
1600

1601
  handleDataSendCloseEvent(reason: HDSProtocolSpecificErrorReason): void { // controller indicates he can't handle audio request currently
1602
    // @ts-expect-error: forceConsistentCasingInFileNames compiler option
1603
    debug("Received close event from controller with reason %s for stream with streamId %s", HDSProtocolSpecificErrorReason[reason], this.streamId);
×
1604
    if (this.state <= SiriAudioSessionState.SENDING) {
×
1605
      this.stopAudioProducer();
×
1606
    }
1607

1608
    this.closed();
×
1609
  }
1610

1611
  private sendDataSendCloseEvent(reason: HDSProtocolSpecificErrorReason): void {
1612
    assert(this.state >= SiriAudioSessionState.SENDING, "state was less than SENDING");
×
1613
    assert(this.state <= SiriAudioSessionState.CLOSING, "state was higher than CLOSING");
×
1614

1615
    this.connection.sendEvent(Protocols.DATA_SEND, Topics.CLOSE, {
×
1616
      streamId: new Int64(this.streamId!),
1617
      reason: new Int64(reason),
1618
    });
1619

1620
    this.closed();
×
1621
  }
1622

1623
  private handleDataStreamConnectionClosed(): void {
1624
    debug("Closing audio session with streamId %d", this.streamId);
×
1625

1626
    if (this.state <= SiriAudioSessionState.SENDING) {
×
1627
      this.stopAudioProducer();
×
1628
    }
1629

1630
    this.closed();
×
1631
  }
1632

1633
  private closed(): void {
1634
    const lastState = this.state;
×
1635
    this.state = SiriAudioSessionState.CLOSED;
×
1636

1637
    if (lastState !== SiriAudioSessionState.CLOSED) {
×
1638
      this.emit(SiriAudioSessionEvents.CLOSE);
×
1639
      this.connection.removeListener(DataStreamConnectionEvent.CLOSED, this.closeListener);
×
1640
    }
1641
    this.removeAllListeners();
×
1642
  }
1643

1644
  private popSome() { // tries to return 5 elements from the queue, if endOfStream=true also less than 5
1645
    if (this.audioFrameQueue.length < 5 && !this.endOfStream) {
×
1646
      return null;
×
1647
    }
1648

1649
    const size = Math.min(this.audioFrameQueue.length, 5); // 5 frames per hap packet seems fine
×
1650
    const result = [];
×
1651
    for (let i = 0; i < size; i++) {
×
1652
      const element = this.audioFrameQueue.shift()!; // removes first element
×
1653
      result.push(element);
×
1654
    }
1655

1656
    return result;
×
1657
  }
1658

1659
}
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