homebridge-denon-tv
Version:
Homebridge plugin to control Denon/Marantz AV Receivers.
534 lines (470 loc) • 26 kB
JavaScript
import { promises as fsPromises } from 'fs';
import EventEmitter from 'events';
import Denon from './denon.js';
let Accessory, Characteristic, Service, Categories, Encode, AccessoryUUID;
class MainZone extends EventEmitter {
constructor(api, device, name, host, port, generation, zone, devInfoFile, inputsFile, inputsNamesFile, inputsTargetVisibilityFile) {
super();
Accessory = api.platformAccessory;
Characteristic = api.hap.Characteristic;
Service = api.hap.Service;
Categories = api.hap.Categories;
Encode = api.hap.encode;
AccessoryUUID = api.hap.uuid;
//device configuration
this.name = name;
this.host = host;
this.port = port;
this.generation = generation;
this.zone = zone;
this.inputsDisplayOrder = device.inputsDisplayOrder || 0;
this.inputs = device.passThroughInputs || [];
this.sensorInput = device.sensorInput || false;
this.sensorInputs = device.sensorInputs || [];
this.infoButtonCommand = device.infoButtonCommand || 'MNINF';
this.refreshInterval = device.refreshInterval * 1000 || 5000;
this.enableDebugMode = device.enableDebugMode || false;
this.disableLogInfo = device.disableLogInfo || false;
this.devInfoFile = devInfoFile;
this.inputsFile = inputsFile;
this.inputsNamesFile = inputsNamesFile;
this.inputsTargetVisibilityFile = inputsTargetVisibilityFile;
//sensors
this.sensorsInputsConfigured = [];
for (const sensor of this.sensorInputs) {
const sensorInputName = sensor.name ?? false;
const sensorInputReference = sensor.reference ?? false;
const sensorInputDisplayType = sensor.displayType ?? 0;
if (sensorInputName && sensorInputReference && sensorInputDisplayType > 0) {
sensor.serviceType = ['', Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor][sensorInputDisplayType];
sensor.characteristicType = ['', Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState][sensorInputDisplayType];
sensor.state = false;
this.sensorsInputsConfigured.push(sensor);
} else {
const log = sensorInputDisplayType === 0 ? false : this.emit('info', `Sensor Name: ${sensorInputName ? sensorInputName : 'Missing'}, Reference: ${sensorInputReference ? sensorInputReference : 'Missing'}`);
};
}
this.sensorsInputsConfiguredCount = this.sensorsInputsConfigured.length || 0;
//variable
this.startPrepareAccessory = true;
this.allServices = [];
this.inputsConfigured = [];
this.inputIdentifier = 1;
this.power = false;
this.reference = '';
this.mediaState = false;
this.sensorInputState = false;
};
async saveData(path, data) {
try {
data = JSON.stringify(data, null, 2);
await fsPromises.writeFile(path, data);
const debug = this.enableDebugLog ? this.emit('debug', `Saved data: ${data}`) : false;
return true;
} catch (error) {
throw new Error(`Save data error: ${error}`);
};
}
async readData(path) {
try {
const data = await fsPromises.readFile(path);
return data;
} catch (error) {
throw new Error(`Read data error: ${error}`);
};
}
async sanitizeString(str) {
// Replace dots, colons, and semicolons inside words with a space
str = str.replace(/(\w)[.:;]+(\w)/g, '$1 $2');
// Remove remaining dots, colons, semicolons, plus, and minus anywhere in the string
str = str.replace(/[.:;+\-]/g, '');
// Replace all other invalid characters (anything not A-Z, a-z, 0-9, space, or apostrophe) with a space
str = str.replace(/[^A-Za-z0-9 ']/g, ' ');
// Trim leading and trailing spaces
str = str.trim();
return str;
}
async displayOrder() {
try {
switch (this.inputsDisplayOrder) {
case 0:
this.inputsConfigured.sort((a, b) => a.identifier - b.identifier);
break;
case 1:
this.inputsConfigured.sort((a, b) => a.name.localeCompare(b.name));
break;
case 2:
this.inputsConfigured.sort((a, b) => b.name.localeCompare(a.name));
break;
case 3:
this.inputsConfigured.sort((a, b) => a.reference.localeCompare(b.reference));
break;
case 4:
this.inputsConfigured.sort((a, b) => b.reference.localeCompare(a.reference));
break;
}
const debug = !this.enableDebugMode ? false : this.emit('debug', `Inputs display order: ${JSON.stringify(this.inputsConfigured, null, 2)}`);
const displayOrder = this.inputsConfigured.map(input => input.identifier);
this.televisionService.setCharacteristic(Characteristic.DisplayOrder, Encode(1, displayOrder).toString('base64'));
return true;
} catch (error) {
throw new Error(`Display order error: ${error}`);
};
}
async startImpulseGenerator() {
try {
//start impulse generator
await this.denon.impulseGenerator.start([{ name: 'checkState', sampling: this.refreshInterval }]);
return true;
} catch (error) {
throw new Error(`Impulse generator start error: ${error}`);
};
}
async prepareDataForAccessory() {
try {
//read inputs file
const savedInputs = await this.readData(this.inputsFile);
this.savedInputs = savedInputs.toString().trim() !== '' ? JSON.parse(savedInputs) : this.inputs;
const debug = this.enableDebugMode ? this.emit('debug', `Read saved Inputs: ${JSON.stringify(this.savedInputs, null, 2)}`) : false;
//read inputs names from file
const savedInputsNames = await this.readData(this.inputsNamesFile);
this.savedInputsNames = savedInputsNames.toString().trim() !== '' ? JSON.parse(savedInputsNames) : {};
const debug1 = !this.enableDebugMode ? false : this.emit('debug', `Read saved Inputs Names: ${JSON.stringify(this.savedInputsNames, null, 2)}`);
//read inputs visibility from file
const savedInputsTargetVisibility = await this.readData(this.inputsTargetVisibilityFile);
this.savedInputsTargetVisibility = savedInputsTargetVisibility.toString().trim() !== '' ? JSON.parse(savedInputsTargetVisibility) : {};
const debug2 = !this.enableDebugMode ? false : this.emit('debug', `Read saved Inputs Target Visibility: ${JSON.stringify(this.savedInputsTargetVisibility, null, 2)}`);
return true;
} catch (error) {
throw new Error(`Prepare data for accessory error: ${error}`);
}
}
//prepare accessory
async prepareAccessory() {
try {
//accessory
const debug = !this.enableDebugMode ? false : this.emit('debug', `Prepare accessory`);
const accessoryName = this.name;
const accessoryUUID = AccessoryUUID.generate(this.serialNumber + this.zone);
const accessoryCategory = Categories.AUDIO_RECEIVER;
const accessory = new Accessory(accessoryName, accessoryUUID, accessoryCategory);
//information service
const debug1 = !this.enableDebugMode ? false : this.emit('debug', `Prepare information service`);
this.informationService = accessory.getService(Service.AccessoryInformation)
.setCharacteristic(Characteristic.Manufacturer, this.manufacturer)
.setCharacteristic(Characteristic.Model, this.modelName)
.setCharacteristic(Characteristic.SerialNumber, this.serialNumber)
.setCharacteristic(Characteristic.FirmwareRevision, this.firmwareRevision)
.setCharacteristic(Characteristic.ConfiguredName, accessoryName);
this.allServices.push(this.informationService);
//prepare television service
const debug2 = !this.enableDebugMode ? false : this.emit('debug', `Prepare television service`);
this.televisionService = accessory.addService(Service.Television, `${accessoryName} Television`, 'Television');
this.televisionService.setCharacteristic(Characteristic.ConfiguredName, accessoryName);
this.televisionService.setCharacteristic(Characteristic.SleepDiscoveryMode, 1);
this.televisionService.getCharacteristic(Characteristic.Active)
.onGet(async () => {
const state = true;
return state;
})
.onSet(async (state) => {
try {
//const powerState = this.masterPower ? (state ? 'PWON' : 'PWSTANDBY') : (state ? 'ZMON' : 'ZMOFF');
//await this.denon.send(powerState);
//const info = this.disableLogInfo ? false : this.emit('info', `set Power: ${powerState}`);
} catch (error) {
this.emit('warn', `set Power error: ${error}`);
};
});
this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier)
.onGet(async () => {
const inputIdentifier = this.inputIdentifier;
return inputIdentifier;
})
.onSet(async (activeIdentifier) => {
try {
const input = this.inputsConfigured.find(input => input.identifier === activeIdentifier);
const inputName = input.name;
const inputMode = input.mode;
const inputReference = input.reference;
const reference = `${inputMode}${inputReference}`;
await this.denon.send(reference);
const info = this.disableLogInfo ? false : this.emit('info', `set Input Name: ${inputName}, Reference: ${reference}`);
} catch (error) {
this.emit('warn', `set Input error: ${error}`);
};
});
this.televisionService.getCharacteristic(Characteristic.RemoteKey)
.onSet(async (command) => {
try {
const rcMedia = this.inputReference === 'SPOTIFY' || this.inputReference === 'BT' || this.inputReference === 'USB/IPOD' || this.inputReference === 'NET' || this.inputReference === 'MPLAY';
switch (command) {
case Characteristic.RemoteKey.REWIND:
command = rcMedia ? 'NS9E' : 'MN9E';
break;
case Characteristic.RemoteKey.FAST_FORWARD:
command = rcMedia ? 'NS9D' : 'MN9D';
break;
case Characteristic.RemoteKey.NEXT_TRACK:
command = rcMedia ? 'MN9D' : 'MN9F';
break;
case Characteristic.RemoteKey.PREVIOUS_TRACK:
command = rcMedia ? 'MN9E' : 'MN9G';
break;
case Characteristic.RemoteKey.ARROW_UP:
command = rcMedia ? 'NS90' : 'MNCUP';
break;
case Characteristic.RemoteKey.ARROW_DOWN:
command = rcMedia ? 'NS91' : 'MNCDN';
break;
case Characteristic.RemoteKey.ARROW_LEFT:
command = rcMedia ? 'NS92' : 'MNCLT';
break;
case Characteristic.RemoteKey.ARROW_RIGHT:
command = rcMedia ? 'NS93' : 'MNENT';
break;
case Characteristic.RemoteKey.SELECT:
command = rcMedia ? 'NS94' : 'MNENT';
break;
case Characteristic.RemoteKey.BACK:
command = rcMedia ? 'MNRTN' : 'MNRTN';
break;
case Characteristic.RemoteKey.EXIT:
command = rcMedia ? 'MNRTN' : 'MNRTN';
break;
case Characteristic.RemoteKey.PLAY_PAUSE:
command = rcMedia ? (this.mediaState ? 'NS9B' : 'NS9A') : 'NS94';
this.mediaState = !this.mediaState;
break;
case Characteristic.RemoteKey.INFORMATION:
command = this.infoButtonCommand;
break;
}
await this.denon.send(command);
const info = this.disableLogInfo ? false : this.emit('info', `set Remote Key: ${command}`);
} catch (error) {
this.emit('warn', `set Remote Key error: ${error}`);
};
});
this.allServices.push(this.televisionService);
//prepare inputs service
const debug8 = !this.enableDebugMode ? false : this.emit('debug', `Prepare inputs services`);
//check possible inputs count (max 85)
const inputs = this.savedInputs;
const inputsCount = inputs.length;
const possibleInputsCount = 85 - this.allServices.length;
const maxInputsCount = inputsCount >= possibleInputsCount ? possibleInputsCount : inputsCount;
for (let i = 0; i < maxInputsCount; i++) {
//input
const input = inputs[i];
const inputIdentifier = i + 1;
//get input reference
const inputReference = input.reference;
//get input name
const name = input.name ?? `Input ${inputIdentifier}`;
//get input name
const savedInputsName = this.savedInputsNames[inputReference] ?? false;
input.name = savedInputsName ? savedInputsName.substring(0, 64) : name.substring(0, 64);
//get type
const inputSourceType = 0;
//get configured
const isConfigured = 1;
//get visibility
input.visibility = this.savedInputsTargetVisibility[inputReference] ?? 0;
//add identifier to the input
input.identifier = inputIdentifier;
//input service
const sanitizedName = await this.sanitizeString(input.name);
const inputService = accessory.addService(Service.InputSource, sanitizedName, `Input ${inputIdentifier}`);
inputService
.setCharacteristic(Characteristic.Identifier, inputIdentifier)
.setCharacteristic(Characteristic.Name, sanitizedName)
.setCharacteristic(Characteristic.IsConfigured, isConfigured)
.setCharacteristic(Characteristic.InputSourceType, inputSourceType)
.setCharacteristic(Characteristic.CurrentVisibilityState, input.visibility)
inputService.getCharacteristic(Characteristic.ConfiguredName)
.onGet(async () => {
return sanitizedName;
})
.onSet(async (value) => {
try {
input.name = value;
this.savedInputsNames[inputReference] = value;
await this.saveData(this.inputsNamesFile, this.savedInputsNames);
const debug = !this.enableDebugMode ? false : this.emit('debug', `Saved Input Name: ${value}, Reference: ${inputReference}`);
//sort inputs
const index = this.inputsConfigured.findIndex(input => input.reference === inputReference);
this.inputsConfigured[index].name = value;
await this.displayOrder();
} catch (error) {
this.emit('warn', `Save Input Name error: ${error}`);
}
});
inputService.getCharacteristic(Characteristic.TargetVisibilityState)
.onGet(async () => {
return input.visibility;
})
.onSet(async (state) => {
try {
input.visibility = state;
this.savedInputsTargetVisibility[inputReference] = state;
await this.saveData(this.inputsTargetVisibilityFile, this.savedInputsTargetVisibility);
const debug = !this.enableDebugMode ? false : this.emit('debug', `Saved Input: ${input.name} Target Visibility: ${state ? 'HIDEN' : 'SHOWN'}`);
} catch (error) {
this.emit('warn', `Save Input Target Visibility error: ${error}`);
}
});
this.inputsConfigured.push(input);
this.televisionService.addLinkedService(inputService);
this.allServices.push(inputService);
};
//prepare sonsor input service
if (this.sensorInput) {
const debug = !this.enableDebugMode ? false : this.emit('debug', `Prepare input sensor service`);
this.sensorInputService = accessory.addService(Service.ContactSensor, `${this.sZoneName} Input Sensor`, `Input Sensor`);
this.sensorInputService.addOptionalCharacteristic(Characteristic.ConfiguredName);
this.sensorInputService.setCharacteristic(Characteristic.ConfiguredName, `${accessoryName} Input Sensor`);
this.sensorInputService.getCharacteristic(Characteristic.ContactSensorState)
.onGet(async () => {
const state = this.sensorInputState;
return state;
});
this.allServices.push(this.sensorInputService);
};
//prepare sonsor inputs service
const possibleSensorInputsCount = 99 - this.allServices.length;
const maxSensorInputsCount = this.sensorsInputsConfiguredCount >= possibleSensorInputsCount ? possibleSensorInputsCount : this.sensorsInputsConfiguredCount;
if (maxSensorInputsCount > 0) {
const debug = !this.enableDebugMode ? false : this.emit('debug', `Prepare inputs sensors services`);
this.sensorsInputsServices = [];
for (let i = 0; i < maxSensorInputsCount; i++) {
//get sensor
const sensorInput = this.sensorsInputsConfigured[i];
//get sensor name
const sensorInputName = sensorInput.name;
//get sensor name prefix
const namePrefix = sensorInput.namePrefix || false;
//get service type
const serviceType = sensorInput.serviceType;
//get service type
const characteristicType = sensorInput.characteristicType;
const serviceName = namePrefix ? `${accessoryName} ${sensorInputName}` : sensorInputName;
const sensorInputService = new serviceType(serviceName, `Sensor ${i}`);
sensorInputService.addOptionalCharacteristic(Characteristic.ConfiguredName);
sensorInputService.setCharacteristic(Characteristic.ConfiguredName, serviceName);
sensorInputService.getCharacteristic(characteristicType)
.onGet(async () => {
const state = sensorInput.state
return state;
});
this.sensorsInputsServices.push(sensorInputService);
this.allServices.push(sensorInputService);
accessory.addService(sensorInputService);
}
};
//sort inputs list
await this.displayOrder();
return accessory;
} catch (error) {
throw new Error(error)
};
}
//start
async start() {
try {
//denon client
this.denon = new Denon({
host: this.host,
port: this.port,
generation: this.generation,
zone: this.zone,
inputs: this.inputs,
devInfoFile: this.devInfoFile,
inputsFile: this.inputsFile,
getInputsFromDevice: false,
getFavoritesFromDevice: false,
getQuickSmartSelectFromDevice: false,
enableDebugLog: this.enableDebugMode
});
this.denon.on('deviceInfo', (manufacturer, modelName, serialNumber, firmwareRevision, deviceZones, apiVersion, supportPictureMode) => {
this.emit('devInfo', `-------- ${this.name} --------`);
this.emit('devInfo', `Manufacturer: ${manufacturer}`);
this.emit('devInfo', `Model: ${modelName}`);
this.emit('devInfo', `Control: Pass Through Inputs`);
this.emit('devInfo', `----------------------------------`);
this.manufacturer = manufacturer;
this.modelName = modelName;
this.serialNumber = serialNumber;
this.firmwareRevision = firmwareRevision;
})
.on('stateChanged', async (power, reference, volume, volumeDisplay, mute, pictureMode) => {
const input = this.inputsConfigured.find(input => input.reference === reference) ?? false;
const inputIdentifier = input ? input.identifier : this.inputIdentifier;
if (this.televisionService) {
this.televisionService
.updateCharacteristic(Characteristic.Active, 1)
.updateCharacteristic(Characteristic.ActiveIdentifier, inputIdentifier)
}
if (this.sensorInputService && reference !== this.reference) {
for (let i = 0; i < 2; i++) {
const state = power ? [true, false][i] : false;
this.sensorInputService
.updateCharacteristic(Characteristic.ContactSensorState, state)
this.sensorInputState = state;
}
}
if (this.sensorsInputsServices) {
for (let i = 0; i < this.sensorsInputsConfiguredCount; i++) {
const sensorInput = this.sensorsInputsConfigured[i];
const state = power ? sensorInput.reference === reference : false;
sensorInput.state = state;
const characteristicType = sensorInput.characteristicType;
this.sensorsInputsServices[i]
.updateCharacteristic(characteristicType, state);
}
}
this.inputIdentifier = inputIdentifier;
this.power = power;
this.reference = reference;
if (!this.disableLogInfo) {
const name = input ? input.name : reference;
this.emit('info', `Power: ${power ? 'ON' : 'OFF'}`);
this.emit('info', `Pass Through Input Name: ${name}`);
this.emit('info', `Reference: ${reference}`);
};
})
.on('success', (success) => {
this.emit('success', success);
})
.on('info', (info) => {
this.emit('info', info);
})
.on('debug', (debug) => {
this.emit('debug', debug);
})
.on('warn', (warn) => {
this.emit('warn', warn);
})
.on('error', (error) => {
this.emit('error', error);
});
//connect to avr
const connect = await this.denon.connect();
if (!connect) {
return false;
}
//prepare data for accessory
await this.prepareDataForAccessory();
//prepare accessory
if (this.startPrepareAccessory) {
const accessory = await this.prepareAccessory();
this.emit('publishAccessory', accessory);
this.startPrepareAccessory = false;
}
return true;
} catch (error) {
throw new Error(`Start error: ${error}`);
};
};
};
export default MainZone;