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
JavaScript
"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 === '