UNPKG

homebridge-appletv-enhanced

Version:

Plugin that exposes the Apple TV to HomeKit with much richer features than the vanilla Apple TV implementation of HomeKit.

933 lines 83.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AppleTVEnhancedAccessory = void 0; const fs_1 = __importDefault(require("fs")); const http_1 = __importDefault(require("http")); const node_pyatv_1 = require("@sebbo2002/node-pyatv"); const md5_1 = __importDefault(require("md5")); const child_process_1 = require("child_process"); const path_1 = __importDefault(require("path")); const CustomPyAtvInstance_1 = __importDefault(require("./CustomPyAtvInstance")); const utils_1 = require("./utils"); const PrefixLogger_1 = __importDefault(require("./PrefixLogger")); const enums_1 = require("./enums"); const RocketRemote_1 = __importDefault(require("./RocketRemote")); const tvOS18InputBugSolver_1 = __importDefault(require("./tvOS18InputBugSolver")); const Characteristics_1 = require("./Characteristics"); const HIDE_BY_DEFAULT_APPS = [ 'com.apple.podcasts', 'com.apple.TVAppStore', 'com.apple.TVSearch', 'com.apple.Arcade', 'com.apple.TVHomeSharing', 'com.apple.TVSettings', 'com.apple.Fitness', 'com.apple.TVShows', 'com.apple.TVMovies', 'com.apple.facetime', ]; const DEFAULT_APP_RENAME = { // eslint-disable-next-line @typescript-eslint/naming-convention 'com.apple.TVWatchList': 'Apple TV', // eslint-disable-next-line @typescript-eslint/naming-convention 'com.apple.TVMusic': 'Apple Music', }; const AIR_PLAY_URI = 'com.apple.TVAirPlay'; const MAX_SERVICES = 100; const HOME_IDENTIFIER = 69; const AVADA_KEDAVRA_IDENTIFIER = 42; const AIR_PLAY_IDENTIFIER = 7567; /** * Platform Accessory * An instance of this class is created for each accessory your platform registers * Each accessory may expose multiple services of different service types. */ class AppleTVEnhancedAccessory { platform; accessory; airPlayInputService = undefined; appConfigs = undefined; avadaKedavraService = undefined; booted = false; commonConfig = undefined; config; credentials = undefined; customPyatvCommandServices = {}; device; deviceStateConfigs = undefined; deviceStateServices = {}; homeInputService = undefined; inputs = {}; lastDeviceState = null; lastDeviceStateChange = 0; lastDeviceStateDraft = null; lastNonZeroVolume = 50; lastTurningOnEvent = 0; log; mediaConfigs = undefined; mediaTypeServices = {}; offline = false; pyatvCharacteristics = {}; pyatvListenerHandlers = {}; remoteKeyAsSwitchConfigs = undefined; remoteKeyServices = {}; rocketRemote = undefined; service = undefined; televisionSpeakerService = undefined; volumeFanService = undefined; constructor(platform, accessory) { this.platform = platform; this.accessory = accessory; this.config = this.applyConfigOverrides(this.platform.config, this.accessory.context.mac); this.device = CustomPyAtvInstance_1.default.deviceAdvanced({ mac: this.accessory.context.mac }); this.log = new PrefixLogger_1.default(this.platform.logLevelLogger, `${this.device.name} (${this.device.mac})`); this.log.debug(`Accessory Config: ${JSON.stringify(this.config)}`); (0, tvOS18InputBugSolver_1.default)(this.log, this.platform.api.user.storagePath(), this.device.mac); const credentials = this.getCredentials(); this.device = CustomPyAtvInstance_1.default.deviceAdvanced({ mac: this.accessory.context.mac, airplayCredentials: credentials, companionCredentials: credentials, }); const pairingRequired = async () => { return this.pair(this.device.host, this.device.mac, this.device.name).then((c) => { this.setCredentials(c); this.device = CustomPyAtvInstance_1.default.deviceAdvanced({ mac: this.device.mac, airplayCredentials: c, companionCredentials: c, }); this.log.success('Paring was successful. Add it to your home in the Home app: com.apple.home://launch'); }); }; const validationLoop = () => { //FIXME: catch errors / remove void void this.credentialsValid().then((valid) => { if (valid) { this.log.success('Credentials are still valid. Continuing ...'); void this.startUp(); } else { this.log.warn('Credentials are no longer valid. Need to repair ...'); //FIXME: catch errors / remove void void pairingRequired().then(validationLoop.bind(this)); } }); }; validationLoop(); } async untilBooted() { while (!this.booted) { await (0, utils_1.delay)(100); } this.log.debug('Reporting as booted.'); } addServiceSave(serviceConstructor, ...constructorArgs) { if (this.accessory.services.length + 1 === MAX_SERVICES) { return undefined; } this.log.debug(`Total services ${this.accessory.services.length + 1} (${MAX_SERVICES - this.accessory.services.length - 1} \ remaining)`); return this.accessory.addService(serviceConstructor, ...constructorArgs); } airPlayInputUpdateName(event) { if (event.value === null || event.value === '') { return; } const configuredName = event.value !== undefined && event.value !== 'AirPlay' ? (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(`AirPlay ${event.value}`), 64) : 'AirPlay'; this.log.debug(`AirPlay: Set dynamic input name to ${configuredName}.`); this.airPlayInputService.updateCharacteristic(this.platform.characteristic.ConfiguredName, configuredName); } appIdToNumber(appId) { const hash = new Uint8Array((0, md5_1.default)(appId, { asBytes: true })); const view = new DataView(hash.buffer); return view.getUint32(0); } // https://github.com/homebridge/HAP-NodeJS/issues/644#issue-409099368 appIdentifiersOrderToTLV8(listOfIdentifiers) { let identifiersTLV = Buffer.alloc(0); listOfIdentifiers.forEach((identifier, index) => { if (index !== 0) { identifiersTLV = Buffer.concat([ identifiersTLV, this.platform.api.hap.encode(enums_1.DisplayOrderTypes.ARRAY_ELEMENT_END, Buffer.alloc(0)), ]); } const element = Buffer.alloc(4); element.writeUInt32LE(identifier, 0); identifiersTLV = Buffer.concat([ identifiersTLV, this.platform.api.hap.encode(enums_1.DisplayOrderTypes.ARRAY_ELEMENT_START, element), ]); }); return identifiersTLV.toString('base64'); } applyConfigOverrides(config, mac) { if (config.deviceSpecificOverrides === undefined) { return config; } const override = config.deviceSpecificOverrides.find((e) => e.mac?.toUpperCase() === mac.toUpperCase()); if (override === undefined) { return config; } config = structuredClone(config); if (override.overrideMediaTypes === true) { config.mediaTypes = override.mediaTypes; } if (override.overrideDeviceStates === true) { config.deviceStates = override.deviceStates; } if (override.overrideDeviceStateDelay === true) { config.deviceStateDelay = override.deviceStateDelay; } if (override.overrideRemoteKeysAsSwitch === true) { config.remoteKeysAsSwitch = override.remoteKeysAsSwitch; } if (override.overrideAvadaKedavraAppAmount === true) { config.avadaKedavraAppAmount = override.avadaKedavraAppAmount; } if (override.overrideCustomInputURIs === true) { config.customInputURIs = override.customInputURIs; } if (override.overrideCustomPyatvCommands === true) { config.customPyatvCommands = override.customPyatvCommands; } if (override.overrideDisableVolumeControlRemote === true) { config.disableVolumeControlRemote = override.disableVolumeControlRemote; } if (override.overrideAbsoluteVolumeControl === true) { config.absoluteVolumeControl = override.absoluteVolumeControl; } if (override.overrideSetTopBox === true) { config.setTopBox = override.setTopBox; } return config; } createAirPlayInput() { this.log.debug(`Adding ${AIR_PLAY_URI} as an input. (named: AirPlay)`); this.airPlayInputService = this.accessory.getService('AirPlay') || this.addServiceSave(this.platform.service.InputSource, 'AirPlay', AIR_PLAY_URI) .setCharacteristic(this.platform.characteristic.ConfiguredName, 'AirPlay') .setCharacteristic(this.platform.characteristic.InputSourceType, this.platform.characteristic.InputSourceType.AIRPLAY) .setCharacteristic(this.platform.characteristic.IsConfigured, this.platform.characteristic.IsConfigured.NOT_CONFIGURED) .setCharacteristic(this.platform.characteristic.Name, 'AirPlay') .setCharacteristic(this.platform.characteristic.CurrentVisibilityState, this.platform.characteristic.CurrentVisibilityState.HIDDEN) .setCharacteristic(this.platform.characteristic.InputDeviceType, this.platform.characteristic.InputDeviceType.OTHER) .setCharacteristic(this.platform.characteristic.TargetVisibilityState, this.platform.characteristic.TargetVisibilityState.HIDDEN) .setCharacteristic(this.platform.characteristic.Identifier, AIR_PLAY_IDENTIFIER); this.service.addLinkedService(this.airPlayInputService); } createAvadaKedavra() { const visibilityState = this.getCommonConfig().showAvadaKedavra === this.platform.characteristic.CurrentVisibilityState.HIDDEN ? this.platform.characteristic.CurrentVisibilityState.HIDDEN : this.platform.characteristic.CurrentVisibilityState.SHOWN; const name = 'Avada Kedavra'; const configuredName = this.getCommonConfig().avadaKedavraName ?? name; this.log.debug(`Adding Avada Kedavra as an input. (named: ${configuredName})`); this.avadaKedavraService = this.accessory.getService(name) || this.addServiceSave(this.platform.service.InputSource, name, 'avadaKedavra') .setCharacteristic(this.platform.characteristic.ConfiguredName, configuredName) .setCharacteristic(this.platform.characteristic.InputSourceType, this.platform.characteristic.InputSourceType.OTHER) .setCharacteristic(this.platform.characteristic.IsConfigured, this.platform.characteristic.IsConfigured.CONFIGURED) .setCharacteristic(this.platform.characteristic.Name, name) .setCharacteristic(this.platform.characteristic.CurrentVisibilityState, visibilityState) .setCharacteristic(this.platform.characteristic.InputDeviceType, this.platform.characteristic.InputDeviceType.OTHER) .setCharacteristic(this.platform.characteristic.TargetVisibilityState, visibilityState) .setCharacteristic(this.platform.characteristic.Identifier, AVADA_KEDAVRA_IDENTIFIER); this.avadaKedavraService.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); const oldValue = this.avadaKedavraService.getCharacteristic(this.platform.characteristic.ConfiguredName).value; if (oldValue === value) { return; } if (oldValue !== '') { this.log.info(`Changing configured name of Avada Kedavra from ${oldValue} to ${value}.`); } this.setCommonConfig('avadaKedavraName', value.toString()); }) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return this.avadaKedavraService.getCharacteristic(this.platform.characteristic.ConfiguredName).value; }); this.avadaKedavraService.getCharacteristic(this.platform.characteristic.TargetVisibilityState) .onSet(async (value) => { const current = this.avadaKedavraService.getCharacteristic(this.platform.characteristic.TargetVisibilityState).value; this.log.info(`Changing visibility state of Avada Kedavra from ${current} to ${value}.`); this.avadaKedavraService.updateCharacteristic(this.platform.characteristic.CurrentVisibilityState, value); this.setCommonConfig('showAvadaKedavra', value); }); this.service.addLinkedService(this.avadaKedavraService); } createCustomPyatvCommandSwitches(commandConfigs) { for (const commandConfig of commandConfigs) { const name = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(commandConfig.name), 64); this.log.debug(`Adding custom PyATV command ${name} as a switch.`); const s = this.accessory.getService(name) || this.addServiceSave(this.platform.service.Switch, name, `custom-pyatv-command-${name.replace(' ', '-')}`); s.addOptionalCharacteristic(this.platform.characteristic.ConfiguredName); s .setCharacteristic(this.platform.characteristic.Name, name) .setCharacteristic(this.platform.characteristic.ConfiguredName, name) .setCharacteristic(this.platform.characteristic.On, false); s.getCharacteristic(this.platform.characteristic.On) .onSet(async (value) => { if (value === true) { this.log.info(`Triggered custom PyATV command ${commandConfig.name}`); this.rocketRemote?.sendCommand(commandConfig.command, false, true); setTimeout(() => { s.updateCharacteristic(this.platform.characteristic.On, false); }, 700); } }) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return false; }); this.service.addLinkedService(s); this.customPyatvCommandServices[name] = s; } } createDeviceStateSensors() { const deviceStates = Object.keys(node_pyatv_1.NodePyATVDeviceState); for (const deviceState of deviceStates) { if (this.config.deviceStates === undefined || this.config.deviceStates.includes(deviceState) === false) { continue; } const name = (0, utils_1.capitalizeFirstLetter)(deviceState); const configuredName = this.getDeviceStateConfigs()[deviceState] ?? name; this.log.debug(`Adding device state ${deviceState} as a motion sensor. (named: ${configuredName})`); const s = this.accessory.getService(name) || this.addServiceSave(this.platform.service.MotionSensor, name, deviceState); s.addOptionalCharacteristic(this.platform.characteristic.ConfiguredName); s .setCharacteristic(this.platform.characteristic.MotionDetected, false) .setCharacteristic(this.platform.characteristic.Name, name) .setCharacteristic(this.platform.characteristic.ConfiguredName, configuredName); s.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); const oldConfiguredName = s.getCharacteristic(this.platform.characteristic.ConfiguredName).value; if (oldConfiguredName === value) { return; } if (oldConfiguredName !== '') { this.log.info(`Changing configured name of device state sensor ${deviceState} from ${oldConfiguredName} to \ ${value}.`); } this.setDeviceStateConfig(deviceState, value.toString()); }); s.getCharacteristic(this.platform.characteristic.MotionDetected) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return s.getCharacteristic(this.platform.characteristic.MotionDetected).value; }); this.service.addLinkedService(s); this.deviceStateServices[deviceState] = s; } } createHomeInput() { const visibilityState = this.getCommonConfig().showHomeInput === this.platform.characteristic.CurrentVisibilityState.SHOWN ? this.platform.characteristic.CurrentVisibilityState.SHOWN : this.platform.characteristic.CurrentVisibilityState.HIDDEN; const configuredName = this.getCommonConfig().homeInputName ?? 'Home'; this.log.debug(`Adding Home as an input. (named: ${configuredName})`); this.homeInputService = this.accessory.getService('HomeInput') || this.addServiceSave(this.platform.service.InputSource, 'HomeInput', 'homeInput') .setCharacteristic(this.platform.characteristic.ConfiguredName, configuredName) .setCharacteristic(this.platform.characteristic.InputSourceType, this.platform.characteristic.InputSourceType.OTHER) .setCharacteristic(this.platform.characteristic.IsConfigured, this.platform.characteristic.IsConfigured.CONFIGURED) .setCharacteristic(this.platform.characteristic.Name, 'Home') .setCharacteristic(this.platform.characteristic.CurrentVisibilityState, visibilityState) .setCharacteristic(this.platform.characteristic.InputDeviceType, this.platform.characteristic.InputDeviceType.OTHER) .setCharacteristic(this.platform.characteristic.TargetVisibilityState, visibilityState) .setCharacteristic(this.platform.characteristic.Identifier, HOME_IDENTIFIER); this.homeInputService.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); const oldValue = this.homeInputService.getCharacteristic(this.platform.characteristic.ConfiguredName).value; if (oldValue === value) { return; } if (oldValue !== '') { this.log.info(`Changing configured name of Home Input from ${oldValue} to ${value}.`); } this.setCommonConfig('homeInputName', value.toString()); }) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return this.homeInputService.getCharacteristic(this.platform.characteristic.ConfiguredName).value; }); this.homeInputService.getCharacteristic(this.platform.characteristic.TargetVisibilityState) .onSet(async (value) => { const current = this.homeInputService.getCharacteristic(this.platform.characteristic.TargetVisibilityState).value; this.log.info(`Changing visibility state of Home Input from ${current} to ${value}.`); this.homeInputService.updateCharacteristic(this.platform.characteristic.CurrentVisibilityState, value); this.setCommonConfig('showHomeInput', value); }); this.service.addLinkedService(this.homeInputService); } createInputs(apps, customURIs) { const appsAndCustomInputs = [ ...customURIs.map((uri) => { return { id: uri, name: uri }; }), ...apps, ]; const appConfigs = this.getAppConfigs(); appsAndCustomInputs.forEach((app) => { if (!Object.keys(appConfigs).includes(app.id)) { appConfigs[app.id] = { configuredName: DEFAULT_APP_RENAME[app.id] || (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(app.name), 64), isConfigured: this.platform.characteristic.IsConfigured.CONFIGURED, visibilityState: HIDE_BY_DEFAULT_APPS.includes(app.id) ? this.platform.characteristic.CurrentVisibilityState.HIDDEN : this.platform.characteristic.CurrentVisibilityState.SHOWN, identifier: this.appIdToNumber(app.id), }; } }); this.setAppConfigs(appConfigs); appsAndCustomInputs.sort((a, b) => { if (customURIs.includes(a.id) === customURIs.includes(b.id)) { return appConfigs[a.id].configuredName > appConfigs[b.id].configuredName ? 1 : -1; } else { return customURIs.includes(a.id) ? 1 : -1; } }); let addedApps = 0; appsAndCustomInputs.slice().reverse().every((app) => { this.log.debug(`Adding ${app.id} as an input. (named: ${appConfigs[app.id].configuredName})`); const name = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(app.name), 64); const s = this.accessory.getService(name) || this.addServiceSave(this.platform.service.InputSource, name, app.id); if (s === undefined) { this.log.warn(`\nThe maximum of ${MAX_SERVICES} services on a single accessory is reached. \ The following services have been added: - 01 One service for Accessory Information - 01 The television service (Apple TV) itself - 01 Television speaker service to control the volume with the iOS remote - ${this.config.absoluteVolumeControl === true ? '01' : '00'} Fans for volume control - ${Object.keys(this.deviceStateServices).length.toString().padStart(2, '0')} motion sensors for device states - ${Object.keys(this.mediaTypeServices).length.toString().padStart(2, '0')} motion sensors for media types - ${Object.keys(this.remoteKeyServices).length.toString().padStart(2, '0')} switches for remote keys - 01 Avada Kedavra as an input - 01 Home as an input - 01 AirPlay as an dynamic input - ${(this.config.customPyatvCommands ?? '0').length.toString().padStart(2, '0')} switches for custom PyATV commands - ${addedApps.toString().padStart(2, '0')} apps as inputs have been added (${apps.length - addedApps} apps could not be added; including \ custom Inputs) It might be a good idea to uninstall unused apps.`); return false; } s.setCharacteristic(this.platform.characteristic.ConfiguredName, appConfigs[app.id].configuredName) .setCharacteristic(this.platform.characteristic.InputSourceType, this.platform.characteristic.InputSourceType.APPLICATION) .setCharacteristic(this.platform.characteristic.IsConfigured, appConfigs[app.id].isConfigured) .setCharacteristic(this.platform.characteristic.Name, name) .setCharacteristic(this.platform.characteristic.CurrentVisibilityState, appConfigs[app.id].visibilityState) .setCharacteristic(this.platform.characteristic.InputDeviceType, this.platform.characteristic.InputDeviceType.OTHER) .setCharacteristic(this.platform.characteristic.TargetVisibilityState, appConfigs[app.id].visibilityState) .setCharacteristic(this.platform.characteristic.Identifier, appConfigs[app.id].identifier); s.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); if (appConfigs[app.id].configuredName === value) { return; } this.log.info(`Changing configured name of ${app.id} from ${appConfigs[app.id].configuredName} to ${value}.`); appConfigs[app.id].configuredName = value; this.setAppConfigs(appConfigs); }) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return appConfigs[app.id].configuredName; }); s.getCharacteristic(this.platform.characteristic.IsConfigured) .onSet(async (value) => { this.log.info(`Changing is configured of ${appConfigs[app.id].configuredName} (${app.id}) \ from ${appConfigs[app.id].isConfigured} to ${value}.`); appConfigs[app.id].isConfigured = value; this.setAppConfigs(appConfigs); }); s.getCharacteristic(this.platform.characteristic.TargetVisibilityState) .onSet(async (value) => { this.log.info(`Changing visibility state of ${appConfigs[app.id].configuredName} (${app.id}) \ from ${appConfigs[app.id].visibilityState} to ${value}.`); appConfigs[app.id].visibilityState = value; s.updateCharacteristic(this.platform.characteristic.CurrentVisibilityState, value); this.setAppConfigs(appConfigs); }); this.service.addLinkedService(s); this.inputs[app.id] = s; addedApps++; return true; }); const appOrderIdentifiers = appsAndCustomInputs.slice(appsAndCustomInputs.length - addedApps).map((e) => appConfigs[e.id].identifier); const appOrderIdentifiersWithAvadaKedavra = [AVADA_KEDAVRA_IDENTIFIER, HOME_IDENTIFIER].concat(appOrderIdentifiers); const tlv8 = this.appIdentifiersOrderToTLV8(appOrderIdentifiersWithAvadaKedavra); this.log.debug(`Input display order: ${tlv8}`); this.service.setCharacteristic(this.platform.characteristic.DisplayOrder, tlv8); } createListeners() { this.log.debug('recreating listeners'); const filterErrorHandler = (event, listener) => { if (!(event instanceof Error)) { if (this.offline && event.value !== null) { this.log.success('Reestablished the connection'); this.offline = false; } this.log.debug(`event ${event.key}: ${event.value}`); void listener(event); } }; const powerStateListener = (e) => { filterErrorHandler(e, this.handleActiveUpdate.bind(this)); }; const appIdListener = (e) => { filterErrorHandler(e, this.handleInputUpdate.bind(this)); }; const appListener = (e) => { filterErrorHandler(e, this.airPlayInputUpdateName.bind(this)); }; const deviceStateListener = (e) => { filterErrorHandler(e, this.handleDeviceStateUpdate.bind(this)); }; const mediaTypeListener = (e) => { filterErrorHandler(e, this.handleMediaTypeUpdate.bind(this)); }; const volumeListener = (e) => { filterErrorHandler(e, this.handleVolumeUpdate.bind(this)); }; const pyatvCharacteristicListener = (e, characteristicID) => { filterErrorHandler(e, this.handlePyatvCharacteristicUpdate.bind(this, characteristicID)); }; this.device.on('update:powerState', powerStateListener); this.device.on('update:appId', appIdListener); this.device.on('update:app', appListener); this.device.on('update:deviceState', deviceStateListener); this.device.on('update:mediaType', mediaTypeListener); this.device.on('update:volume', volumeListener); for (const characteristicID of Object.values(enums_1.PyATVCustomCharacteristicID)) { const handler = (e) => { pyatvCharacteristicListener(e, characteristicID); }; this.pyatvListenerHandlers[characteristicID] = handler; this.device.on(`update:${characteristicID}`, handler); } this.device.once('error', ((e) => { this.log.debug(e); this.offline = true; this.log.warn('Lost connection. Trying to reconnect ...'); this.device.removeListener('update:powerState', powerStateListener); this.device.removeListener('update:appId', appIdListener); this.device.removeListener('update:app', appListener); this.device.removeListener('update:deviceState', deviceStateListener); this.device.removeListener('update:mediaType', mediaTypeListener); this.device.removeListener('update:volume', volumeListener); for (const characteristic in this.pyatvListenerHandlers) { this.device.removeListener(`update:${characteristic}`, this.pyatvListenerHandlers[characteristic]); } const credentials = this.getCredentials(); this.device = CustomPyAtvInstance_1.default.deviceAdvanced({ mac: this.device.mac, airplayCredentials: credentials, companionCredentials: credentials, }) || this.device; this.log.debug(`New internal device: ${this.device}`); setTimeout(this.createListeners.bind(this), 5000); }).bind(this)); } createMediaTypeSensors() { const mediaTypes = Object.keys(node_pyatv_1.NodePyATVMediaType); for (const mediaType of mediaTypes) { if (this.config.mediaTypes === undefined || this.config.mediaTypes.includes(mediaType) === false) { continue; } const name = (0, utils_1.capitalizeFirstLetter)(mediaType); const configuredName = this.getMediaConfigs()[mediaType] ?? name; this.log.debug(`Adding media type ${mediaType} as a motion sensor. (named: ${configuredName})`); const s = this.accessory.getService(name) || this.addServiceSave(this.platform.service.MotionSensor, name, mediaType); s.addOptionalCharacteristic(this.platform.characteristic.ConfiguredName); s .setCharacteristic(this.platform.characteristic.MotionDetected, false) .setCharacteristic(this.platform.characteristic.Name, name) .setCharacteristic(this.platform.characteristic.ConfiguredName, configuredName); s.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); const oldConfiguredName = s.getCharacteristic(this.platform.characteristic.ConfiguredName).value; if (oldConfiguredName === value) { return; } if (oldConfiguredName !== '') { this.log.info(`Changing configured name of media type sensor ${mediaType} from ${oldConfiguredName} to ${value}.`); } this.setMediaTypeConfig(mediaType, value); }); s.getCharacteristic(this.platform.characteristic.MotionDetected) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return s.getCharacteristic(this.platform.characteristic.MotionDetected).value; }); this.service.addLinkedService(s); this.mediaTypeServices[mediaType] = s; } } async createPyATVCharacteristics() { for (const pyatvChar of Object.values(enums_1.PyATVCustomCharacteristicID)) { const characteristic = this.service.addCharacteristic((0, Characteristics_1.newPyatvCharacteristic)(this.platform.api.hap, pyatvChar)); this.pyatvCharacteristics[pyatvChar] = characteristic; this.log.debug(`Adding custom characteristic ${characteristic.displayName}.`); switch (pyatvChar) { case enums_1.PyATVCustomCharacteristicID.ALBUM: characteristic.updateValue(await this.device.getAlbum() ?? null); break; case enums_1.PyATVCustomCharacteristicID.ARTIST: characteristic.updateValue(await this.device.getArtist() ?? null); break; case enums_1.PyATVCustomCharacteristicID.CONTENT_IDENTIFIER: characteristic.updateValue(await this.device.getContentIdentifier() ?? null); break; case enums_1.PyATVCustomCharacteristicID.EPISODE_NUMBER: characteristic.updateValue(await this.device.getEpisodeNumber() ?? null); break; case enums_1.PyATVCustomCharacteristicID.GENRE: characteristic.updateValue(await this.device.getGenre() ?? null); break; case enums_1.PyATVCustomCharacteristicID.ITUNES_STORE_IDENTIFIER: characteristic.updateValue(await this.device.getITunesStoreIdentifier() ?? null); break; case enums_1.PyATVCustomCharacteristicID.OUTPUT_DEVICES: characteristic.updateValue(this.outputDevicesToString(await this.device.getOutputDevices()) ?? null); break; case enums_1.PyATVCustomCharacteristicID.POSITION: characteristic.updateValue(await this.device.getPosition() ?? null); break; case enums_1.PyATVCustomCharacteristicID.REPEAT: characteristic.updateValue(await this.device.getRepeat() ?? null); break; case enums_1.PyATVCustomCharacteristicID.SEASON_NUMBER: characteristic.updateValue(await this.device.getSeasonNumber() ?? null); break; case enums_1.PyATVCustomCharacteristicID.SERIES_NAME: characteristic.updateValue(await this.device.getSeriesName() ?? null); break; case enums_1.PyATVCustomCharacteristicID.SHUFFLE: characteristic.updateValue(await this.device.getShuffle() ?? null); break; case enums_1.PyATVCustomCharacteristicID.TITLE: characteristic.updateValue(await this.device.getTitle() ?? null); break; case enums_1.PyATVCustomCharacteristicID.TOTAL_TIME: characteristic.updateValue(await this.device.getTotalTime() ?? null); break; } if (characteristic.value !== '' && characteristic.value !== null) { this.log.info(`Setting characteristic ${characteristic.displayName} to "${characteristic.value}".`); } else { this.log.debug(`Setting characteristic ${characteristic.displayName} to "${characteristic.value}".`); } } } createRemote() { this.log.debug('recreating rocket remote'); this.rocketRemote = new RocketRemote_1.default(this.device.mac, CustomPyAtvInstance_1.default.getAtvremotePath(), this.getCredentials(), this.getCredentials(), this.log, this.config.avadaKedavraAppAmount ?? 15); this.rocketRemote.onHome((() => { this.service.updateCharacteristic(this.platform.characteristic.ActiveIdentifier, HOME_IDENTIFIER); }).bind(this)); this.rocketRemote.onClose((async () => { await (0, utils_1.delay)(5000); this.createRemote(); }).bind(this)); } createRemoteKeysAsSwitches() { const remoteKeys = Object.values(enums_1.RocketRemoteKey); for (const remoteKey of remoteKeys) { if (this.config.remoteKeysAsSwitch === undefined || this.config.remoteKeysAsSwitch.includes(remoteKey) === false) { continue; } const name = (0, utils_1.snakeCaseToTitleCase)(remoteKey); const configuredName = this.getRemoteKeyAsSwitchConfigs()[remoteKey] ?? name; this.log.debug(`Adding remote key ${remoteKey} as a switch. (named: ${configuredName})`); const s = this.accessory.getService(name) || this.addServiceSave(this.platform.service.Switch, name, remoteKey); s.addOptionalCharacteristic(this.platform.characteristic.ConfiguredName); s .setCharacteristic(this.platform.characteristic.Name, name) .setCharacteristic(this.platform.characteristic.ConfiguredName, configuredName) .setCharacteristic(this.platform.characteristic.On, false); s.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); const oldConfiguredName = s.getCharacteristic(this.platform.characteristic.ConfiguredName).value; if (oldConfiguredName === value) { return; } if (oldConfiguredName !== '') { this.log.info(`Changing configured name of remote key switch ${remoteKey} from ${oldConfiguredName} to ${value}.`); } this.setRemoteKeyAsSwitchConfig(remoteKey, value); }); s.getCharacteristic(this.platform.characteristic.On) .onSet(async (value) => { if (value === true) { this.rocketRemote?.sendCommand(remoteKey); setTimeout(() => { s.updateCharacteristic(this.platform.characteristic.On, false); }, 200); } }) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return false; }); this.service.addLinkedService(s); this.remoteKeyServices[remoteKey] = s; } } createTelevisionSpeaker() { this.log.debug('Adding television speaker.'); this.televisionSpeakerService = this.accessory.getService('televisionSpeaker') || this.addServiceSave(this.platform.service.TelevisionSpeaker, 'televisionSpeaker', 'televisionSpeaker'); this.televisionSpeakerService.setCharacteristic(this.platform.characteristic.Active, this.platform.characteristic.Active.ACTIVE); this.televisionSpeakerService.setCharacteristic(this.platform.characteristic.Mute, false); if (this.config.disableVolumeControlRemote !== true) { this.televisionSpeakerService.setCharacteristic(this.platform.characteristic.VolumeControlType, this.platform.characteristic.VolumeControlType.RELATIVE); this.televisionSpeakerService.getCharacteristic(this.platform.characteristic.VolumeSelector) .onSet(async (value) => { if (value === this.platform.characteristic.VolumeSelector.INCREMENT) { this.rocketRemote?.volumeUp(); } else { this.rocketRemote?.volumeDown(); } }); this.televisionSpeakerService.getCharacteristic(this.platform.characteristic.Mute) .onSet(async (value) => { if (value === true) { this.unmute(); } else { this.mute(); } }); } this.service.addLinkedService(this.televisionSpeakerService); } async createVolumeFan() { if (this.config.absoluteVolumeControl !== true) { this.log.debug('Adding no fan for volume control as it has not been configured on this Apple TV.'); return; } this.log.debug('Adding fan for volume control.'); const volTmp = (await this.device.getState({ maxAge: 600000 })).volume; // TTL 10min const vol = volTmp !== null ? volTmp : 50; const name = 'Volume'; const configuredName = this.getCommonConfig().volumeFanName ?? name; this.volumeFanService = this.accessory.getService(name) || this.addServiceSave(this.platform.service.Fanv2, name, 'fanVolumeControl'); this.volumeFanService.addOptionalCharacteristic(this.platform.characteristic.ConfiguredName); this.volumeFanService.setCharacteristic(this.platform.characteristic.Name, name); this.volumeFanService.setCharacteristic(this.platform.characteristic.ConfiguredName, configuredName); this.volumeFanService.getCharacteristic(this.platform.characteristic.ConfiguredName) .onSet(async (value) => { if (value === '') { return; } value = (0, utils_1.trimToMaxLength)((0, utils_1.removeSpecialCharacters)(value.toString()), 64); const oldValue = this.volumeFanService.getCharacteristic(this.platform.characteristic.ConfiguredName).value; if (oldValue === value) { return; } this.log.info(`Changing configured name of Volume Fan from ${oldValue} to ${value}.`); this.setCommonConfig('volumeFanName', value); }) .onGet(async () => { if (this.offline) { throw new this.platform.api.hap.HapStatusError(-70402 /* this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */); } return this.volumeFanService.getCharacteristic(this.platform.characteristic.ConfiguredName).value; }); this.volumeFanService.setCharacteristic(this.platform.characteristic.Active, vol !== 0 ? this.platform.characteristic.Active.ACTIVE : this.platform.characteristic.Active.INACTIVE); this.volumeFanService.getCharacteristic(this.platform.characteristic.Active) .onSet(async (value) => { if (value === this.platform.characteristic.Active.ACTIVE) { this.unmute(); } else { this.mute(); } }); this.volumeFanService.setCharacteristic(this.platform.characteristic.RotationSpeed, vol); this.volumeFanService.getCharacteristic(this.platform.characteristic.RotationSpeed) .onSet(async (value) => { this.log.info(`Setting volume to ${value}%`); this.rocketRemote?.setVolume(value, true); }); this.service.addLinkedService(this.volumeFanService); } async credentialsValid() { if (this.getCredentials() === undefined) { return false; } for (let i = 0; i < 5; i++) { this.log.info('verifying credentials ...'); try { await this.device.listApps(); return true; } catch (error) { if (error instanceof Error && error.message.includes('pyatv.exceptions.ProtocolError: Command _systemInfo failed')) { this.log.debug(error.message); this.log.debug(error.stack); continue; } if (error instanceof Error && error.message.includes('asyncio.exceptions.CancelledError') && error.message.includes('TimeoutError')) { this.log.debug(error.message); this.log.debug(error.stack); while (true) { this.log.error('The plugin is receiving errors that look like you have not set the access level of Speakers & TVs \ in your home app to "Everybody" or "Anybody On the Same Network" with no password. Fix this and restart the plugin to continue \ initializing the Apple TV device. Additionally, make sure to check the TV\'s HomeKit settings. Enable debug logging to see the original \ errors.'); await (0, utils_1.delay)(300000); } } if (error instanceof Error && error.message.includes('Could not find any Apple TV on current network')) { this.log.debug(error.message); this.log.debug(error.stack); while (true) { this.log.error('Apple TV could not be reached on your network. This is likely a network problem. Restart the \ plugin after you have fixed the root cause. Enable debug logging to see the original errors.'); await (0, utils_1.delay)(300000); } } if (error instanceof Error) { this.log.error(error.message); this.log.debug(error.stack); while (true) { await (0, utils_1.delay)(300000); } } throw error; } } return false; } getAppConfigs() { if (this.appConfigs === undefined) { const jsonPath = this.getPath('apps.json'); this.log.debug(`Loading app config from ${jsonPath}`); try { this.appConfigs = JSON.parse(fs_1.default.readFileSync(jsonPath, 'utf8')); } catch (err) { if (err instanceof Error && err.name === 'SyntaxError') { this.log.warn(`The file ${jsonPath} does not contain a valid JSON. Resetting to its defaults ...`); this.setAppConfigs({}); return {}; } else { throw err; } } } return this.appConfigs; } getCommonConfig() { if (this.commonConfig === undefined) { const jsonPath = this.getPath('common.json'); this.log.debug(`Loading common config from ${jsonPath}`); try { this.commonConfig = JSON.parse(fs_1.default.readFileSync(jsonPath, 'utf8')); } catch (err) { if (err instanceof Error && err.name === 'SyntaxError') { this.log.warn(`The file ${jsonPath} does not contain a valid JSON. Resetting to its defaults ...`); this.commonConfig = {}; } else { throw err; } } } return this.commonConfig; } getCredentials() { if (this.credentials === undefined) { const path = this.getPath('credentials.txt', ''); const fileContent = fs_1.default.readFileSync(path, 'utf8').trim(); this.credentials = fileContent === '' ? undefined : fileContent; this.log.debug(`Loaded credentials: ${this.credentials}`); } return this.credentials; } getDeviceStateConfigs() { if (this.deviceStateConfigs === undefined) { const jsonPath = this.getPath('deviceStates.json'); this.log.debug(`Loading device states config from ${jsonPath}`); try { this.deviceStateConfigs = JSON.parse(fs_1.default.readFileSync(jsonPath, 'utf8')); } catch (err) { if (err instanceof Error && err.name === 'SyntaxError') { this.log.warn(`The file ${jsonPath} does not contain a valid JSON. Resetting to its defaults ...`); this.deviceStateConfigs = {}; } else { throw err; } } } return this.deviceStateConfigs; } getMediaConfigs() { if (this.mediaConfigs === undefined) { const jsonPath = this.getPath('mediaTypes.json'); this.log.debug(`Loading media types config from ${jsonPath}`); try { this.mediaConfigs = JSON.parse(fs_1.default.readFileSync(jsonPath, 'utf8')); } catch (err) { if (err instanceof Error && err.name === '