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

homebridge / HAP-NodeJS / 14017380819

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

push

github

web-flow
updated dependencies (#1085)

1360 of 2511 branches covered (54.16%)

Branch coverage included in aggregate %.

6237 of 9297 relevant lines covered (67.09%)

312.17 hits per line

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

18.68
/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
/**
362
 * @group Apple TV Remote
363
 */
364
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
365
export declare interface RemoteController {
366
  on(event: "active-change", listener: (active: boolean) => void): this;
367
  on(event: "active-identifier-change", listener: (activeIdentifier: number) => void): this;
368

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

374
  emit(event: "active-change", active: boolean): boolean;
375
  emit(event: "active-identifier-change", activeIdentifier: number): boolean;
376

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

383
/**
384
 * @group Apple TV Remote
385
 */
386
export interface RemoteControllerServiceMap extends ControllerServiceMap {
387
  targetControlManagement: TargetControlManagement,
388
  targetControl: TargetControl,
389

390
  siri?: Siri,
391
  audioStreamManagement?: AudioStreamManagement,
392
  dataStreamTransportManagement?: DataStreamTransportManagement
393
}
394

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

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

413
  private readonly audioSupported: boolean;
414
  private readonly audioProducerConstructor?: SiriAudioStreamProducerConstructor;
415
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
416
  private readonly audioProducerOptions?: any;
417

418
  private targetControlManagementService?: TargetControlManagement;
419
  private targetControlService?: TargetControl;
420

421
  private siriService?: Siri;
422
  private audioStreamManagementService?: AudioStreamManagement;
423
  private dataStreamManagement?: DataStreamManagement;
424

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

430
  private lastButtonEvent = "";
×
431

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

436
  private readonly supportedAudioConfiguration: string;
437
  private selectedAudioConfiguration: AudioCodecConfiguration;
438
  private selectedAudioConfigurationString: string;
439

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

444
  /**
445
   * @private
446
   */
447
  eventHandler?: Record<string, EventHandler>;
448
  /**
449
   * @private
450
   */
451
  requestHandler?: Record<string, RequestHandler>;
452

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

471
    const configuration: SupportedConfiguration = this.constructSupportedConfiguration();
×
472
    this.supportedConfiguration = this.buildTargetControlSupportedConfigurationTLV(configuration);
×
473

474
    const audioConfiguration: SupportedAudioStreamConfiguration = this.constructSupportedAudioConfiguration();
×
475
    this.supportedAudioConfiguration = RemoteController.buildSupportedAudioConfigurationTLV(audioConfiguration);
×
476

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

491
  /**
492
   * @private
493
   */
494
  controllerId(): ControllerIdentifier {
495
    return DefaultControllerType.REMOTE;
×
496
  }
497

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

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

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

516
    if (this.activeAudioSession) {
×
517
      this.handleSiriAudioStop();
×
518
    }
519

520
    setTimeout(() => this.emit(RemoteControllerEvents.ACTIVE_IDENTIFIER_CHANGE, activeIdentifier), 0);
×
521
    this.setInactive();
×
522
  }
523

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

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

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

553
    return undefined;
×
554
  }
555

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

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

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

585
  // ---------------------------------- CONFIGURATION ----------------------------------
586
  // override methods if you would like to change anything (but should not be necessary most likely)
587

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

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

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

614
    return configuration;
×
615
  }
616

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

631
  // --------------------------------- TARGET CONTROL ----------------------------------
632

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

638
    const operation = objects[TargetControlList.OPERATION][0] as Operation;
×
639

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

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

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

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

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

681
  private handleAddTarget(targetConfiguration?: TargetConfiguration): HAPStatus {
682
    if (!targetConfiguration) {
×
683
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
684
    }
685

686
    this.targetConfigurations.set(targetConfiguration.targetIdentifier, targetConfiguration);
×
687

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

690
    setTimeout(() => this.emit(RemoteControllerEvents.TARGET_ADDED, targetConfiguration), 0);
×
691

692
    this.updatedTargetConfiguration(); // set response
×
693
    return HAPStatus.SUCCESS;
×
694
  }
695

