UNPKG

homebridge-samsung-ac

Version:

A Homebridge platform plugin for older Samsung air conditioners (TLSv1), enabling Child Bridge support for stability.

367 lines (327 loc) 17.5 kB
// Samsung Air Conditioner Homebridge Plugin // Version 2.1.0 (Final Defensive Coding Update) 'use strict'; const tls = require('tls'); const fs = require('fs'); const { constants } = require('crypto'); let HAP; const PLUGIN_NAME = 'homebridge-samsung-ac'; const PLATFORM_NAME = 'SamsungACPlatform'; const CONSTANTS = { API_PORT: 8888, API_DEVICES_PATH: '/devices', PLUGIN_VERSION: '2.1.0', DEFAULT_RETRY_ATTEMPTS: 3, DEFAULT_CACHE_DURATION_MS: 30000, DEFAULT_TIMEOUT_MS: 5000, POWER: { ON: 'On', OFF: 'Off' }, SWING: { UP_DOWN: 'Up_And_Low', FIX: 'Fix' }, COMFORT: { NANO_ON: 'Comode_Nano', NANO_OFF: 'Comode_Off' }, AUTOCLEAN: { ON: 'Autoclean_On', OFF: 'Autoclean_Off' }, MODE: { COOL: 'Cool', DRY: 'Dry', WIND: 'Wind', AUTO: 'Auto' } }; const certificateCache = new Map(); function getCertificate(path) { if (certificateCache.has(path)) { return certificateCache.get(path); } const certBuffer = fs.readFileSync(path); certificateCache.set(path, certBuffer); return certBuffer; } class SwingModeHandler { constructor(type) { this.type = type; } getValue(state) { if (!state) return false; if (this.type === 'wind') return state.Wind?.direction === CONSTANTS.SWING.UP_DOWN; return state.Mode?.options?.includes(CONSTANTS.COMFORT.NANO_ON); } getCommand(enable) { if (this.type === 'wind') { const dir = enable ? CONSTANTS.SWING.UP_DOWN : CONSTANTS.SWING.FIX; return { endpoint: '/wind', data: { direction: dir } }; } const opt = enable ? CONSTANTS.COMFORT.NANO_ON : CONSTANTS.COMFORT.NANO_OFF; return { endpoint: '/mode', data: { options: [opt] } }; } } class ApiClient { constructor(ip, token, log, options) { this.ip = ip; this.token = token; this.log = log; this.timeout = options.timeout; this.tlsOptions = { host: this.ip, port: CONSTANTS.API_PORT, cert: options.cert, key: options.key, rejectUnauthorized: false, honorCipherOrder: true, ciphers: 'DEFAULT@SECLEVEL=0', minVersion: 'TLSv1', maxVersion: 'TLSv1', secureOptions: constants.SSL_OP_LEGACY_SERVER_CONNECT, }; } async getDeviceStatus() { return await this._request('GET', CONSTANTS.API_DEVICES_PATH); } async sendCommand(index, endpoint, data) { await this._request('PUT', `/devices/${index}${endpoint}`, data); } async _request(method, path, data = null, retries = CONSTANTS.DEFAULT_RETRY_ATTEMPTS) { for (let attempt = 1; attempt <= retries; attempt++) { try { return await this._rawRequest(path, method, data); } catch (e) { const isNetworkError = /ETIMEDOUT|ECONNRESET|EHOSTUNREACH|ENOTFOUND/.test(e.message); if (isNetworkError && attempt < retries) { this.log.warn(`[ApiClient] 네트워크 오류, 재시도 ${attempt}/${retries}... (${e.message})`); await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); } else { this.log.error(`[ApiClient] 최종 요청 실패 (${attempt}회 시도): ${e.message}`); throw e; } } } } _rawRequest(path, method, data) { return new Promise((resolve, reject) => { const jsonData = data ? JSON.stringify(data) : ''; const requestData = [`${method} ${path} HTTP/1.1`, `Host: ${this.ip}`, `Authorization: Bearer ${this.token}`, 'Content-Type: application/json', `Content-Length: ${Buffer.byteLength(jsonData)}`, 'Connection: close', '', jsonData].join('\r\n'); const socket = tls.connect(this.tlsOptions, () => socket.write(requestData)); let responseChunks = ''; socket.setEncoding('utf8'); socket.on('data', chunk => { responseChunks += chunk; }); socket.on('end', () => { try { if (responseChunks.includes('HTTP/1.1 204 No Content')) return resolve({}); const bodySeparator = '\r\n\r\n'; const bodyIndex = responseChunks.indexOf(bodySeparator); if (bodyIndex === -1) return reject(new Error('HTTP 본문 구분자를 찾을 수 없습니다.')); const body = responseChunks.slice(bodyIndex + bodySeparator.length).trim(); if (!body) return resolve({}); resolve(JSON.parse(body)); } catch (e) { reject(new Error(`응답 처리 실패: ${e.message}, 응답: "${responseChunks}"`)); } finally { if (!socket.destroyed) socket.destroy(); } }); socket.setTimeout(this.timeout, () => { socket.destroy(); reject(new Error(`요청 시간 초과 (${this.timeout}ms)`)); }); socket.on('error', err => { if (!socket.destroyed) socket.destroy(); reject(new Error(`TLS 소켓 오류: ${err.message}`)); }); }); } } class SamsungACPlatform { constructor(log, config, api) { this.log = log; this.config = config; this.api = api; this.cachedAccessories = new Map(); this.logicInstances = []; this.log.info("삼성 AC 플랫폼을 초기화합니다..."); this.api.on('didFinishLaunching', () => this.discoverDevices()); this.api.once('shutdown', () => { this.log.info('플랫폼 종료 신호 수신, 모든 장치의 폴링 타이머를 정리합니다.'); for (const logic of this.logicInstances) { logic.shutdown(); } }); } configureAccessory(accessory) { this.log.info(`캐시에서 '${accessory.displayName}' 액세서리를 불러옵니다.`); this.cachedAccessories.set(accessory.UUID, accessory); } discoverDevices() { // 최종 개선점 1: 중복 생성을 막기 위해 인스턴스 목록 초기화 this.logicInstances = []; const allDeviceConfigs = this.config.accessories || []; const validDeviceConfigs = []; for (const deviceConfig of allDeviceConfigs) { if (deviceConfig.name && deviceConfig.ip && deviceConfig.token) { validDeviceConfigs.push(deviceConfig); } else { this.log.error('잘못된 에어컨 설정이 있어 건너뜁니다. (name, ip, token 필드를 확인하세요)', deviceConfig); } } const activeUUIDs = new Set(); this.log.info(`${validDeviceConfigs.length}개의 유효한 에어컨 장치를 설정에서 찾았습니다.`); for (const deviceConfig of validDeviceConfigs) { const uuid = HAP.uuid.generate(deviceConfig.ip + deviceConfig.name); activeUUIDs.add(uuid); const existingAccessory = this.cachedAccessories.get(uuid); let logicInstance; if (existingAccessory) { this.log.info(`'${deviceConfig.name}' 액세서리를 복원하고 로직을 연결합니다.`); existingAccessory.context.config = deviceConfig; this.api.updatePlatformAccessories([existingAccessory]); logicInstance = new SamsungACLogic(this.log, deviceConfig, this.api, existingAccessory); } else { this.log.info(`'${deviceConfig.name}'를 새로운 액세서리로 등록합니다.`); const accessory = new this.api.platformAccessory(deviceConfig.name, uuid); accessory.context.config = deviceConfig; logicInstance = new SamsungACLogic(this.log, deviceConfig, this.api, accessory); this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); } this.logicInstances.push(logicInstance); } const accessoriesToRemove = []; for (const accessory of this.cachedAccessories.values()) { if (!activeUUIDs.has(accessory.UUID)) { accessoriesToRemove.push(accessory); } } if (accessoriesToRemove.length > 0) { this.log.info(`${accessoriesToRemove.length}개의 오래된 액세서리를 제거합니다.`); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessoriesToRemove); } } } class SamsungACLogic { constructor(log, config, api, accessory) { this.log = log; this.config = config; this.accessory = accessory; this.api = api; this.Service = api.hap.Service; this.Characteristic = api.hap.Characteristic; this.name = this.config.name; this.deviceIndex = this.config.deviceIndex ?? 0; this.setDeviceIndex = this.config.setDeviceIndex ?? this.deviceIndex; this.swingModeType = this.config.swingModeType ?? 'comfort'; this.cacheDuration = this.config.cacheDuration ?? CONSTANTS.DEFAULT_CACHE_DURATION_MS; this.timeout = this.config.timeout ?? CONSTANTS.DEFAULT_TIMEOUT_MS; this.pollingInterval = this.config.pollingInterval; this.minTemp = this.config.minTemp ?? 18; this.maxTemp = this.config.maxTemp ?? 30; this.debugMode = this.config.debug === true; this.pollTimer = null; const defaultCertPath = `${__dirname}/cert/cert.pem`; const certPath = this.config.certPath || defaultCertPath; const keyPath = this.config.keyPath || certPath; try { const certBuffer = getCertificate(certPath); const keyBuffer = getCertificate(keyPath); this.client = new ApiClient(this.config.ip, this.config.token, this.log, { timeout: this.timeout, cert: certBuffer, key: keyBuffer }); } catch (e) { this.log.error(`[${this.name}] 인증서 처리 오류: ${e.message}`); return; } this.swingModeHandler = new SwingModeHandler(this.swingModeType); this.deviceState = null; this.lastStateUpdate = 0; this.stateRequestPromise = null; this.aircoService = this.accessory.getService(this.Service.HeaterCooler) || this.accessory.addService(this.Service.HeaterCooler, this.name); this.accessory.getService(this.Service.AccessoryInformation) .setCharacteristic(this.Characteristic.Manufacturer, this.config.manufacturer || 'Samsung') .setCharacteristic(this.Characteristic.Model, this.config.model || 'AC-Model') .setCharacteristic(this.Characteristic.SerialNumber, this.config.serialNumber || this.name) .setCharacteristic(this.Characteristic.FirmwareRevision, CONSTANTS.PLUGIN_VERSION); this.setupCharacteristics(); this.startPolling(); this.log.info(`[${this.name}] 초기화 완료.`); } shutdown() { this.log.info(`[${this.name}] 폴링 타이머를 정리합니다.`); if (this.pollTimer) { clearTimeout(this.pollTimer); } } debugLog(message) { if (this.debugMode) this.log.info(`[${this.name}] ${message}`); } startPolling() { // 최종 개선점 2: 폴링 간격 값에 대한 유효성 검증 강화 const interval = Number(this.pollingInterval); if (Number.isFinite(interval) && interval >= 1) { this.pollingInterval = interval; // 문자열일 경우를 대비해 숫자로 변환 this.log.info(`[${this.name}] ${this.pollingInterval}초 간격으로 상태 폴링을 시작합니다.`); this._poll(); } } async _poll() { this.debugLog('폴링 실행...'); try { await this.getCachedState(true); } catch (e) { this.log.error(`[${this.name}] 폴링 중 오류: ${e.message}`); } finally { if (this.pollTimer) clearTimeout(this.pollTimer); this.pollTimer = setTimeout(() => this._poll(), this.pollingInterval * 1000); } } async getCachedState(force = false) { const now = Date.now(); if (!force && this.deviceState && (now - this.lastStateUpdate < this.cacheDuration)) { this.debugLog(`캐시된 상태 사용`); return this.deviceState; } if (this.stateRequestPromise) { this.debugLog(`진행 중인 요청에 합류합니다.`); return await this.stateRequestPromise; } this.debugLog(`장치에서 새 상태를 가져옵니다.`); this.stateRequestPromise = (async () => { try { const response = await this.client.getDeviceStatus(); if (!response?.Devices?.[this.deviceIndex]) throw new Error(`API 응답에 장치(index: ${this.deviceIndex})가 없습니다.`); this.deviceState = response.Devices[this.deviceIndex]; this.lastStateUpdate = Date.now(); return this.deviceState; } catch (e) { this.log.error(`상태 가져오기 오류: ${e.message}`); throw e; } finally { this.stateRequestPromise = null; } })(); return await this.stateRequestPromise; } async sendCommand(endpoint, data) { this.log.info(`[${this.name}] 명령 전송: ${endpoint} -> ${JSON.stringify(data)}`); await this.client.sendCommand(this.setDeviceIndex, endpoint, data); await new Promise(resolve => setTimeout(resolve, 500)); await this.getCachedState(true); } _createGetter(name, extractor) { return async () => { this.debugLog(`GET ${name}`); try { const state = await this.getCachedState(); const value = extractor(state); this.debugLog(`> ${name}: ${value}`); return value; } catch (e) { this.log.error(`GET ${name} 오류:`, e.message); throw e; } }; } _createSetter(name, commandBuilder) { return async (value) => { this.log.info(`[${this.name}] SET ${name} -> ${value}`); try { const { endpoint, data } = commandBuilder(value); await this.sendCommand(endpoint, data); } catch (e) { this.log.error(`SET ${name} 오류:`, e.message); throw e; } }; } setupCharacteristics() { this.aircoService.getCharacteristic(this.Characteristic.Active) .onGet(this._createGetter('Active', state => state.Operation.power === CONSTANTS.POWER.ON ? 1 : 0)) .onSet(this._createSetter('Active', value => ({ endpoint: '', data: { Operation: { power: value ? CONSTANTS.POWER.ON : CONSTANTS.POWER.OFF } } }))); this.aircoService.getCharacteristic(this.Characteristic.CurrentHeaterCoolerState) .onGet(this._createGetter('CurrentState', state => state.Operation.power !== CONSTANTS.POWER.ON ? this.Characteristic.CurrentHeaterCoolerState.INACTIVE : this.Characteristic.CurrentHeaterCoolerState.COOLING)); this.aircoService.getCharacteristic(this.Characteristic.TargetHeaterCoolerState) .setProps({ validValues: [this.Characteristic.TargetHeaterCoolerState.COOL] }) .onGet(this._createGetter('TargetState', () => this.Characteristic.TargetHeaterCoolerState.COOL)) .onSet(value => this.log.info(`[${this.name}] SET TargetState -> ${value} (COOL 모드만 지원)`)); this.aircoService.getCharacteristic(this.Characteristic.CurrentTemperature) .onGet(this._createGetter('CurrentTemp', state => state.Temperatures[0].current)); this.aircoService.getCharacteristic(this.Characteristic.CoolingThresholdTemperature) .setProps({ minValue: this.minTemp, maxValue: this.maxTemp, minStep: 1 }) .onGet(this._createGetter('TargetTemp', state => state.Temperatures[0].desired)) .onSet(this._createSetter('TargetTemp', value => ({ endpoint: '/temperatures/0', data: { desired: value } }))); this.aircoService.getCharacteristic(this.Characteristic.SwingMode) .onGet(this._createGetter('SwingMode', state => this.swingModeHandler.getValue(state) ? 1 : 0)) .onSet(this._createSetter('SwingMode', value => this.swingModeHandler.getCommand(value === 1))); this.aircoService.getCharacteristic(this.Characteristic.LockPhysicalControls) .onGet(this._createGetter('LockControls', state => state.Mode.options.includes(CONSTANTS.AUTOCLEAN.ON) ? 1 : 0)) .onSet(this._createSetter('LockControls', value => ({ endpoint: '/mode', data: { options: [value ? CONSTANTS.AUTOCLEAN.ON : CONSTANTS.AUTOCLEAN.OFF] } }))); } } module.exports = (homebridge) => { HAP = homebridge.hap; homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, SamsungACPlatform); };