UNPKG

homebridge-ir-amplifier

Version:

Homebridge plugin for IR amplifier control with OCR volume detection and CEC support for Apple TV integration

590 lines 25.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.CECController = void 0; const child_process_1 = require("child_process"); class CECController { constructor(log, config) { this.log = log; this.config = config; this.cecProcess = null; this.isInitialized = false; this.currentState = { isOn: false, volume: 50, isMuted: false, activeSource: false, }; this.cecServiceWatcher = null; this.initializeCEC(); } async initializeCEC() { try { this.log.info('Initializing CEC controller...'); this.log.debug('CEC Configuration:', { deviceName: this.config.deviceName, physicalAddress: this.config.physicalAddress, logicalAddress: this.config.logicalAddress, vendorId: this.config.vendorId, osdName: this.config.osdName }); // Vérifier si cec-client est disponible await this.checkCECAvailability(); // Démarrer le processus CEC await this.startCECProcess(); // L'initialisation et le scan sont maintenant gérés dans startCECProcess } catch (error) { this.log.error('Failed to initialize CEC controller:', error); } } async checkCECAvailability() { return new Promise((resolve, reject) => { const checkProcess = (0, child_process_1.spawn)('which', ['cec-client']); checkProcess.on('close', (code) => { if (code === 0) { this.log.info('cec-client found'); resolve(); } else { reject(new Error('cec-client not found. Please install libcec-utils')); } }); }); } async startCECProcess() { return new Promise((resolve, reject) => { // Démarrer cec-client en mode monitoring this.cecProcess = (0, child_process_1.spawn)('cec-client', [ '-d', '1', // Debug level '-t', 'a', // Type: audio device '-p', this.config.physicalAddress, // Physical address '-l', this.config.logicalAddress.toString(), // Logical address '-n', this.config.deviceName, // Device name '-o', this.config.osdName, // OSD name '-v', this.config.vendorId, // Vendor ID ]); this.cecProcess.stdout?.on('data', (data) => { this.handleCECMessage(data.toString()); }); this.cecProcess.stderr?.on('data', (data) => { this.log.debug('CEC stderr:', data.toString()); }); this.cecProcess.on('close', (code) => { this.log.warn('CEC process closed with code:', code); this.isInitialized = false; }); this.cecProcess.on('error', (error) => { this.log.error('CEC process error:', error); reject(error); }); // Attendre un peu pour que le processus démarre setTimeout(async () => { if (this.cecProcess && !this.cecProcess.killed) { // Marquer comme initialisé AVANT de scanner this.isInitialized = true; this.log.info('CEC controller initialized successfully'); // Démarrer le watcher pour le service CEC this.startCECServiceWatcher(); // Scanner le bus CEC pour découvrir les appareils await this.scanCECDevices(); // Démarrer le scan périodique this.startPeriodicScan(); resolve(); } else { reject(new Error('Failed to start CEC process')); } }, 2000); }); } async scanCECDevices() { if (!this.isInitialized) { this.log.warn('CEC: Cannot scan devices - controller not initialized'); return; } this.log.info('CEC: Scanning bus for connected devices...'); // Utiliser une approche différente : exécuter cec-client en mode scan try { const { spawn } = require('child_process'); // Exécuter cec-client en mode scan const scanProcess = spawn('cec-client', ['-s', '-d', '1'], { stdio: ['pipe', 'pipe', 'pipe'] }); let scanOutput = ''; scanProcess.stdout.on('data', (data) => { scanOutput += data.toString(); }); scanProcess.stderr.on('data', (data) => { this.log.debug('CEC scan stderr:', data.toString().trim()); }); // Envoyer la commande scan scanProcess.stdin.write('scan\n'); scanProcess.stdin.end(); // Attendre la fin du processus await new Promise((resolve) => { scanProcess.on('close', (code) => { this.log.info('CEC: Scan process completed with code:', code); this.parseScanOutput(scanOutput); resolve(code); }); }); } catch (error) { this.log.error('CEC: Failed to run scan process:', error); // Fallback : utiliser les commandes individuelles const scanCommands = [ 'pow 0', // Demander l'état de la TV (adresse 0) 'pow 4', // Demander l'état de l'Apple TV (adresse 4) 'pow 5', // Demander l'état de l'amplificateur (adresse 5) ]; for (const command of scanCommands) { this.log.debug(`CEC: Sending scan command: ${command}`); await this.sendCECCommand(command); await new Promise(resolve => setTimeout(resolve, 500)); } } this.log.info('CEC: Device scan completed'); } startPeriodicScan() { // Scanner les appareils toutes les 30 secondes setInterval(async () => { if (this.isInitialized) { this.log.debug('CEC: Periodic device scan...'); await this.scanCECDevices(); } }, 30000); } handleCECMessage(message) { const lines = message.split('\n'); for (const line of lines) { if (line.trim()) { // Logger TOUS les messages CEC du bus this.logCECBusMessage(line.trim()); // Parser les réponses de scan this.parseScanResponse(line.trim()); if (line.includes('>>')) { this.log.debug('CEC Command Detected:', line.trim()); this.parseCECCommand(line); } } } } parseScanOutput(output) { this.log.info('CEC: Parsing scan output...'); const lines = output.split('\n'); let currentDevice = null; for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.includes('device #')) { // Nouveau device trouvé const match = trimmedLine.match(/device #(\d+):\s*(.+)/); if (match) { currentDevice = { id: parseInt(match[1]), name: match[2].trim() }; this.log.info(`CEC SCAN: Found device #${currentDevice.id}: ${currentDevice.name}`); } } else if (currentDevice && trimmedLine.includes('address:')) { const match = trimmedLine.match(/address:\s*(.+)/); if (match) { this.log.info(`CEC SCAN: Device #${currentDevice.id} address: ${match[1].trim()}`); } } else if (currentDevice && trimmedLine.includes('vendor:')) { const match = trimmedLine.match(/vendor:\s*(.+)/); if (match) { this.log.info(`CEC SCAN: Device #${currentDevice.id} vendor: ${match[1].trim()}`); } } else if (currentDevice && trimmedLine.includes('osd string:')) { const match = trimmedLine.match(/osd string:\s*(.+)/); if (match) { this.log.info(`CEC SCAN: Device #${currentDevice.id} OSD: ${match[1].trim()}`); } } else if (currentDevice && trimmedLine.includes('power status:')) { const match = trimmedLine.match(/power status:\s*(.+)/); if (match) { const powerStatus = match[1].trim(); this.log.info(`CEC SCAN: Device #${currentDevice.id} power: ${powerStatus}`); // Synchroniser l'état de l'amplificateur (device #5) avec TP-Link if (currentDevice.id === 5) { this.syncAmplifierState(powerStatus); } } } else if (currentDevice && trimmedLine.includes('CEC version:')) { const match = trimmedLine.match(/CEC version:\s*(.+)/); if (match) { this.log.info(`CEC SCAN: Device #${currentDevice.id} CEC version: ${match[1].trim()}`); } } else if (currentDevice && trimmedLine.includes('active source:')) { const match = trimmedLine.match(/active source:\s*(.+)/); if (match) { this.log.info(`CEC SCAN: Device #${currentDevice.id} active source: ${match[1].trim()}`); } } } } syncAmplifierState(powerStatus) { // Synchroniser l'état de l'amplificateur CEC avec TP-Link const isOn = powerStatus === 'on'; this.log.info(`CEC: Syncing amplifier state - CEC: ${powerStatus}, TP-Link should be: ${isOn ? 'ON' : 'OFF'}`); // Notifier le callback de changement d'état this.onPowerStateChange?.(isOn); } parseScanResponse(line) { // Parser les réponses de scan de cec-client if (line.includes('device #')) { // Format: device #0: TV const match = line.match(/device #(\d+):\s*(.+)/); if (match) { const deviceId = parseInt(match[1]); const deviceName = match[2].trim(); this.log.info(`CEC SCAN: Found device #${deviceId}: ${deviceName}`); } } else if (line.includes('address:')) { // Format: address: 0.0.0.0 const match = line.match(/address:\s*(.+)/); if (match) { const address = match[1].trim(); this.log.info(`CEC SCAN: Address: ${address}`); } } else if (line.includes('vendor:')) { // Format: vendor: Panasonic const match = line.match(/vendor:\s*(.+)/); if (match) { const vendor = match[1].trim(); this.log.info(`CEC SCAN: Vendor: ${vendor}`); } } else if (line.includes('osd string:')) { // Format: osd string: TV const match = line.match(/osd string:\s*(.+)/); if (match) { const osdName = match[1].trim(); this.log.info(`CEC SCAN: OSD Name: ${osdName}`); } } else if (line.includes('power status:')) { // Format: power status: standby const match = line.match(/power status:\s*(.+)/); if (match) { const powerStatus = match[1].trim(); this.log.info(`CEC SCAN: Power Status: ${powerStatus}`); } } else if (line.includes('CEC version:')) { // Format: CEC version: 2.0 const match = line.match(/CEC version:\s*(.+)/); if (match) { const version = match[1].trim(); this.log.info(`CEC SCAN: CEC Version: ${version}`); } } else if (line.includes('active source:')) { // Format: active source: no const match = line.match(/active source:\s*(.+)/); if (match) { const activeSource = match[1].trim(); this.log.info(`CEC SCAN: Active Source: ${activeSource}`); } } } logCECBusMessage(message) { // Analyser le type de message CEC if (message.includes('>>')) { // Message reçu (incoming) const decodedMessage = this.decodeCECMessage(message); this.log.info(`CEC BUS: RECEIVED >> ${message} ${decodedMessage}`); // Log spécial pour les commandes de l'Apple TV if (message.includes('4F:') || message.includes('key pressed:')) { this.log.info(`CEC BUS: APPLE TV COMMAND DETECTED >> ${message}`); } } else if (message.includes('<<')) { // Message envoyé (outgoing) const decodedMessage = this.decodeCECMessage(message); this.log.info(`CEC BUS: SENT << ${message} ${decodedMessage}`); } else if (message.includes('TRAFFIC')) { // Trafic CEC général this.log.info(`CEC BUS: TRAFFIC ${message}`); } else if (message.includes('key pressed:')) { // Touches pressées this.log.info(`CEC BUS: KEY PRESSED ${message}`); } else if (message.includes('power status')) { // État d'alimentation this.log.info(`CEC BUS: POWER STATUS ${message}`); } else if (message.includes('volume')) { // Commandes de volume this.log.info(`CEC BUS: VOLUME ${message}`); } else if (message.includes('mute')) { // Commandes de mute this.log.info(`CEC BUS: MUTE ${message}`); } else if (message.includes('source')) { // Changements de source this.log.info(`CEC BUS: SOURCE ${message}`); } else if (message.includes('device')) { // Informations sur les appareils this.log.info(`CEC BUS: DEVICE ${message}`); } else { // Autres messages CEC this.log.debug(`CEC BUS: OTHER ${message}`); } } decodeCECMessage(message) { try { // Extraire les adresses et commandes CEC const match = message.match(/(\d+):(\d+):([0-9A-Fa-f:]+)/); if (match) { const [, from, to, command] = match; const fromDevice = this.getCECDeviceName(parseInt(from)); const toDevice = this.getCECDeviceName(parseInt(to)); const commandName = this.getCECCommandName(command); return `[${fromDevice}(${from}) → ${toDevice}(${to})] ${commandName}`; } } catch (error) { // Ignorer les erreurs de décodage } return ''; } getCECDeviceName(address) { const deviceNames = { 0: 'TV', 1: 'Recording Device 1', 2: 'Recording Device 2', 3: 'Tuner 1', 4: 'Playback Device 1', 5: 'Audio System', // Notre amplificateur 6: 'Tuner 2', 7: 'Tuner 3', 8: 'Playback Device 2', 9: 'Recording Device 3', 10: 'Tuner 4', 11: 'Playback Device 3', 12: 'Reserved', 13: 'Reserved', 14: 'Reserved', 15: 'Unregistered' }; return deviceNames[address] || `Device ${address}`; } getCECCommandName(command) { const commandNames = { '82:10:00': 'Image View On', '82:00:00': 'Image View Off', '44': 'User Control Pressed - Mute', '45': 'User Control Pressed - Unmute', '41': 'User Control Pressed - Volume Up', '42': 'User Control Pressed - Volume Down', '50': 'User Control Pressed - Volume', '83': 'Standby', '04': 'Menu Request', '05': 'Menu Status', '46': 'User Control Pressed - Channel Up', '47': 'User Control Pressed - Channel Down' }; return commandNames[command] || `Command ${command}`; } parseCECCommand(line) { // Parser les commandes CEC reçues if (line.includes('key pressed: power on')) { this.log.info('CEC: Power ON command received from Apple TV - triggering amplifier ON'); this.currentState.isOn = true; this.onPowerStateChange?.(true); } else if (line.includes('key pressed: power off')) { this.log.info('CEC: Power OFF command received from Apple TV - triggering amplifier OFF'); this.currentState.isOn = false; this.onPowerStateChange?.(false); } else if (line.includes('key pressed: volume up')) { this.log.info('CEC: Volume UP command received from Apple TV - will send IR command'); this.currentState.volume = Math.min(100, this.currentState.volume + 1); this.onVolumeChange?.(this.currentState.volume); } else if (line.includes('key pressed: volume down')) { this.log.info('CEC: Volume DOWN command received from Apple TV - will send IR command'); this.currentState.volume = Math.max(0, this.currentState.volume - 1); this.onVolumeChange?.(this.currentState.volume); } else if (line.includes('key pressed: mute')) { this.log.info('CEC: Mute command received from Apple TV'); this.currentState.isMuted = !this.currentState.isMuted; this.onMuteChange?.(this.currentState.isMuted); } else if (line.includes('key pressed: unmute')) { this.log.info('CEC: Unmute command received from Apple TV'); this.currentState.isMuted = false; this.onMuteChange?.(false); } // Parser aussi les commandes de volume absolu via les codes CEC if (line.includes('User Control Pressed - Volume Up')) { this.log.info('CEC: Volume UP (CEC code) command received - will send IR command'); this.currentState.volume = Math.min(100, this.currentState.volume + 1); this.onVolumeChange?.(this.currentState.volume); } else if (line.includes('User Control Pressed - Volume Down')) { this.log.info('CEC: Volume DOWN (CEC code) command received - will send IR command'); this.currentState.volume = Math.max(0, this.currentState.volume - 1); this.onVolumeChange?.(this.currentState.volume); } } // Méthodes publiques pour envoyer des commandes CEC async sendCECCommand(command) { if (!this.cecProcess || !this.isInitialized) { this.log.error('CEC controller not initialized'); return false; } try { this.log.debug(`CEC: Sending raw command to cec-client: ${command}`); this.cecProcess.stdin?.write(command + '\n'); this.log.debug('CEC: Command successfully sent to cec-client'); return true; } catch (error) { this.log.error('CEC: Failed to send command to cec-client:', error); return false; } } async setPowerState(isOn) { // Le plugin n'envoie PAS de commandes CEC - il écoute seulement // Les commandes CEC sont envoyées par l'Apple TV, pas par le plugin this.log.debug(`CEC: Power state updated locally to ${isOn ? 'ON' : 'OFF'} (no CEC command sent)`); this.currentState.isOn = isOn; return true; } async setVolume(volume) { // Envoyer la commande de volume absolu const volumeHex = volume.toString(16).padStart(2, '0'); const command = `tx 4F:50:${volumeHex}`; // User Control Pressed this.log.info(`CEC: Sending volume command - Volume: ${volume} (0x${volumeHex})`); this.log.debug(`CEC: Volume command: ${command}`); return this.sendCECCommand(command); } async setMute(isMuted) { const command = isMuted ? 'tx 4F:44' : 'tx 4F:45'; // Mute/Unmute this.log.info(`CEC: Sending mute command - ${isMuted ? 'MUTE' : 'UNMUTE'}`); this.log.debug(`CEC: Mute command: ${command}`); return this.sendCECCommand(command); } // Méthodes pour enregistrer les callbacks onPowerStateChangeCallback(callback) { this.onPowerStateChange = callback; } onVolumeChangeCallback(callback) { this.onVolumeChange = callback; } onMuteChangeCallback(callback) { this.onMuteChange = callback; } // Surveiller le fichier de communication avec le service CEC startCECServiceWatcher() { const fs = require('fs'); const path = '/tmp/cec-to-homebridge.json'; this.log.info('CEC: Starting CEC service file watcher...'); this.cecServiceWatcher = setInterval(() => { try { if (fs.existsSync(path)) { const data = fs.readFileSync(path, 'utf8'); const command = JSON.parse(data); this.log.info('CEC: Command received from CEC service:', command); switch (command.action) { case 'power': if (command.value === 'on') { this.log.info('CEC: Power ON from CEC service'); this.currentState.isOn = true; this.onPowerStateChange?.(true); } else if (command.value === 'off') { this.log.info('CEC: Power OFF from CEC service'); this.currentState.isOn = false; this.onPowerStateChange?.(false); } break; case 'volume': if (command.value === 'up') { this.log.info('CEC: Volume UP from CEC service'); this.currentState.volume = Math.min(100, this.currentState.volume + 1); this.onVolumeChange?.(this.currentState.volume); } else if (command.value === 'down') { this.log.info('CEC: Volume DOWN from CEC service'); this.currentState.volume = Math.max(0, this.currentState.volume - 1); this.onVolumeChange?.(this.currentState.volume); } break; case 'mute': this.log.info('CEC: Mute toggle from CEC service'); this.currentState.isMuted = !this.currentState.isMuted; this.onMuteChange?.(this.currentState.isMuted); break; } // Supprimer le fichier après traitement fs.unlinkSync(path); } } catch (error) { this.log.error('CEC: Error reading CEC service file:', error); } }, 100); // Vérifier toutes les 100ms } stopCECServiceWatcher() { if (this.cecServiceWatcher) { clearInterval(this.cecServiceWatcher); this.cecServiceWatcher = null; this.log.info('CEC: Stopped CEC service file watcher'); } } // Getters pour l'état actuel getCurrentState() { return { ...this.currentState }; } // Nettoyage des ressources cleanup() { this.log.info('CEC: Cleaning up CEC controller...'); // Arrêter le watcher du service CEC this.stopCECServiceWatcher(); // Arrêter le processus CEC if (this.cecProcess && !this.cecProcess.killed) { this.cecProcess.kill(); this.cecProcess = null; } this.isInitialized = false; this.log.info('CEC: Cleanup completed'); } getIsOn() { return this.currentState.isOn; } getVolume() { return this.currentState.volume; } getIsMuted() { return this.currentState.isMuted; } // Méthode pour arrêter le contrôleur CEC async terminate() { if (this.cecProcess) { this.cecProcess.kill(); this.cecProcess = null; this.isInitialized = false; this.log.info('CEC controller terminated'); } } } exports.CECController = CECController; //# sourceMappingURL=cecController.js.map