696
  private handleUpdateTarget(targetConfiguration?: TargetConfiguration): HAPStatus {
697
    if (!targetConfiguration) {
×
698
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
699
    }
700

701
    const updates: TargetUpdates[] = [];
×
702

703
    const configuredTarget = this.targetConfigurations.get(targetConfiguration.targetIdentifier);
×
704
    if (!configuredTarget) {
×
705
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
706
    }
707

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

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

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

728
      for (const configuration of Object.values(targetConfiguration.buttonConfiguration)) {
×
729
        const savedConfiguration = configuredTarget.buttonConfiguration[configuration.buttonID];
×
730

731
        savedConfiguration.buttonType = configuration.buttonType;
×
732
        savedConfiguration.buttonName = configuration.buttonName;
×
733
      }
734
      updates.push(TargetUpdates.UPDATED_BUTTONS);
×
735
    }
736

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

739
    this.updatedTargetConfiguration(); // set response
×
740
    return HAPStatus.SUCCESS;
×
741
  }
742

743
  private handleRemoveTarget(targetConfiguration?: TargetConfiguration): HAPStatus {
744
    if (!targetConfiguration) {
×
745
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
746
    }
747

748
    const configuredTarget = this.targetConfigurations.get(targetConfiguration.targetIdentifier);
×
749
    if (!configuredTarget) {
×
750
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
751
    }
752

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

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

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

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

773
    this.updatedTargetConfiguration(); // set response
×
774
    return HAPStatus.SUCCESS;
×
775
  }
776

777
  private handleResetTargets(targetConfiguration?: TargetConfiguration): HAPStatus {
778
    if (targetConfiguration) {
×
779
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
780
    }
781

782
    debug("Resetting all target configurations");
×
783
    this.targetConfigurations = new Map();
×
784
    this.updatedTargetConfiguration(); // set response
×
785

786
    setTimeout(() => this.emit(RemoteControllerEvents.TARGETS_RESET), 0);
×
787
    this.setActiveIdentifier(0); // resetting active identifier (also sets active to false)
×
788

789
    return HAPStatus.SUCCESS;
×
790
  }
791

792
  private handleListTargets(targetConfiguration?: TargetConfiguration): HAPStatus {
793
    if (targetConfiguration) {
×
794
      return HAPStatus.INVALID_VALUE_IN_REQUEST;
×
795
    }
796

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

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

809
    if (this.activeConnection) {
×
810
      this.activeConnection.removeListener(HAPConnectionEvent.CLOSED, this.activeConnectionDisconnectListener!);
×
811
      this.activeConnection = undefined;
×
812
      this.activeConnectionDisconnectListener = undefined;
×
813
    }
814

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

821
    const activeTarget = this.targetConfigurations.get(this.activeIdentifier);
×
822
    if (!activeTarget) {
×
823
      callback(HAPStatus.INVALID_VALUE_IN_REQUEST);
×
824
      return;
×
825
    }
826

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

829
    callback();
×
830

831
    this.emit(RemoteControllerEvents.ACTIVE_CHANGE, value as boolean);
×
832
  }
833

834
  private setInactive(): void {
835
    if (this.activeConnection === undefined) {
×
836
      return;
×
837
    }
838

839
    this.activeConnection.removeListener(HAPConnectionEvent.CLOSED, this.activeConnectionDisconnectListener!);
×
840
    this.activeConnection = undefined;
×
841
    this.activeConnectionDisconnectListener = undefined;
×
842

843
    this.targetControlService!.getCharacteristic(Characteristic.Active)!.updateValue(false);
×
844
    debug("Remote was set to INACTIVE");
×
845

846
    setTimeout(() => this.emit(RemoteControllerEvents.ACTIVE_CHANGE, false), 0);
×
847
  }
848

849
  private handleActiveSessionDisconnected(connection: HAPConnection): void {
850
    if (connection !== this.activeConnection) {
×
851
      return;
×
852
    }
853

854
    debug("Active hap session disconnected!");
×
855
    this.setInactive();
×
856
  }
857

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

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

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

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

881
    const buttonIdTlv = tlv.encode(
×
882
      ButtonEvent.BUTTON_ID, buttonID,
883
    );
884

885
    const buttonStateTlv = tlv.encode(
×
886
      ButtonEvent.BUTTON_STATE, buttonState,
887
    );
888

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

895
    const activeIdentifierTlv = tlv.encode(
×
896
      ButtonEvent.ACTIVE_IDENTIFIER, tlv.writeUInt32(this.activeIdentifier),
897
    );
898

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

