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