UNPKG

homebridge-denon-tv

Version:

Homebridge plugin to control Denon/Marantz AV Receivers.

532 lines (464 loc) 26.6 kB
import EventEmitter from 'events'; import Zone from './zone.js'; import Functions from './functions.js'; let Accessory, Characteristic, Service, Categories, Encode, AccessoryUUID; class Surrounds extends EventEmitter { constructor(api, denon, denonInfo, device, 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.denon = denon; this.denonInfo = denonInfo; this.device = device; this.name = device.name; this.zoneControl = device.zoneControl; this.inputsDisplayOrder = device.surrounds?.displayOrder || 0; this.sensors = Array.isArray(device.sensors) ? (device.sensors ?? []).filter(sensor => (sensor.displayType ?? 0) > 0 && (sensor.mode ?? -1) >= 0) : []; this.logInfo = device.log?.info || false; this.logWarn = device.log?.warn || true; this.logDebug = device.log?.debug || false; this.infoButtonCommand = device.infoButtonCommand || 'MNINF'; this.devInfoFile = devInfoFile; this.inputsFile = inputsFile; this.inputsNamesFile = inputsNamesFile; this.inputsTargetVisibilityFile = inputsTargetVisibilityFile; //sensors for (const sensor of this.sensors) { sensor.serviceType = [null, Service.MotionSensor, Service.OccupancySensor, Service.ContactSensor][sensor.displayType]; sensor.characteristicType = [null, Characteristic.MotionDetected, Characteristic.OccupancyDetected, Characteristic.ContactSensorState][sensor.displayType]; sensor.state = false; } //variable this.functions = new Functions(); this.inputIdentifier = 1; this.power = false; this.reference = ''; this.volume = 0; this.volumeDisplay = false; this.mute = false; this.playState = false; this.sensorInputState = false; }; async prepareDataForAccessory() { try { //read dev info from file this.savedInfo = await this.functions.readData(this.devInfoFile, true) ?? {}; if (this.logDebug) this.emit('debug', `Read saved Info: ${JSON.stringify(this.savedInfo, null, 2)}`); //read inputs file this.savedInputs = await this.functions.readData(this.inputsFile, true) ?? []; if (this.logDebug) this.emit('debug', `Read saved Inputs: ${JSON.stringify(this.savedInputs, null, 2)}`); //read inputs names from file this.savedInputsNames = await this.functions.readData(this.inputsNamesFile, true) ?? {}; if (this.logDebug) this.emit('debug', `Read saved Inputs Names: ${JSON.stringify(this.savedInputsNames, null, 2)}`); //read inputs visibility from file this.savedInputsTargetVisibility = await this.functions.readData(this.inputsTargetVisibilityFile, true) ?? {}; if (this.logDebug) 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}`); } } async displayOrder() { try { const sortStrategies = { 1: (a, b) => a.name.localeCompare(b.name), 2: (a, b) => b.name.localeCompare(a.name), 3: (a, b) => a.reference.localeCompare(b.reference), 4: (a, b) => b.reference.localeCompare(a.reference), }; const sortFn = sortStrategies[this.inputsDisplayOrder]; // Sort only if a valid function exists if (sortFn) { this.inputsServices.sort(sortFn); } // Debug if (this.logDebug) { const orderDump = this.inputsServices.map(svc => ({ name: svc.name, reference: svc.reference, identifier: svc.identifier, })); this.emit('debug', `Inputs display order:\n${JSON.stringify(orderDump, null, 2)}`); } // Always update DisplayOrder characteristic, even for "none" const displayOrder = this.inputsServices.map(svc => svc.identifier); const encodedOrder = Encode(1, displayOrder).toString('base64'); this.televisionService.updateCharacteristic(Characteristic.DisplayOrder, encodedOrder); return; } catch (error) { throw new Error(`Display order error: ${error}`); } } async addRemoveOrUpdateInput(inputs, remove = false) { try { if (!this.inputsServices) return; let updated = false; for (const input of inputs) { if (this.inputsServices.length >= 85 && !remove) continue; const inputReference = input.reference; const savedName = this.savedInputsNames[inputReference] ?? input.name; const sanitizedName = await this.functions.sanitizeString(savedName); const inputMode = input.mode ?? 0; const inputZonePrefix = input.zonePrefix; const inputVisibility = this.savedInputsTargetVisibility[inputReference] ?? 0; if (remove) { const svc = this.inputsServices.find(s => s.reference === inputReference); if (svc) { if (this.logDebug) this.emit('debug', `Removing input: ${input.name}, reference: ${inputReference}`); this.accessory.removeService(svc); this.inputsServices = this.inputsServices.filter(s => s.reference !== inputReference); updated = true; } continue; } let inputService = this.inputsServices.find(s => s.reference === inputReference); if (inputService) { const nameChanged = inputService.name !== sanitizedName; if (nameChanged) { inputService.name = sanitizedName; inputService .updateCharacteristic(Characteristic.Name, sanitizedName) .updateCharacteristic(Characteristic.ConfiguredName, sanitizedName); if (this.logDebug) this.emit('debug', `Updated Input: ${input.name}, reference: ${inputReference}`); updated = true; } } else { const identifier = this.inputsServices.length + 1; inputService = this.accessory.addService(Service.InputSource, sanitizedName, `Input ${inputReference}`); inputService.identifier = identifier; inputService.reference = inputReference; inputService.name = sanitizedName; inputService.mode = inputMode; inputService.zonePrefix = inputZonePrefix; inputService.visibility = inputVisibility; inputService .setCharacteristic(Characteristic.Identifier, identifier) .setCharacteristic(Characteristic.Name, sanitizedName) .setCharacteristic(Characteristic.ConfiguredName, sanitizedName) .setCharacteristic(Characteristic.IsConfigured, 1) .setCharacteristic(Characteristic.InputSourceType, inputMode) .setCharacteristic(Characteristic.CurrentVisibilityState, inputVisibility) .setCharacteristic(Characteristic.TargetVisibilityState, inputVisibility); // ConfiguredName persistence inputService.getCharacteristic(Characteristic.ConfiguredName) .onSet(async (value) => { try { value = await this.functions.sanitizeString(value); inputService.name = value; this.savedInputsNames[inputReference] = value; await this.functions.saveData(this.inputsNamesFile, this.savedInputsNames); if (this.logDebug) this.emit('debug', `Saved Input: ${input.name}, reference: ${inputReference}`); await this.displayOrder(); } catch (error) { if (this.logWarn) this.emit('warn', `Save Input Name error: ${error}`); } }); // TargetVisibility persistence inputService.getCharacteristic(Characteristic.TargetVisibilityState) .onSet(async (state) => { try { inputService.visibility = state; this.savedInputsTargetVisibility[inputReference] = state; await this.functions.saveData(this.inputsTargetVisibilityFile, this.savedInputsTargetVisibility); if (this.logDebug) this.emit('debug', `Saved Input: ${input.name}, reference: ${inputReference}, target visibility: ${state ? 'HIDDEN' : 'SHOWN'}`); } catch (error) { if (this.logWarn) this.emit('warn', `Save Target Visibility error: ${error}`); } }); this.inputsServices.push(inputService); this.televisionService.addLinkedService(inputService); if (this.logDebug) this.emit('debug', `Added Input: ${input.name}, reference: ${inputReference}`); updated = true; } } // Only one time run if (updated) await this.displayOrder(); return true; } catch (error) { throw new Error(`Add/Remove/Update input error: ${error}`); } } //prepare accessory async prepareAccessory() { try { //accessory if (this.logDebug) this.emit('debug', `Prepare accessory`); const accessoryName = this.name; const accessoryUUID = AccessoryUUID.generate(this.savedInfo.serialNumber + this.zoneControl); const accessoryCategory = Categories.AUDIO_RECEIVER; const accessory = new Accessory(accessoryName, accessoryUUID, accessoryCategory); this.accessory = accessory; //information service if (this.logDebug) this.emit('debug', `Prepare information service`); this.informationService = accessory.getService(Service.AccessoryInformation) .setCharacteristic(Characteristic.Manufacturer, this.savedInfo.manufacturer) .setCharacteristic(Characteristic.Model, this.savedInfo.modelName) .setCharacteristic(Characteristic.SerialNumber, this.savedInfo.serialNumber) .setCharacteristic(Characteristic.FirmwareRevision, this.savedInfo.firmwareRevision); //prepare television service if (this.logDebug) 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 = this.power; return state; }) .onSet(async (state) => { try { //const powerState = this.masterPower ? (state ? 'PWON' : 'PWSTANDBY') : (state ? 'ZMON' : 'ZMOFF'); //await this.denon.send(powerState); //if (this.logInfo) this.emit('info', `set Power: ${powerState}`); } catch (error) { if (this.logWarn) 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.inputsServices.find(i => i.identifier === activeIdentifier); if (!input) { if (this.logWarn) this.emit('warn', `Input with identifier ${activeIdentifier} not found`); return; } const { zonePrefix, name, reference } = input; if (!this.power) { if (this.logDebug) this.emit('debug', `AVR is off, deferring input switch to '${activeIdentifier}'`); (async () => { for (let attempt = 0; attempt < 3; attempt++) { await new Promise(resolve => setTimeout(resolve, 4000)); // if AVR on if (this.power) { // if input didn't switch → retry command if (this.inputIdentifier !== activeIdentifier) { if (this.logDebug) this.emit('debug', `Retrying input switch (${attempt + 1}/3)`); await this.denon.send(`${zonePrefix}${reference}`); } else { // success this.televisionService.updateCharacteristic(Characteristic.ActiveIdentifier, activeIdentifier); if (this.logInfo) this.emit('info', `Input set successfully: ${name}`); return; } } } if (this.logWarn) this.emit('warn', `Failed to set input after retries: ${name}`); })().catch(err => { if (this.logWarn) this.emit('warn', `retry error: ${err}`); }); return; } // AVR is on await this.denon.send(`${zonePrefix}${reference}`); if (this.logInfo) this.emit('info', `set Input Name: ${name}, Reference: ${reference}`); } catch (error) { if (this.logWarn) this.emit('warn', `set Input error: ${error}`); } }); this.televisionService.getCharacteristic(Characteristic.RemoteKey) .onSet(async (command) => { try { const rcMedia = this.reference === 'SPOTIFY' || this.reference === 'BT' || this.reference === 'USB/IPOD' || this.reference === 'NET' || this.reference === '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.playState ? 'NS9B' : 'NS9A') : 'NS94'; this.playState = !this.playState; break; case Characteristic.RemoteKey.INFORMATION: command = this.infoButtonCommand; break; } await this.denon.send(command); if (this.logInfo) this.emit('info', `set Remote Key: ${command}`); } catch (error) { if (this.logWarn) this.emit('warn', `set Remote Key error: ${error}`); } }); //prepare inputs service if (this.logDebug) this.emit('debug', `Prepare surrounds services`); this.inputsServices = []; await this.addRemoveOrUpdateInput(this.savedInputs, false); //prepare sensor service const possibleSensorCount = 99 - this.accessory.services.length; const maxSensorCount = this.sensors.length >= possibleSensorCount ? possibleSensorCount : this.sensors.length; if (maxSensorCount > 0) { this.sensorServices = []; if (this.logDebug) this.emit('debug', `Prepare inputs sensors services`); for (let i = 0; i < maxSensorCount; i++) { const sensor = this.sensors[i]; //get sensor name const name = sensor.name || `Sensor ${i}`; //get sensor name prefix const namePrefix = sensor.namePrefix; //get service type const serviceType = sensor.serviceType; //get characteristic type const characteristicType = sensor.characteristicType; const serviceName = namePrefix ? `${accessoryName} ${name}` : name; const sensorService = new serviceType(serviceName, `Sensor ${i}`); sensorService.addOptionalCharacteristic(Characteristic.ConfiguredName); sensorService.setCharacteristic(Characteristic.ConfiguredName, serviceName); sensorService.getCharacteristic(characteristicType) .onGet(async () => { const state = sensor.state; return state; }); this.sensorServices.push(sensorService); accessory.addService(sensorService); } } return accessory; } catch (error) { throw new Error(error) } } //start async start() { try { //denon client this.zone = new Zone(this.denon, this.device, this.inputsFile) .on('deviceInfo', (info) => { this.emit('devInfo', `-------- ${this.name} --------`); this.emit('devInfo', `Manufacturer: ${info.manufacturer}`); this.emit('devInfo', `Model: ${info.modelName}`); this.emit('devInfo', `Control: ${info.controlZone}`); this.emit('devInfo', `----------------------------------`); this.informationService?.updateCharacteristic(Characteristic.FirmwareRevision, info.firmwareRevision); }) .on('addRemoveOrUpdateInput', async (inputs, remove) => { await this.addRemoveOrUpdateInput(inputs, remove); }) .on('stateChanged', async (power, reference, volume, volumeDisplay, mute) => { const input = this.inputsServices?.find(input => input.reference === reference); const inputIdentifier = input ? input.identifier : this.inputIdentifier; const scaledVolume = await this.functions.scaleValue(volume, -80, 18, 0, 100); mute = power ? mute : true; this.televisionService ?.updateCharacteristic(Characteristic.Active, power ? 1 : 0) .updateCharacteristic(Characteristic.ActiveIdentifier, inputIdentifier); // sensors const currentStateModeMap = { 0: reference, 1: power, 2: scaledVolume, 3: mute, }; const previousStateModeMap = { 0: this.reference, 1: this.power, 2: this.volume, 3: this.mute, }; for (let i = 0; i < this.sensors.length; i++) { let state = false; const sensor = this.sensors[i]; const currentValue = currentStateModeMap[sensor.mode]; const previousValue = previousStateModeMap[sensor.mode]; const pulse = sensor.pulse; const reference = sensor.referenceSurround; const level = sensor.level; const characteristicType = sensor.characteristicType; // modes >= 4 are independent from main power const isActiveMode = power; if (pulse && currentValue !== previousValue) { for (let step = 0; step < 2; step++) { state = isActiveMode ? (step === 0) : false; sensor.state = state; this.sensorServices?.[i]?.updateCharacteristic(characteristicType, state); await new Promise(resolve => setTimeout(resolve, 500)); } } else { if (isActiveMode) { switch (sensor.mode) { case 0: // reference mode state = currentValue === reference; break; case 2: // volume mode state = currentValue === level; break; case 1: // power case 3: // mute state = currentValue === true; break; default: state = false; } } sensor.state = state; this.sensorServices?.[i]?.updateCharacteristic(characteristicType, state); } } this.inputIdentifier = inputIdentifier; this.power = power; this.reference = reference; this.volume = scaledVolume; this.mute = mute; this.volumeDisplay = volumeDisplay; if (this.logInfo) { const name = input ? input.name : reference; this.emit('info', `Power: ${power ? 'ON' : 'OFF'}`); this.emit('info', `Surround 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)); //checkInfo to avr and check state const checkInfo = await this.zone.checkInfo(this.denonInfo); if (!checkInfo) return false; //prepare data for accessory await this.prepareDataForAccessory(); //prepare accessory const accessory = await this.prepareAccessory(); return accessory; } catch (error) { throw new Error(`Start error: ${error}`); } } } export default Surrounds;