905
  private parseTargetConfigurationTLV(data: Buffer): TargetConfiguration {
906
    const configTLV = tlv.decode(data);
×
907

908
    const identifier = tlv.readUInt32(configTLV[TargetConfigurationTypes.TARGET_IDENTIFIER]);
×
909

910
    let name = undefined;
×
911
    if (configTLV[TargetConfigurationTypes.TARGET_NAME]) {
×
912
      name = configTLV[TargetConfigurationTypes.TARGET_NAME].toString();
×
913
    }
914

915
    let category = undefined;
×
916
    if (configTLV[TargetConfigurationTypes.TARGET_CATEGORY]) {
×
917
      category = tlv.readUInt16(configTLV[TargetConfigurationTypes.TARGET_CATEGORY]);
×
918
    }
919

920
    const buttonConfiguration: Record<number, ButtonConfiguration> = {};
×
921

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

935
        buttonConfiguration[buttonId] = {
×
936
          buttonID: buttonId,
937
          buttonType: buttonType,
938
          buttonName: buttonName,
939
        };
940
      });
941
    }
942

943
    return {
×
944
      targetIdentifier: identifier,
945
      targetName: name,
946
      targetCategory: category,
947
      buttonConfiguration: buttonConfiguration,
948
    };
949
  }
950

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

958
      const targetName = tlv.encode(
×
959
        TargetConfigurationTypes.TARGET_NAME, configuration.targetName!,
960
      );
961

962
      const targetCategory = tlv.encode(
×
963
        TargetConfigurationTypes.TARGET_CATEGORY, tlv.writeUInt16(configuration.targetCategory!),
964
      );
965

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

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

982
        buttonConfigurationBuffers.push(tlvBuffer);
×
983
      }
984

985
      const buttonConfiguration = tlv.encode(
×
986
        TargetConfigurationTypes.BUTTON_CONFIGURATION, Buffer.concat(buttonConfigurationBuffers),
987
      );
988

989
      const targetConfiguration = Buffer.concat(
×
990
        [targetIdentifier, targetName, targetCategory, buttonConfiguration],
991
      );
992

993
      bufferList.push(tlv.encode(TargetControlList.TARGET_CONFIGURATION, targetConfiguration));
×
994
    }
995

996
    this.targetConfigurationsString = Buffer.concat(bufferList).toString("base64");
×
997
    this.stateChangeDelegate?.();
×
998
  }
999

1000
  private buildTargetControlSupportedConfigurationTLV(configuration: SupportedConfiguration): string {
1001
    const maximumTargets = tlv.encode(
×
1002
      TargetControlCommands.MAXIMUM_TARGETS, configuration.maximumTargets,
1003
    );
1004

1005
    const ticksPerSecond = tlv.encode(
×
1006
      TargetControlCommands.TICKS_PER_SECOND, tlv.writeVariableUIntLE(configuration.ticksPerSecond),
1007
    );
1008

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

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

1023
    return Buffer.concat(
×
1024
      [maximumTargets, ticksPerSecond, supportedButtonConfiguration, type],
1025
    ).toString("base64");
1026
  }
1027

1028
  // --------------------------------- SIRI/DATA STREAM --------------------------------
1029

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

1036
    connection.addProtocolHandler(Protocols.DATA_SEND, this);
×
1037
  }
1038

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

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

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

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

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

1072
    audioSession.on(SiriAudioSessionEvents.CLOSE, this.handleSiriAudioSessionClosed.bind(this, audioSession));
×
1073
    audioSession.start();
×
1074
  }
1075

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

1087
    debug("handleSiriAudioStop called although no audio session was started");
×
1088
  }
1089

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

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

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

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

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

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

1137
  // ------------------------------- AUDIO CONFIGURATION -------------------------------
1138

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

1144
    const selectedAudioStreamConfiguration = tlv.decode(
×
1145
      objects[SelectedAudioInputStreamConfigurationTypes.SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION],
1146
    );
1147

1148
    const codec = selectedAudioStreamConfiguration[AudioCodecConfigurationTypes.CODEC_TYPE][0];
×
1149
    const parameters = tlv.decode(selectedAudioStreamConfiguration[AudioCodecConfigurationTypes.CODEC_PARAMETERS]);
×
1150

1151
    const channels = parameters[AudioCodecParametersTypes.CHANNEL][0];
×
1152
    const bitrate = parameters[AudioCodecParametersTypes.BIT_RATE][0];
×
1153
    const samplerate = parameters[AudioCodecParametersTypes.SAMPLE_RATE][0];
×
1154

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

1168
    callback();
×
1169
  }
1170

