UNPKG

hap-nodejs

Version:

HAP-NodeJS is a Node.js implementation of HomeKit Accessory Server.

929 lines (928 loc) 60.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SiriAudioSession = exports.SiriAudioSessionEvents = exports.RemoteController = exports.RemoteControllerEvents = exports.TargetUpdates = exports.AudioCodecTypes = exports.ButtonState = exports.TargetCategory = exports.ButtonType = void 0; const tslib_1 = require("tslib"); const assert_1 = tslib_1.__importDefault(require("assert")); const debug_1 = tslib_1.__importDefault(require("debug")); const events_1 = require("events"); const Characteristic_1 = require("../Characteristic"); const datastream_1 = require("../datastream"); const Service_1 = require("../Service"); const tlv = tslib_1.__importStar(require("../util/tlv")); const debug = (0, debug_1.default)("HAP-NodeJS:Remote:Controller"); var TargetControlCommands; (function (TargetControlCommands) { TargetControlCommands[TargetControlCommands["MAXIMUM_TARGETS"] = 1] = "MAXIMUM_TARGETS"; TargetControlCommands[TargetControlCommands["TICKS_PER_SECOND"] = 2] = "TICKS_PER_SECOND"; TargetControlCommands[TargetControlCommands["SUPPORTED_BUTTON_CONFIGURATION"] = 3] = "SUPPORTED_BUTTON_CONFIGURATION"; TargetControlCommands[TargetControlCommands["TYPE"] = 4] = "TYPE"; })(TargetControlCommands || (TargetControlCommands = {})); var SupportedButtonConfigurationTypes; (function (SupportedButtonConfigurationTypes) { SupportedButtonConfigurationTypes[SupportedButtonConfigurationTypes["BUTTON_ID"] = 1] = "BUTTON_ID"; SupportedButtonConfigurationTypes[SupportedButtonConfigurationTypes["BUTTON_TYPE"] = 2] = "BUTTON_TYPE"; })(SupportedButtonConfigurationTypes || (SupportedButtonConfigurationTypes = {})); /** * @group Apple TV Remote */ var ButtonType; (function (ButtonType) { // noinspection JSUnusedGlobalSymbols ButtonType[ButtonType["UNDEFINED"] = 0] = "UNDEFINED"; ButtonType[ButtonType["MENU"] = 1] = "MENU"; ButtonType[ButtonType["PLAY_PAUSE"] = 2] = "PLAY_PAUSE"; ButtonType[ButtonType["TV_HOME"] = 3] = "TV_HOME"; ButtonType[ButtonType["SELECT"] = 4] = "SELECT"; ButtonType[ButtonType["ARROW_UP"] = 5] = "ARROW_UP"; ButtonType[ButtonType["ARROW_RIGHT"] = 6] = "ARROW_RIGHT"; ButtonType[ButtonType["ARROW_DOWN"] = 7] = "ARROW_DOWN"; ButtonType[ButtonType["ARROW_LEFT"] = 8] = "ARROW_LEFT"; ButtonType[ButtonType["VOLUME_UP"] = 9] = "VOLUME_UP"; ButtonType[ButtonType["VOLUME_DOWN"] = 10] = "VOLUME_DOWN"; ButtonType[ButtonType["SIRI"] = 11] = "SIRI"; ButtonType[ButtonType["POWER"] = 12] = "POWER"; ButtonType[ButtonType["GENERIC"] = 13] = "GENERIC"; })(ButtonType || (exports.ButtonType = ButtonType = {})); var TargetControlList; (function (TargetControlList) { TargetControlList[TargetControlList["OPERATION"] = 1] = "OPERATION"; TargetControlList[TargetControlList["TARGET_CONFIGURATION"] = 2] = "TARGET_CONFIGURATION"; })(TargetControlList || (TargetControlList = {})); var Operation; (function (Operation) { // noinspection JSUnusedGlobalSymbols Operation[Operation["UNDEFINED"] = 0] = "UNDEFINED"; Operation[Operation["LIST"] = 1] = "LIST"; Operation[Operation["ADD"] = 2] = "ADD"; Operation[Operation["REMOVE"] = 3] = "REMOVE"; Operation[Operation["RESET"] = 4] = "RESET"; Operation[Operation["UPDATE"] = 5] = "UPDATE"; })(Operation || (Operation = {})); var TargetConfigurationTypes; (function (TargetConfigurationTypes) { TargetConfigurationTypes[TargetConfigurationTypes["TARGET_IDENTIFIER"] = 1] = "TARGET_IDENTIFIER"; TargetConfigurationTypes[TargetConfigurationTypes["TARGET_NAME"] = 2] = "TARGET_NAME"; TargetConfigurationTypes[TargetConfigurationTypes["TARGET_CATEGORY"] = 3] = "TARGET_CATEGORY"; TargetConfigurationTypes[TargetConfigurationTypes["BUTTON_CONFIGURATION"] = 4] = "BUTTON_CONFIGURATION"; })(TargetConfigurationTypes || (TargetConfigurationTypes = {})); /** * @group Apple TV Remote */ var TargetCategory; (function (TargetCategory) { // noinspection JSUnusedGlobalSymbols TargetCategory[TargetCategory["UNDEFINED"] = 0] = "UNDEFINED"; TargetCategory[TargetCategory["APPLE_TV"] = 24] = "APPLE_TV"; })(TargetCategory || (exports.TargetCategory = TargetCategory = {})); var ButtonConfigurationTypes; (function (ButtonConfigurationTypes) { ButtonConfigurationTypes[ButtonConfigurationTypes["BUTTON_ID"] = 1] = "BUTTON_ID"; ButtonConfigurationTypes[ButtonConfigurationTypes["BUTTON_TYPE"] = 2] = "BUTTON_TYPE"; ButtonConfigurationTypes[ButtonConfigurationTypes["BUTTON_NAME"] = 3] = "BUTTON_NAME"; })(ButtonConfigurationTypes || (ButtonConfigurationTypes = {})); var ButtonEvent; (function (ButtonEvent) { ButtonEvent[ButtonEvent["BUTTON_ID"] = 1] = "BUTTON_ID"; ButtonEvent[ButtonEvent["BUTTON_STATE"] = 2] = "BUTTON_STATE"; ButtonEvent[ButtonEvent["TIMESTAMP"] = 3] = "TIMESTAMP"; ButtonEvent[ButtonEvent["ACTIVE_IDENTIFIER"] = 4] = "ACTIVE_IDENTIFIER"; })(ButtonEvent || (ButtonEvent = {})); /** * @group Apple TV Remote */ var ButtonState; (function (ButtonState) { ButtonState[ButtonState["UP"] = 0] = "UP"; ButtonState[ButtonState["DOWN"] = 1] = "DOWN"; })(ButtonState || (exports.ButtonState = ButtonState = {})); var SelectedAudioInputStreamConfigurationTypes; (function (SelectedAudioInputStreamConfigurationTypes) { SelectedAudioInputStreamConfigurationTypes[SelectedAudioInputStreamConfigurationTypes["SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION"] = 1] = "SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION"; })(SelectedAudioInputStreamConfigurationTypes || (SelectedAudioInputStreamConfigurationTypes = {})); // ---------- var SupportedAudioStreamConfigurationTypes; (function (SupportedAudioStreamConfigurationTypes) { // noinspection JSUnusedGlobalSymbols SupportedAudioStreamConfigurationTypes[SupportedAudioStreamConfigurationTypes["AUDIO_CODEC_CONFIGURATION"] = 1] = "AUDIO_CODEC_CONFIGURATION"; SupportedAudioStreamConfigurationTypes[SupportedAudioStreamConfigurationTypes["COMFORT_NOISE_SUPPORT"] = 2] = "COMFORT_NOISE_SUPPORT"; })(SupportedAudioStreamConfigurationTypes || (SupportedAudioStreamConfigurationTypes = {})); var AudioCodecConfigurationTypes; (function (AudioCodecConfigurationTypes) { AudioCodecConfigurationTypes[AudioCodecConfigurationTypes["CODEC_TYPE"] = 1] = "CODEC_TYPE"; AudioCodecConfigurationTypes[AudioCodecConfigurationTypes["CODEC_PARAMETERS"] = 2] = "CODEC_PARAMETERS"; })(AudioCodecConfigurationTypes || (AudioCodecConfigurationTypes = {})); /** * @group Camera */ var AudioCodecTypes; (function (AudioCodecTypes) { // noinspection JSUnusedGlobalSymbols AudioCodecTypes[AudioCodecTypes["PCMU"] = 0] = "PCMU"; AudioCodecTypes[AudioCodecTypes["PCMA"] = 1] = "PCMA"; AudioCodecTypes[AudioCodecTypes["AAC_ELD"] = 2] = "AAC_ELD"; AudioCodecTypes[AudioCodecTypes["OPUS"] = 3] = "OPUS"; AudioCodecTypes[AudioCodecTypes["MSBC"] = 4] = "MSBC"; AudioCodecTypes[AudioCodecTypes["AMR"] = 5] = "AMR"; AudioCodecTypes[AudioCodecTypes["AMR_WB"] = 6] = "AMR_WB"; })(AudioCodecTypes || (exports.AudioCodecTypes = AudioCodecTypes = {})); var AudioCodecParametersTypes; (function (AudioCodecParametersTypes) { AudioCodecParametersTypes[AudioCodecParametersTypes["CHANNEL"] = 1] = "CHANNEL"; AudioCodecParametersTypes[AudioCodecParametersTypes["BIT_RATE"] = 2] = "BIT_RATE"; AudioCodecParametersTypes[AudioCodecParametersTypes["SAMPLE_RATE"] = 3] = "SAMPLE_RATE"; AudioCodecParametersTypes[AudioCodecParametersTypes["PACKET_TIME"] = 4] = "PACKET_TIME"; // only present in selected audio codec parameters tlv })(AudioCodecParametersTypes || (AudioCodecParametersTypes = {})); var SiriAudioSessionState; (function (SiriAudioSessionState) { SiriAudioSessionState[SiriAudioSessionState["STARTING"] = 0] = "STARTING"; SiriAudioSessionState[SiriAudioSessionState["SENDING"] = 1] = "SENDING"; SiriAudioSessionState[SiriAudioSessionState["CLOSING"] = 2] = "CLOSING"; SiriAudioSessionState[SiriAudioSessionState["CLOSED"] = 3] = "CLOSED"; })(SiriAudioSessionState || (SiriAudioSessionState = {})); /** * @group Apple TV Remote */ var TargetUpdates; (function (TargetUpdates) { TargetUpdates[TargetUpdates["NAME"] = 0] = "NAME"; TargetUpdates[TargetUpdates["CATEGORY"] = 1] = "CATEGORY"; TargetUpdates[TargetUpdates["UPDATED_BUTTONS"] = 2] = "UPDATED_BUTTONS"; TargetUpdates[TargetUpdates["REMOVED_BUTTONS"] = 3] = "REMOVED_BUTTONS"; })(TargetUpdates || (exports.TargetUpdates = TargetUpdates = {})); /** * @group Apple TV Remote */ var RemoteControllerEvents; (function (RemoteControllerEvents) { /** * This event is emitted when the active state of the remote has changed. * active = true indicates that there is currently an Apple TV listening of button presses and audio streams. */ RemoteControllerEvents["ACTIVE_CHANGE"] = "active-change"; /** * This event is emitted when the currently selected target has changed. * Possible reasons for a changed active identifier: manual change via api call, first target configuration * gets added, active target gets removed, accessory gets unpaired, reset request was sent. * An activeIdentifier of 0 indicates that no target is selected. */ RemoteControllerEvents["ACTIVE_IDENTIFIER_CHANGE"] = "active-identifier-change"; /** * This event is emitted when a new target configuration is received. As we currently do not persistently store * configured targets, this will be called at every startup for every Apple TV configured in the home. */ RemoteControllerEvents["TARGET_ADDED"] = "target-add"; /** * This event is emitted when an existing target was updated. * The 'updates' array indicates what exactly was changed for the target. */ RemoteControllerEvents["TARGET_UPDATED"] = "target-update"; /** * This event is emitted when an existing configuration for a target was removed. */ RemoteControllerEvents["TARGET_REMOVED"] = "target-remove"; /** * This event is emitted when a reset of the target configuration is requested. * With this event every configuration made should be reset. This event is also called * when the accessory gets unpaired. */ RemoteControllerEvents["TARGETS_RESET"] = "targets-reset"; })(RemoteControllerEvents || (exports.RemoteControllerEvents = RemoteControllerEvents = {})); /** * Handles everything needed to implement a fully working HomeKit remote controller. * * @group Apple TV Remote */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class RemoteController extends events_1.EventEmitter { stateChangeDelegate; audioSupported; audioProducerConstructor; // eslint-disable-next-line @typescript-eslint/no-explicit-any audioProducerOptions; targetControlManagementService; targetControlService; siriService; audioStreamManagementService; dataStreamManagement; buttons = {}; // internal mapping of buttonId to buttonType for supported buttons supportedConfiguration; targetConfigurations = new Map(); targetConfigurationsString = ""; lastButtonEvent = ""; activeIdentifier = 0; // id of 0 means no device selected activeConnection; // session which marked this remote as active and listens for events and siri activeConnectionDisconnectListener; supportedAudioConfiguration; selectedAudioConfiguration; selectedAudioConfigurationString; dataStreamConnections = new Map(); // maps targetIdentifiers to active data stream connections activeAudioSession; nextAudioSession; /** * @private */ eventHandler; /** * @private */ requestHandler; /** * Creates a new RemoteController. * If siri voice input is supported the constructor to an SiriAudioStreamProducer needs to be supplied. * Otherwise, a remote without voice support will be created. * * For every audio session a new SiriAudioStreamProducer will be constructed. * * @param audioProducerConstructor - constructor for a SiriAudioStreamProducer * @param producerOptions - if supplied this argument will be supplied as third argument of the SiriAudioStreamProducer * constructor. This should be used to supply configurations to the stream producer. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/explicit-module-boundary-types constructor(audioProducerConstructor, producerOptions) { super(); this.audioSupported = audioProducerConstructor !== undefined; this.audioProducerConstructor = audioProducerConstructor; this.audioProducerOptions = producerOptions; const configuration = this.constructSupportedConfiguration(); this.supportedConfiguration = this.buildTargetControlSupportedConfigurationTLV(configuration); const audioConfiguration = this.constructSupportedAudioConfiguration(); this.supportedAudioConfiguration = RemoteController.buildSupportedAudioConfigurationTLV(audioConfiguration); this.selectedAudioConfiguration = { codecType: 3 /* AudioCodecTypes.OPUS */, parameters: { channels: 1, bitrate: 0 /* AudioBitrate.VARIABLE */, samplerate: 1 /* AudioSamplerate.KHZ_16 */, rtpTime: 20, }, }; this.selectedAudioConfigurationString = RemoteController.buildSelectedAudioConfigurationTLV({ audioCodecConfiguration: this.selectedAudioConfiguration, }); } /** * @private */ controllerId() { return "remote" /* DefaultControllerType.REMOTE */; } /** * Set a new target as active target. A value of 0 indicates that no target is selected currently. * * @param activeIdentifier - target identifier */ setActiveIdentifier(activeIdentifier) { if (activeIdentifier === this.activeIdentifier) { return; } if (activeIdentifier !== 0 && !this.targetConfigurations.has(activeIdentifier)) { throw Error("Tried setting unconfigured targetIdentifier to active"); } debug("%d is now the active target", activeIdentifier); this.activeIdentifier = activeIdentifier; this.targetControlService.getCharacteristic(Characteristic_1.Characteristic.ActiveIdentifier).updateValue(activeIdentifier); if (this.activeAudioSession) { this.handleSiriAudioStop(); } setTimeout(() => this.emit("active-identifier-change" /* RemoteControllerEvents.ACTIVE_IDENTIFIER_CHANGE */, activeIdentifier), 0); this.setInactive(); } /** * @returns if the current target is active, meaning the active device is listening for button events or audio sessions */ isActive() { return !!this.activeConnection; } /** * Checks if the supplied targetIdentifier is configured. * * @param targetIdentifier - The target identifier. */ isConfigured(targetIdentifier) { return this.targetConfigurations.has(targetIdentifier); } /** * Returns the targetIdentifier for a give device name * * @param name - The name of the device. * @returns The targetIdentifier of the device or undefined if not existent. */ getTargetIdentifierByName(name) { for (const [activeIdentifier, configuration] of Object.entries(this.targetConfigurations)) { if (configuration.targetName === name) { return parseInt(activeIdentifier, 10); } } return undefined; } /** * Sends a button event to press the supplied button. * * @param button - button to be pressed */ pushButton(button) { this.sendButtonEvent(button, 1 /* ButtonState.DOWN */); } /** * Sends a button event that the supplied button was released. * * @param button - button which was released */ releaseButton(button) { this.sendButtonEvent(button, 0 /* ButtonState.UP */); } /** * Presses a supplied button for a given time. * * @param button - button to be pressed and released * @param time - time in milliseconds (defaults to 200ms) */ pushAndReleaseButton(button, time = 200) { this.pushButton(button); setTimeout(() => this.releaseButton(button), time); } // ---------------------------------- CONFIGURATION ---------------------------------- // override methods if you would like to change anything (but should not be necessary most likely) constructSupportedConfiguration() { const configuration = { maximumTargets: 10, // some random number. (ten should be okay?) ticksPerSecond: 1000, // we rely on unix timestamps supportedButtonConfiguration: [], hardwareImplemented: this.audioSupported, // siri is only allowed for hardware implemented remotes }; const supportedButtons = [ 1 /* ButtonType.MENU */, 2 /* ButtonType.PLAY_PAUSE */, 3 /* ButtonType.TV_HOME */, 4 /* ButtonType.SELECT */, 5 /* ButtonType.ARROW_UP */, 6 /* ButtonType.ARROW_RIGHT */, 7 /* ButtonType.ARROW_DOWN */, 8 /* ButtonType.ARROW_LEFT */, 9 /* ButtonType.VOLUME_UP */, 10 /* ButtonType.VOLUME_DOWN */, 12 /* ButtonType.POWER */, 13 /* ButtonType.GENERIC */, ]; if (this.audioSupported) { // add siri button if this remote supports it supportedButtons.push(11 /* ButtonType.SIRI */); } supportedButtons.forEach(button => { const buttonConfiguration = { buttonID: 100 + button, buttonType: button, }; configuration.supportedButtonConfiguration.push(buttonConfiguration); this.buttons[button] = buttonConfiguration.buttonID; // also saving mapping of type to id locally }); return configuration; } constructSupportedAudioConfiguration() { // the following parameters are expected from HomeKit for a remote return { audioCodecConfiguration: { codecType: 3 /* AudioCodecTypes.OPUS */, parameters: { channels: 1, bitrate: 0 /* AudioBitrate.VARIABLE */, samplerate: 1 /* AudioSamplerate.KHZ_16 */, }, }, }; } // --------------------------------- TARGET CONTROL ---------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any handleTargetControlWrite(value, callback) { const data = Buffer.from(value, "base64"); const objects = tlv.decode(data); const operation = objects[1 /* TargetControlList.OPERATION */][0]; let targetConfiguration = undefined; if (objects[2 /* TargetControlList.TARGET_CONFIGURATION */]) { // if target configuration was sent, parse it targetConfiguration = this.parseTargetConfigurationTLV(objects[2 /* TargetControlList.TARGET_CONFIGURATION */]); } debug("Received TargetControl write operation %s", Operation[operation]); let handler; switch (operation) { case Operation.ADD: handler = this.handleAddTarget.bind(this); break; case Operation.UPDATE: handler = this.handleUpdateTarget.bind(this); break; case Operation.REMOVE: handler = this.handleRemoveTarget.bind(this); break; case Operation.RESET: handler = this.handleResetTargets.bind(this); break; case Operation.LIST: handler = this.handleListTargets.bind(this); break; default: callback(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */, undefined); return; } const status = handler(targetConfiguration); if (status === 0 /* HAPStatus.SUCCESS */) { callback(undefined, this.targetConfigurationsString); // passing value for write response if (operation === Operation.ADD && this.activeIdentifier === 0) { this.setActiveIdentifier(targetConfiguration.targetIdentifier); } } else { callback(new Error(status + "")); } } handleAddTarget(targetConfiguration) { if (!targetConfiguration) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } this.targetConfigurations.set(targetConfiguration.targetIdentifier, targetConfiguration); debug("Configured new target '" + targetConfiguration.targetName + "' with targetIdentifier '" + targetConfiguration.targetIdentifier + "'"); setTimeout(() => this.emit("target-add" /* RemoteControllerEvents.TARGET_ADDED */, targetConfiguration), 0); this.updatedTargetConfiguration(); // set response return 0 /* HAPStatus.SUCCESS */; } handleUpdateTarget(targetConfiguration) { if (!targetConfiguration) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } const updates = []; const configuredTarget = this.targetConfigurations.get(targetConfiguration.targetIdentifier); if (!configuredTarget) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } if (targetConfiguration.targetName) { debug("Target name was updated '%s' => '%s' (%d)", configuredTarget.targetName, targetConfiguration.targetName, configuredTarget.targetIdentifier); configuredTarget.targetName = targetConfiguration.targetName; updates.push(0 /* TargetUpdates.NAME */); } if (targetConfiguration.targetCategory) { debug("Target category was updated '%d' => '%d' for target '%s' (%d)", configuredTarget.targetCategory, targetConfiguration.targetCategory, configuredTarget.targetName, configuredTarget.targetIdentifier); configuredTarget.targetCategory = targetConfiguration.targetCategory; updates.push(1 /* TargetUpdates.CATEGORY */); } if (targetConfiguration.buttonConfiguration) { debug("%d button configurations were updated for target '%s' (%d)", Object.keys(targetConfiguration.buttonConfiguration).length, configuredTarget.targetName, configuredTarget.targetIdentifier); for (const configuration of Object.values(targetConfiguration.buttonConfiguration)) { const savedConfiguration = configuredTarget.buttonConfiguration[configuration.buttonID]; savedConfiguration.buttonType = configuration.buttonType; savedConfiguration.buttonName = configuration.buttonName; } updates.push(2 /* TargetUpdates.UPDATED_BUTTONS */); } setTimeout(() => this.emit("target-update" /* RemoteControllerEvents.TARGET_UPDATED */, targetConfiguration, updates), 0); this.updatedTargetConfiguration(); // set response return 0 /* HAPStatus.SUCCESS */; } handleRemoveTarget(targetConfiguration) { if (!targetConfiguration) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } const configuredTarget = this.targetConfigurations.get(targetConfiguration.targetIdentifier); if (!configuredTarget) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } if (targetConfiguration.buttonConfiguration) { for (const key in targetConfiguration.buttonConfiguration) { if (Object.prototype.hasOwnProperty.call(targetConfiguration.buttonConfiguration, key)) { delete configuredTarget.buttonConfiguration[key]; } } debug("Removed %d button configurations of target '%s' (%d)", Object.keys(targetConfiguration.buttonConfiguration).length, configuredTarget.targetName, configuredTarget.targetIdentifier); setTimeout(() => this.emit("target-update" /* RemoteControllerEvents.TARGET_UPDATED */, configuredTarget, [3 /* TargetUpdates.REMOVED_BUTTONS */]), 0); } else { this.targetConfigurations.delete(targetConfiguration.targetIdentifier); debug("Target '%s' (%d) was removed", configuredTarget.targetName, configuredTarget.targetIdentifier); setTimeout(() => this.emit("target-remove" /* RemoteControllerEvents.TARGET_REMOVED */, targetConfiguration.targetIdentifier), 0); const keys = Object.keys(this.targetConfigurations); this.setActiveIdentifier(keys.length === 0 ? 0 : parseInt(keys[0], 10)); // switch to next available remote } this.updatedTargetConfiguration(); // set response return 0 /* HAPStatus.SUCCESS */; } handleResetTargets(targetConfiguration) { if (targetConfiguration) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } debug("Resetting all target configurations"); this.targetConfigurations = new Map(); this.updatedTargetConfiguration(); // set response setTimeout(() => this.emit("targets-reset" /* RemoteControllerEvents.TARGETS_RESET */), 0); this.setActiveIdentifier(0); // resetting active identifier (also sets active to false) return 0 /* HAPStatus.SUCCESS */; } handleListTargets(targetConfiguration) { if (targetConfiguration) { return -70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */; } // this.targetConfigurationsString is updated after each change, so we basically don't need to do anything here debug("Returning " + Object.keys(this.targetConfigurations).length + " target configurations"); return 0 /* HAPStatus.SUCCESS */; } handleActiveWrite(value, callback, connection) { if (this.activeIdentifier === 0) { debug("Tried to change active state. There is no active target set though"); callback(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */); return; } if (this.activeConnection) { this.activeConnection.removeListener("closed" /* HAPConnectionEvent.CLOSED */, this.activeConnectionDisconnectListener); this.activeConnection = undefined; this.activeConnectionDisconnectListener = undefined; } this.activeConnection = value ? connection : undefined; if (this.activeConnection) { // register listener when hap connection disconnects this.activeConnectionDisconnectListener = this.handleActiveSessionDisconnected.bind(this, this.activeConnection); this.activeConnection.on("closed" /* HAPConnectionEvent.CLOSED */, this.activeConnectionDisconnectListener); } const activeTarget = this.targetConfigurations.get(this.activeIdentifier); if (!activeTarget) { callback(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */); return; } debug("Remote with activeTarget '%s' (%d) was set to %s", activeTarget.targetName, this.activeIdentifier, value ? "ACTIVE" : "INACTIVE"); callback(); this.emit("active-change" /* RemoteControllerEvents.ACTIVE_CHANGE */, value); } setInactive() { if (this.activeConnection === undefined) { return; } this.activeConnection.removeListener("closed" /* HAPConnectionEvent.CLOSED */, this.activeConnectionDisconnectListener); this.activeConnection = undefined; this.activeConnectionDisconnectListener = undefined; this.targetControlService.getCharacteristic(Characteristic_1.Characteristic.Active).updateValue(false); debug("Remote was set to INACTIVE"); setTimeout(() => this.emit("active-change" /* RemoteControllerEvents.ACTIVE_CHANGE */, false), 0); } handleActiveSessionDisconnected(connection) { if (connection !== this.activeConnection) { return; } debug("Active hap session disconnected!"); this.setInactive(); } sendButtonEvent(button, buttonState) { const buttonID = this.buttons[button]; if (buttonID === undefined || buttonID === 0) { throw new Error("Tried sending button event for unsupported button (" + button + ")"); } if (this.activeIdentifier === 0) { // cannot press button if no device is selected throw new Error("Tried sending button event although no target was selected"); } if (!this.isActive()) { // cannot press button if device is not active (aka no Apple TV is listening) throw new Error("Tried sending button event although target was not marked as active"); } if (button === 11 /* ButtonType.SIRI */ && this.audioSupported) { if (buttonState === 1 /* ButtonState.DOWN */) { // start streaming session this.handleSiriAudioStart(); } else if (buttonState === 0 /* ButtonState.UP */) { // stop streaming session this.handleSiriAudioStop(); } return; } const buttonIdTlv = tlv.encode(1 /* ButtonEvent.BUTTON_ID */, buttonID); const buttonStateTlv = tlv.encode(2 /* ButtonEvent.BUTTON_STATE */, buttonState); const timestampTlv = tlv.encode(3 /* ButtonEvent.TIMESTAMP */, tlv.writeVariableUIntLE(new Date().getTime())); const activeIdentifierTlv = tlv.encode(4 /* ButtonEvent.ACTIVE_IDENTIFIER */, tlv.writeUInt32(this.activeIdentifier)); this.lastButtonEvent = Buffer.concat([ buttonIdTlv, buttonStateTlv, timestampTlv, activeIdentifierTlv, ]).toString("base64"); this.targetControlService.getCharacteristic(Characteristic_1.Characteristic.ButtonEvent).sendEventNotification(this.lastButtonEvent); } parseTargetConfigurationTLV(data) { const configTLV = tlv.decode(data); const identifier = tlv.readUInt32(configTLV[1 /* TargetConfigurationTypes.TARGET_IDENTIFIER */]); let name = undefined; if (configTLV[2 /* TargetConfigurationTypes.TARGET_NAME */]) { name = configTLV[2 /* TargetConfigurationTypes.TARGET_NAME */].toString(); } let category = undefined; if (configTLV[3 /* TargetConfigurationTypes.TARGET_CATEGORY */]) { category = tlv.readUInt16(configTLV[3 /* TargetConfigurationTypes.TARGET_CATEGORY */]); } const buttonConfiguration = {}; if (configTLV[4 /* TargetConfigurationTypes.BUTTON_CONFIGURATION */]) { const buttonConfigurationTLV = tlv.decodeList(configTLV[4 /* TargetConfigurationTypes.BUTTON_CONFIGURATION */], 1 /* ButtonConfigurationTypes.BUTTON_ID */); buttonConfigurationTLV.forEach(entry => { const buttonId = entry[1 /* ButtonConfigurationTypes.BUTTON_ID */][0]; const buttonType = tlv.readUInt16(entry[2 /* ButtonConfigurationTypes.BUTTON_TYPE */]); let buttonName; if (entry[3 /* ButtonConfigurationTypes.BUTTON_NAME */]) { buttonName = entry[3 /* ButtonConfigurationTypes.BUTTON_NAME */].toString(); } else { // @ts-expect-error: forceConsistentCasingInFileNames compiler option buttonName = ButtonType[buttonType]; } buttonConfiguration[buttonId] = { buttonID: buttonId, buttonType: buttonType, buttonName: buttonName, }; }); } return { targetIdentifier: identifier, targetName: name, targetCategory: category, buttonConfiguration: buttonConfiguration, }; } updatedTargetConfiguration() { const bufferList = []; for (const configuration of Object.values(this.targetConfigurations)) { const targetIdentifier = tlv.encode(1 /* TargetConfigurationTypes.TARGET_IDENTIFIER */, tlv.writeUInt32(configuration.targetIdentifier)); const targetName = tlv.encode(2 /* TargetConfigurationTypes.TARGET_NAME */, configuration.targetName); const targetCategory = tlv.encode(3 /* TargetConfigurationTypes.TARGET_CATEGORY */, tlv.writeUInt16(configuration.targetCategory)); const buttonConfigurationBuffers = []; for (const value of configuration.buttonConfiguration.values()) { let tlvBuffer = tlv.encode(1 /* ButtonConfigurationTypes.BUTTON_ID */, value.buttonID, 2 /* ButtonConfigurationTypes.BUTTON_TYPE */, tlv.writeUInt16(value.buttonType)); if (value.buttonName) { tlvBuffer = Buffer.concat([ tlvBuffer, tlv.encode(3 /* ButtonConfigurationTypes.BUTTON_NAME */, value.buttonName), ]); } buttonConfigurationBuffers.push(tlvBuffer); } const buttonConfiguration = tlv.encode(4 /* TargetConfigurationTypes.BUTTON_CONFIGURATION */, Buffer.concat(buttonConfigurationBuffers)); const targetConfiguration = Buffer.concat([targetIdentifier, targetName, targetCategory, buttonConfiguration]); bufferList.push(tlv.encode(2 /* TargetControlList.TARGET_CONFIGURATION */, targetConfiguration)); } this.targetConfigurationsString = Buffer.concat(bufferList).toString("base64"); this.stateChangeDelegate?.(); } buildTargetControlSupportedConfigurationTLV(configuration) { const maximumTargets = tlv.encode(1 /* TargetControlCommands.MAXIMUM_TARGETS */, configuration.maximumTargets); const ticksPerSecond = tlv.encode(2 /* TargetControlCommands.TICKS_PER_SECOND */, tlv.writeVariableUIntLE(configuration.ticksPerSecond)); const supportedButtonConfigurationBuffers = []; configuration.supportedButtonConfiguration.forEach(value => { const tlvBuffer = tlv.encode(1 /* SupportedButtonConfigurationTypes.BUTTON_ID */, value.buttonID, 2 /* SupportedButtonConfigurationTypes.BUTTON_TYPE */, tlv.writeUInt16(value.buttonType)); supportedButtonConfigurationBuffers.push(tlvBuffer); }); const supportedButtonConfiguration = tlv.encode(3 /* TargetControlCommands.SUPPORTED_BUTTON_CONFIGURATION */, Buffer.concat(supportedButtonConfigurationBuffers)); const type = tlv.encode(4 /* TargetControlCommands.TYPE */, configuration.hardwareImplemented ? 1 : 0); return Buffer.concat([maximumTargets, ticksPerSecond, supportedButtonConfiguration, type]).toString("base64"); } // --------------------------------- SIRI/DATA STREAM -------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any handleTargetControlWhoAmI(connection, message) { const targetIdentifier = message.identifier; this.dataStreamConnections.set(targetIdentifier, connection); debug("Discovered HDS connection for targetIdentifier %s", targetIdentifier); connection.addProtocolHandler("dataSend" /* Protocols.DATA_SEND */, this); } handleSiriAudioStart() { if (!this.audioSupported) { throw new Error("Cannot start siri stream on remote where siri is not supported"); } if (!this.isActive()) { debug("Tried opening Siri audio stream, however no controller is connected!"); return; } if (this.activeAudioSession && (!this.activeAudioSession.isClosing() || this.nextAudioSession)) { // there is already a session running, which is not in closing state and/or there is even already a // nextAudioSession running. ignoring start request debug("Tried opening Siri audio stream, however there is already one in progress"); return; } const connection = this.dataStreamConnections.get(this.activeIdentifier); // get connection for current target if (connection === undefined) { // target seems not connected, ignore it debug("Tried opening Siri audio stream however target is not connected via HDS"); return; } // eslint-disable-next-line @typescript-eslint/no-use-before-define const audioSession = new SiriAudioSession(connection, this.selectedAudioConfiguration, this.audioProducerConstructor, this.audioProducerOptions); if (!this.activeAudioSession) { this.activeAudioSession = audioSession; } else { // we checked above that this only happens if the activeAudioSession is in closing state, // so no collision with the input device can happen this.nextAudioSession = audioSession; } audioSession.on("close" /* SiriAudioSessionEvents.CLOSE */, this.handleSiriAudioSessionClosed.bind(this, audioSession)); audioSession.start(); } handleSiriAudioStop() { if (this.activeAudioSession) { if (!this.activeAudioSession.isClosing()) { this.activeAudioSession.stop(); return; } else if (this.nextAudioSession && !this.nextAudioSession.isClosing()) { this.nextAudioSession.stop(); return; } } debug("handleSiriAudioStop called although no audio session was started"); } // eslint-disable-next-line @typescript-eslint/no-explicit-any handleDataSendAckEvent(message) { const streamId = message.streamId; const endOfStream = message.endOfStream; if (this.activeAudioSession && this.activeAudioSession.streamId === streamId) { this.activeAudioSession.handleDataSendAckEvent(endOfStream); } else if (this.nextAudioSession && this.nextAudioSession.streamId === streamId) { this.nextAudioSession.handleDataSendAckEvent(endOfStream); } else { debug("Received dataSend acknowledgment event for unknown streamId '%s'", streamId); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any handleDataSendCloseEvent(message) { const streamId = message.streamId; const reason = message.reason; if (this.activeAudioSession && this.activeAudioSession.streamId === streamId) { this.activeAudioSession.handleDataSendCloseEvent(reason); } else if (this.nextAudioSession && this.nextAudioSession.streamId === streamId) { this.nextAudioSession.handleDataSendCloseEvent(reason); } else { debug("Received dataSend close event for unknown streamId '%s'", streamId); } } handleSiriAudioSessionClosed(session) { if (session === this.activeAudioSession) { this.activeAudioSession = this.nextAudioSession; this.nextAudioSession = undefined; } else if (session === this.nextAudioSession) { this.nextAudioSession = undefined; } } handleDataStreamConnectionClosed(connection) { for (const [targetIdentifier, connection0] of this.dataStreamConnections) { if (connection === connection0) { debug("HDS connection disconnected for targetIdentifier %s", targetIdentifier); this.dataStreamConnections.delete(targetIdentifier); break; } } } // ------------------------------- AUDIO CONFIGURATION ------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any handleSelectedAudioConfigurationWrite(value, callback) { const data = Buffer.from(value, "base64"); const objects = tlv.decode(data); const selectedAudioStreamConfiguration = tlv.decode(objects[1 /* SelectedAudioInputStreamConfigurationTypes.SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION */]); const codec = selectedAudioStreamConfiguration[1 /* AudioCodecConfigurationTypes.CODEC_TYPE */][0]; const parameters = tlv.decode(selectedAudioStreamConfiguration[2 /* AudioCodecConfigurationTypes.CODEC_PARAMETERS */]); const channels = parameters[1 /* AudioCodecParametersTypes.CHANNEL */][0]; const bitrate = parameters[2 /* AudioCodecParametersTypes.BIT_RATE */][0]; const samplerate = parameters[3 /* AudioCodecParametersTypes.SAMPLE_RATE */][0]; this.selectedAudioConfiguration = { codecType: codec, parameters: { channels: channels, bitrate: bitrate, samplerate: samplerate, rtpTime: 20, }, }; this.selectedAudioConfigurationString = RemoteController.buildSelectedAudioConfigurationTLV({ audioCodecConfiguration: this.selectedAudioConfiguration, }); callback(); } static buildSupportedAudioConfigurationTLV(configuration) { const codecConfigurationTLV = RemoteController.buildCodecConfigurationTLV(configuration.audioCodecConfiguration); const supportedAudioStreamConfiguration = tlv.encode(1 /* SupportedAudioStreamConfigurationTypes.AUDIO_CODEC_CONFIGURATION */, codecConfigurationTLV); return supportedAudioStreamConfiguration.toString("base64"); } static buildSelectedAudioConfigurationTLV(configuration) { const codecConfigurationTLV = RemoteController.buildCodecConfigurationTLV(configuration.audioCodecConfiguration); const supportedAudioStreamConfiguration = tlv.encode(1 /* SelectedAudioInputStreamConfigurationTypes.SELECTED_AUDIO_INPUT_STREAM_CONFIGURATION */, codecConfigurationTLV); return supportedAudioStreamConfiguration.toString("base64"); } static buildCodecConfigurationTLV(codecConfiguration) { const parameters = codecConfiguration.parameters; let parametersTLV = tlv.encode(1 /* AudioCodecParametersTypes.CHANNEL */, parameters.channels, 2 /* AudioCodecParametersTypes.BIT_RATE */, parameters.bitrate, 3 /* AudioCodecParametersTypes.SAMPLE_RATE */, parameters.samplerate); if (parameters.rtpTime) { parametersTLV = Buffer.concat([ parametersTLV, tlv.encode(4 /* AudioCodecParametersTypes.PACKET_TIME */, parameters.rtpTime), ]); } return tlv.encode(1 /* AudioCodecConfigurationTypes.CODEC_TYPE */, codecConfiguration.codecType, 2 /* AudioCodecConfigurationTypes.CODEC_PARAMETERS */, parametersTLV); } // ----------------------------------------------------------------------------------- /** * @private */ constructServices() { this.targetControlManagementService = new Service_1.Service.TargetControlManagement("", ""); this.targetControlManagementService.setCharacteristic(Characteristic_1.Characteristic.TargetControlSupportedConfiguration, this.supportedConfiguration); this.targetControlManagementService.setCharacteristic(Characteristic_1.Characteristic.TargetControlList, this.targetConfigurationsString); this.targetControlManagementService.setPrimaryService(); // you can also expose multiple TargetControl services to control multiple apple tvs simultaneously. // should we extend this class to support multiple TargetControl services or should users just create a second accessory? this.targetControlService = new Service_1.Service.TargetControl("", ""); this.targetControlService.setCharacteristic(Characteristic_1.Characteristic.ActiveIdentifier, 0); this.targetControlService.setCharacteristic(Characteristic_1.Characteristic.Active, false); this.targetControlService.setCharacteristic(Characteristic_1.Characteristic.ButtonEvent, this.lastButtonEvent); if (this.audioSupported) { this.siriService = new Service_1.Service.Siri("", ""); this.siriService.setCharacteristic(Characteristic_1.Characteristic.SiriInputType, Characteristic_1.Characteristic.SiriInputType.PUSH_BUTTON_TRIGGERED_APPLE_TV); this.audioStreamManagementService = new Service_1.Service.AudioStreamManagement("", ""); this.audioStreamManagementService.setCharacteristic(Characteristic_1.Characteristic.SupportedAudioStreamConfiguration, this.supportedAudioConfiguration); this.audioStreamManagementService.setCharacteristic(Characteristic_1.Characteristic.SelectedAudioStreamConfiguration, this.selectedAudioConfigurationString); this.dataStreamManagement = new datastream_1.DataStreamManagement(); this.siriService.addLinkedService(this.dataStreamManagement.getService()); this.siriService.addLinkedService(this.audioStreamManagementService); } return { targetControlManagement: this.targetControlManagementService, targetControl: this.targetControlService, siri: this.siriService, audioStreamManagement: this.audioStreamManagementService, dataStreamTransportManagement: this.dataStreamManagement?.getService(), }; } /** * @private */ initWithServices(serviceMap) { this.targetControlManagementService = serviceMap.targetControlManagement; this.targetControlService = serviceMap.targetControl; this.siriService = serviceMap.siri; this.audioStreamManagementService = serviceMap.audioStreamManagement; this.dataStreamManagement = new datastream_1.DataStreamManagement(serviceMap.dataStreamTransportManagement); } /** * @private */ configureServices() { if (!this.targetControlManagementService || !this.targetControlService) { throw new Error("Unexpected state: Services not configured!"); // playing it save } this.targetControlManagementService.getCharacteristic(Characteristic_1.Characteristic.TargetControlList) .on("get" /* CharacteristicEventTypes.GET */, callback => { callback(null, this.targetConfigurationsString); }) .on("set" /* CharacteristicEventTypes.SET */, this.handleTargetControlWrite.bind(this)); this.targetControlService.getCharacteristic(Characteristic_1.Characteristic.ActiveIdentifier) .on("get" /* CharacteristicEventTypes.GET */, callback => { callback(undefined, this.activeIdentifier); }); this.targetControlService.getCharacteristic(Characteristic_1.Characteristic.Active) .on("get" /* CharacteristicEventTypes.GET */, callback => { callback(undefined, this.isActive()); }) .on("set" /* CharacteristicEventTypes.SET */, (value, callback, context, connection) => { if (!connection) { debug("Set event handler for Remote.Active cannot be called from plugin. Connection undefined!"); callback(-70410 /* HAPStatus.INVALID_VALUE_IN_REQUEST */); return; } this.handleActiveWrite(value, callback, connection); }); this.targetControlService.getCharacteristic(Characteristic_1.Characteristic.ButtonEvent) .on("get" /* CharacteristicEventTypes.GET */, (callback) => { callback(undefined, this.lastButtonEvent); }); if (this.audioSupported) { this.audioStreamManagementService.getCharacteristic(Characteristic_1.Characteristic.SelectedAudioStreamConfiguration) .on("get" /* CharacteristicEventTypes.GET */, callback => { callback(null, this.selectedAudioConfigurationString); }) .on("set" /* CharacteristicEventTypes.SET */, this.handleSelectedAudioConfigurationWrite.bind(this)) .updateValue(this.selectedAudioConfigurationString); this.dataStreamManagement .onEventMessage("targetControl" /* Protocols.TARGET_CONTROL */, "whoami" /* Topics.WHOAMI */, this.handleTargetControlWhoAmI.bind(this)) .onServerEvent("connection-closed" /* DataStreamServerEvent.CONNECTION_CLOSED */, this.handleDataStreamConnectionClosed.bind(this)); this.eventHandler = { ["ack" /* Topics.ACK */]: this.handleDataSendAckEvent.bind(this), ["close" /* Topics.CLOSE */]: this.handleDataSendCloseEvent.bind(this), }; } } /** * @private */ handleControllerRemoved() { this.targetControlManagementService = undefined; this.targetControlService = undefined; this.siriService = undefined; this.audioStreamManagementService = undefined; this.eventHandler = undefined; this.requestHandler = undefined; this.dataStreamManagement?.destroy(); this.dataStreamManagement = undefined; // the call to dataStreamManagement.destroy will close any open data stream connection // which will result in a call to this.handleDataStreamConnectionClosed, cleaning up this.dataStreamConnections. // It will also result in a call to SiriAudioSession.handleDataStreamConnectionClosed (if there are any open session) // which again results in a call to this.handleSiriAudioSessionClosed,cleaning up this.activeAudioSession and this.nextAudioSession. } /** * @private */ handleFactoryReset() { debug("Running factory reset. Resetting targets..."); this.handleResetTargets(undefined); this.lastButtonEvent = ""; } /** * @private */ serialize() { if (!this.activeIdentifier && Object.keys(this.targetConfigurations).length === 0) { return undefined;