1171
  private static buildSupportedAudioConfigurationTLV(configuration: SupportedAudioStreamConfiguration): string {
1172
    const codecConfigurationTLV = RemoteController.buildCodecConfigurationTLV(configuration.audioCodecConfiguration);
×
1173

1174
    const supportedAudioStreamConfiguration = tlv.encode(
×
1175
      SupportedAudioStreamConfigurationTypes.AUDIO_CODEC_CONFIGURATION, codecConfigurationTLV,
1176
    );
1177
    return supportedAudioStreamConfiguration.toString("base64");
×
1178
  }
1179

1180
  private static buildSelectedAudioConfigurationTLV(configuration: SelectedAudioStreamConfiguration): string {
1181
    const codecConfigurationTLV = RemoteController.buildCodecConfigurationTLV(configuration.audioCodecConfiguration);
×
1182

1183
    const supportedAudioStreamConfiguration = tlv.encode(
×
1184
      SelectedAudioInputStreamConfigurationTypes.SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION, codecConfigurationTLV,
1185
    );
1186
    return supportedAudioStreamConfiguration.toString("base64");
×
1187
  }
1188

1189
  private static buildCodecConfigurationTLV(codecConfiguration: AudioCodecConfiguration): Buffer {
1190
    const parameters = codecConfiguration.parameters;
×
1191

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

1204
    return tlv.encode(
×
1205
      AudioCodecConfigurationTypes.CODEC_TYPE, codecConfiguration.codecType,
1206
      AudioCodecConfigurationTypes.CODEC_PARAMETERS, parametersTLV,
1207
    );
1208
  }
1209

1210
  // -----------------------------------------------------------------------------------
1211

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

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

1228
    if (this.audioSupported) {
×
1229
      this.siriService = new Service.Siri("", "");
×
1230
      this.siriService.setCharacteristic(Characteristic.SiriInputType, Characteristic.SiriInputType.PUSH_BUTTON_TRIGGERED_APPLE_TV);
×
1231

1232
      this.audioStreamManagementService = new Service.AudioStreamManagement("", "");
×
1233
      this.audioStreamManagementService.setCharacteristic(Characteristic.SupportedAudioStreamConfiguration, this.supportedAudioConfiguration);
×
1234
      this.audioStreamManagementService.setCharacteristic(Characteristic.SelectedAudioStreamConfiguration, this.selectedAudioConfigurationString);
×
1235

1236
      this.dataStreamManagement = new DataStreamManagement();
×
1237

1238
      this.siriService.addLinkedService(this.dataStreamManagement!.getService());
×
1239
      this.siriService.addLinkedService(this.audioStreamManagementService!);
×
1240
    }
1241

1242
    return {
×
1243
      targetControlManagement: this.targetControlManagementService,
1244
      targetControl: this.targetControlService,
1245

1246
      siri: this.siriService,
1247
      audioStreamManagement: this.audioStreamManagementService,
1248
      dataStreamTransportManagement: this.dataStreamManagement?.getService(),
1249
    };
1250
  }
1251

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

1259
    this.siriService = serviceMap.siri;
×
1260
    this.audioStreamManagementService = serviceMap.audioStreamManagement;
×
1261
    this.dataStreamManagement = new DataStreamManagement(serviceMap.dataStreamTransportManagement);
×
1262
  }
1263

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

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

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

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

1307
      this.dataStreamManagement!
×
1308
        .onEventMessage(Protocols.TARGET_CONTROL, Topics.WHOAMI, this.handleTargetControlWhoAmI.bind(this))
1309
        .onServerEvent(DataStreamServerEvent.CONNECTION_CLOSED, this.handleDataStreamConnectionClosed.bind(this));
1310

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

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

1327
    this.eventHandler = undefined;
×
1328
    this.requestHandler = undefined;
×
1329

1330
    this.dataStreamManagement?.destroy();
×
1331
    this.dataStreamManagement = undefined;
×
1332

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

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

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

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

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

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

1385
}
1386

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

1394
/**
1395
 * @group Apple TV Remote
1396
 */
1397
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
1398
export declare interface SiriAudioSession {
1399
  on(event: "close", listener: () => void): this;
1400

1401
  emit(event: "close"): boolean;
1402
}
1403

1404
/**
1405
 * Represents an ongoing audio transmission
1406
 * @group Apple TV Remote
1407
 */
1408
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
1409
export class SiriAudioSession extends EventEmitter {
18✔
1410
  readonly connection: DataStreamConnection;
1411
  private readonly selectedAudioConfiguration: AudioCodecConfiguration;
1412

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

1417
  /**
1418
   * @private file private API
1419
   */
1420
  state: SiriAudioSessionState = SiriAudioSessionState.STARTING;
×
1421
  streamId?: number; // present when state >= SENDING
1422
  endOfStream = false;
×
1423

1424
  private audioFrameQueue: AudioFrame[] = [];
×
1425
  private readonly maxQueueSize = 1024;
×
1426
  private sequenceNumber = 0;
×
1427

1428
  private readonly closeListener: () => void;
1429

1430
  constructor(
1431
    connection: DataStreamConnection,
1432
    selectedAudioConfiguration: AudioCodecConfiguration,
1433
    producerConstructor: SiriAudioStreamProducerConstructor,
1434
    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any
1435
    producerOptions?: any,
1436
  ) {
1437
    super();
×
1438
    this.connection = connection;
×
1439
    this.selectedAudioConfiguration = selectedAudioConfiguration;
×
1440

1441
    this.producer = new producerConstructor(this.handleSiriAudioFrame.bind(this), this.handleProducerError.bind(this), producerOptions);
×
1442

1443
    this.connection.on(DataStreamConnectionEvent.CLOSED, this.closeListener = this.handleDataStreamConnectionClosed.bind(this));
×
1444
  }
1445

1446
  /**
1447
   * Called when siri button is pressed
1448
   */
1449
  start(): void {
1450
    debug("Sending request to start siri audio stream");
×
1451

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

1462
      assert.strictEqual(this.state, SiriAudioSessionState.STARTING);
×
1463
      this.state = SiriAudioSessionState.SENDING;
×
1464

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

1475
        if (!this.producerRunning) { // audio producer errored in the meantime
×
1476
          this.sendDataSendCloseEvent(HDSProtocolSpecificErrorReason.CANCELLED);
×
1477
        } else {
1478
          debug("Successfully setup siri audio stream with streamId %d", this.streamId);
×
1479
        }
1480
      }
1481
    });
1482

1483
    this.startAudioProducer(); // start audio producer and queue frames in the meantime
×
1484
  }
1485

1486
  /**
1487
   * @returns if the audio session is closing
1488
   */
1489
  isClosing(): boolean {
1490
    return this.state >= SiriAudioSessionState.CLOSING;
×
1491
  }
1492

1493
  /**
1494
   * Called when siri button is released (or active identifier is changed to another device)
1495
   */
1496
  stop(): void {
1497
    assert(this.state <= SiriAudioSessionState.SENDING, "state was higher than SENDING");
×
1498

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

1501
    this.endOfStream = true; // mark as endOfStream
×
1502
    this.stopAudioProducer();
×
1503

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

1507
      this.state = SiriAudioSessionState.CLOSING; // we are waiting for an acknowledgment (triggered by endOfStream property)
×
1508
    } else { // if state is not SENDING (aka state is STARTING) the callback for DATA_SEND OPEN did not yet return (or never will)
1509
      this.closed();
×
1510
    }
1511
  }
1512

1513
  private startAudioProducer() {
1514
    this.producer.startAudioProduction(this.selectedAudioConfiguration);
×
1515
    this.producerRunning = true;
×
1516

1517
    this.producerTimer = setTimeout(() => { // producer has 3s to start producing audio frames
×
1518
      debug("Didn't receive any frames from audio producer for stream with streamId %s. Canceling the stream now.", this.streamId);
×
1519
      this.producerTimer = undefined;
×
1520
      this.handleProducerError(HDSProtocolSpecificErrorReason.CANCELLED);
×
1521
    }, 3000);
1522
    this.producerTimer.unref();
×
1523
  }
1524

1525
  private stopAudioProducer() {
1526
    this.producer.stopAudioProduction();
×
1527
    this.producerRunning = false;
×
1528

1529
    if (this.producerTimer) {
×
1530
      clearTimeout(this.producerTimer);
×
1531
      this.producerTimer = undefined;
×
1532
    }
1533
  }
1534

1535
  private handleSiriAudioFrame(frame?: AudioFrame): void { // called from audio producer
1536
    if (this.state >= SiriAudioSessionState.CLOSING) {
×
1537
      return;
×
1538
    }
1539

1540
    if (this.producerTimer) { // if producerTimer is defined, then this is the first frame we are receiving
×
1541
      clearTimeout(this.producerTimer);
×
1542
      this.producerTimer = undefined;
×
1543
    }
1544

1545
    if (frame && this.audioFrameQueue.length < this.maxQueueSize) { // add frame to queue whilst it is not full
×
1546
      this.audioFrameQueue.push(frame);
×
1547
    }
1548

1549
    if (this.state !== SiriAudioSessionState.SENDING) { // dataSend isn't open yet
×
1550
      return;
×
1551
    }
1552

1553
    let queued;
1554
    while ((queued = this.popSome()) !== null) { // send packets
×
1555
      const packets: AudioFramePacket[] = [];
×
1556
      queued.forEach(frame => {
×
1557
        const packetData: AudioFramePacket = {
×
1558
          data: frame.data,
1559
          metadata: {
1560
            rms: new Float32(frame.rms),
1561
            sequenceNumber: new Int64(this.sequenceNumber++),
1562
          },
1563
        };
1564
        packets.push(packetData);
×
1565
      });
1566

1567
      const message: DataSendMessageData = {
×
1568
        packets: packets,
1569
        streamId: new Int64(this.streamId!),
1570
        endOfStream: this.endOfStream,
1571
      };
1572

1573
      try {
×
1574
        this.connection.sendEvent(Protocols.DATA_SEND, Topics.DATA, message);
×
1575
      } catch (error) {
1576
        debug("Error occurred when trying to send audio frame of hds connection: %s", error.message);
×
1577

1578
        this.stopAudioProducer();
×
1579
        this.closed();
×
1580
      }
1581

1582
      if (this.endOfStream) {
×
1583
        break; // popSome() returns empty list if endOfStream=true
×
1584
      }
1585
    }
1586
  }
1587

1588
  private handleProducerError(error: HDSProtocolSpecificErrorReason): void { // called from audio producer
1589
    if (this.state >= SiriAudioSessionState.CLOSING) {
×
1590
      return;
×
1591
    }
1592

1593
    this.stopAudioProducer(); // ensure backend is closed
×
1594
    if (this.state === SiriAudioSessionState.SENDING) { // if state is less than sending dataSend isn't open (yet)
×
1595
      this.sendDataSendCloseEvent(error); // cancel submission
×
1596
    }
1597
  }
1598

1599
  handleDataSendAckEvent(endOfStream: boolean): void { // transfer was successful
1600
    assert.strictEqual(endOfStream, true);
×
1601

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

1604
    this.sendDataSendCloseEvent(HDSProtocolSpecificErrorReason.NORMAL);
×
1605
  }
1606

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

1614
    this.closed();
×
1615
  }
1616

1617
  private sendDataSendCloseEvent(reason: HDSProtocolSpecificErrorReason): void {
1618
    assert(this.state >= SiriAudioSessionState.SENDING, "state was less than SENDING");
×
1619
    assert(this.state <= SiriAudioSessionState.CLOSING, "state was higher than CLOSING");
×
1620

1621
    this.connection.sendEvent(Protocols.DATA_SEND, Topics.CLOSE, {
×
1622
      streamId: new Int64(this.streamId!),
1623
      reason: new Int64(reason),
1624
    });
1625

1626
    this.closed();
×
1627
  }
1628

1629
  private handleDataStreamConnectionClosed(): void {
1630
    debug("Closing audio session with streamId %d", this.streamId);
×
1631

1632
    if (this.state <= SiriAudioSessionState.SENDING) {
×
1633
      this.stopAudioProducer();
×
1634
    }
1635

1636
    this.closed();
×
1637
  }
1638

1639
  private closed(): void {
1640
    const lastState = this.state;
×
1641
    this.state = SiriAudioSessionState.CLOSED;
×
1642

1643
    if (lastState !== SiriAudioSessionState.CLOSED) {
×
1644
      this.emit(SiriAudioSessionEvents.CLOSE);
×
1645
      this.connection.removeListener(DataStreamConnectionEvent.CLOSED, this.closeListener);
×
1646
    }
1647
    this.removeAllListeners();
×
1648
  }
1649

1650
  private popSome() { // tries to return 5 elements from the queue, if endOfStream=true also less than 5
1651
    if (this.audioFrameQueue.length < 5 && !this.endOfStream) {
×
1652
      return null;
×
1653
    }
1654

1655
    const size = Math.min(this.audioFrameQueue.length, 5); // 5 frames per hap packet seems fine
×
1656
    const result = [];
×
1657
    for (let i = 0; i < size; i++) {
×
1658
      const element = this.audioFrameQueue.shift()!; // removes first element
×
1659
      result.push(element);
×
1660
    }
1661

1662
    return result;
×
1663
  }
1664

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