homebridge-samsung-ac
Version:
Smart and unified Homebridge plugin for various Samsung air conditioner models
370 lines (325 loc) • 17.2 kB
JavaScript
// 'use strict'; 는 자바스크립트의 엄격 모드를 활성화하여, 잠재적인 오류를 줄여주는 좋은 습관입니다.
'use strict';
// Node.js에 내장된 https 모듈을 불러옵니다. axios 라이브러리를 대체하여 더 낮은 수준의 직접적인 통신을 담당합니다.
const https = require('https');
// Node.js에 내장된 fs (File System) 모듈로, 인증서 같은 파일을 읽기 위해 필요합니다.
const fs = require('fs');
// Homebridge 플러그인 개발에 필요한 핵심 클래스들을 담을 변수를 미리 선언합니다.
var Service, Characteristic, Accessory;
// 이 함수는 Homebridge가 플러그인을 로드할 때 최초로 실행되는 부분입니다.
module.exports = function(homebridge) {
// Homebridge의 핵심 클래스들을 전역 변수에 할당하여 플러그인 전체에서 사용할 수 있게 합니다.
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
Accessory = homebridge.hap.Accessory;
// 이 플러그인을 Homebridge에 공식적으로 등록합니다.
homebridge.registerAccessory('homebridge-samsung-ac', 'SamsungAC', SamsungAirco);
}
// 삼성 에어컨 액세서리의 모든 로직을 담고 있는 메인 클래스입니다.
class SamsungAirco {
// 생성자 함수: Homebridge가 config.json을 기반으로 이 액세서리를 초기화할 때 실행됩니다.
constructor(log, config) {
this.log = log;
this.name = config.name;
// --- config.json에서 가져온 주요 설정값들 ---
this.ip = config.ip;
this.token = config.token;
this.patchCert = config.patchCert;
// --- 다양한 에어컨 모델을 지원하기 위한 설정 ---
this.deviceIndex = config.deviceIndex || 0;
this.setDeviceIndex = config.setDeviceIndex ?? this.deviceIndex;
this.swingModeType = config.swingModeType || 'comfort';
this.cacheDuration = config.cacheDuration || 3000;
if (!this.ip || !this.token || !this.patchCert) {
this.log.error("IP, 토큰, 인증서 경로(patchCert)는 필수 설정 항목입니다.");
return;
}
// --- 모든 SSL/TLS 통신 오류 해결을 위한 핵심 에이전트 설정 ---
this.httpsAgent = new https.Agent({
cert: fs.readFileSync(this.patchCert), // 클라이언트 '인증서'
key: fs.readFileSync(this.patchCert), // 클라이언트 '비공개 키' (상호 인증용)
rejectUnauthorized: false, // 자체 서명 인증서 허용
ciphers: 'DEFAULT@SECLEVEL=1', // 약한 암호화 방식(ca md too weak) 허용
secureProtocol: 'TLSv1_method' // 구형 프로토콜(unsupported protocol) 사용 강제
});
// --- 상태 캐싱을 위한 변수 초기화 ---
this.deviceState = null;
this.lastStateUpdate = 0;
// --- 홈 앱에 표시될 서비스 생성 ---
this.aircoSamsung = new Service.HeaterCooler(this.name);
this.informationService = new Service.AccessoryInformation()
.setCharacteristic(Characteristic.Manufacturer, 'Samsung')
.setCharacteristic(Characteristic.Model, 'Air Conditioner')
.setCharacteristic(Characteristic.SerialNumber, config.serialNumber || 'AF16K7970WFN');
}
/**
* 네이티브 https 모듈을 사용하여 "raw" HTTP 요청을 보내는 헬퍼 함수
* @param {string} method - 'GET' 또는 'PUT'
* @param {string} path - API 경로 (예: '/devices')
* @param {object | null} data - PUT 요청 시 보낼 데이터
* @returns {Promise<object>} - 성공 시 JSON 응답 객체를, 실패 시 에러를 포함한 Promise
*/
_request(method, path, data = null) {
return new Promise((resolve, reject) => {
const options = {
hostname: this.ip,
port: 8888,
path: path,
method: method,
headers: { 'Authorization': `Bearer ${this.token}` },
agent: this.httpsAgent,
timeout: 5000
};
if (data) {
const postData = JSON.stringify(data);
options.headers['Content-Type'] = 'application/json';
options.headers['Content-Length'] = Buffer.byteLength(postData);
}
const req = https.request(options, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`요청 실패, 상태 코드: ${res.statusCode}`));
}
let body = [];
res.on('data', (chunk) => body.push(chunk));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(body).toString() || '{}'));
} catch (e) {
reject(e);
}
});
});
req.on('error', (e) => reject(e));
req.on('timeout', () => {
req.destroy();
reject(new Error('요청 시간 초과'));
});
if (data) {
req.write(JSON.stringify(data));
}
req.end();
});
}
/**
* 불필요한 API 호출을 줄여 성능을 향상시키는 핵심 함수.
* @returns {Promise<object>} - 에어컨의 현재 상태 객체를 포함한 Promise
*/
async getCachedState() {
const now = Date.now();
if (this.deviceState && (now - this.lastStateUpdate < this.cacheDuration)) {
return this.deviceState;
}
this.log.info('기기에서 최신 상태를 가져옵니다...');
try {
const responseData = await this._request('GET', '/devices');
this.deviceState = responseData.Devices[this.deviceIndex];
this.lastStateUpdate = now;
return this.deviceState;
} catch (error) {
this.log.error(`기기 상태를 가져오는 데 실패했습니다: ${error.message}`);
if (this.deviceState) {
this.log.warn('가져오기 오류로 인해 오래된 캐시 데이터를 반환합니다.');
return this.deviceState;
}
throw new Error('기기 상태를 가져올 수 없습니다.');
}
}
/**
* 에어컨에 제어 명령을 보내는 헬퍼 함수.
* @param {string} endpoint - API 엔드포인트 (예: '/mode')
* @param {object} data - 전송할 JSON 데이터
*/
async sendCommand(endpoint, data) {
const fullEndpoint = `/devices/${this.setDeviceIndex}${endpoint}`;
try {
await this._request('PUT', fullEndpoint, data);
this.log.info(`명령어를 ${fullEndpoint}(으)로 성공적으로 보냈습니다.`);
this.deviceState = null;
await this.getCachedState();
} catch (error) {
this.log.error(`${endpoint}(으)로 명령을 보내는 데 실패했습니다: ${error.message}`);
throw error;
}
}
identify(callback) {
this.log.info("장치 식별 요청이 들어왔습니다!");
callback();
}
/**
* 이 액세서리가 홈 앱에 제공할 모든 서비스와 특성(기능)을 정의하고 반환하는 함수.
* @returns {Service[]} - 서비스 목록 배열
*/
getServices() {
// --- 핵심 수정 사항 ---
// 이 서비스가 액세서리의 '대표'임을 명시하여, 타일 탭 문제를 해결합니다.
this.aircoSamsung.setPrimaryService(true);
// 1. '활성' 특성 (전원 On/Off)을 가장 먼저 등록합니다.
// 이것이 홈 앱 타일의 기본 탭 동작이 됩니다.
this.aircoSamsung.getCharacteristic(Characteristic.Active)
.on('get', this.getActive.bind(this))
.on('set', this.setActive.bind(this));
// 2. '현재 냉난방기 상태' 등록 (IDLE, COOLING 등)
this.aircoSamsung.getCharacteristic(Characteristic.CurrentHeaterCoolerState)
.on('get', this.getCurrentHeaterCoolerState.bind(this));
// 3. '목표 냉난방기 상태' 등록 (모드 선택)
// 사용자가 선택할 수 있는 모드를 제한합니다.
this.aircoSamsung.getCharacteristic(Characteristic.TargetHeaterCoolerState)
.setProps({ validValues: [Characteristic.TargetHeaterCoolerState.COOL] }) // '냉방' 모드만 허용
.on('get', this.getTargetHeaterCoolerState.bind(this))
.on('set', this.setTargetHeaterCoolerState.bind(this));
// 4. '현재 온도' 등록
this.aircoSamsung.getCharacteristic(Characteristic.CurrentTemperature)
.on('get', this.getCurrentTemperature.bind(this));
// 5. '냉방 설정 온도' 등록
this.aircoSamsung.getCharacteristic(Characteristic.CoolingThresholdTemperature)
.setProps({ minValue: 18, maxValue: 30, minStep: 1 })
.on('get', this.getTargetTemperature.bind(this))
.on('set', this.setTargetTemperature.bind(this));
// 6. '스윙 모드' (바람 방향 또는 무풍) 등록
this.aircoSamsung.getCharacteristic(Characteristic.SwingMode)
.on('get', this.getSwingMode.bind(this))
.on('set', this.setSwingMode.bind(this));
// 7. '물리 제어 잠금'을 '자동 청소' 스위치로 활용
// 부가 기능이므로 마지막에 등록합니다.
this.aircoSamsung.getCharacteristic(Characteristic.LockPhysicalControls)
.on('get', this.getLockPhysicalControls.bind(this))
.on('set', this.setLockPhysicalControls.bind(this));
// 정보 서비스와 에어컨 서비스를 배열로 반환합니다.
return [this.informationService, this.aircoSamsung];
}
// --- Getters & Setters ---
// 각 특성의 상태를 가져오거나(get) 설정(set)하는 함수들.
// async/await를 사용하여 비동기 로직을 더 읽기 쉽게 작성했습니다.
async getActive(callback) {
try {
const state = await this.getCachedState();
const isActive = state.Operation.power === "On" ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE;
callback(null, isActive);
} catch (error) {
callback(error);
}
}
async setActive(value, callback) {
try {
if (value === Characteristic.Active.INACTIVE) {
await this.sendCommand('', { Operation: { power: 'Off' } });
} else {
this.log.info('전원을 켠 후, "청정 건조" 모드로 설정합니다...');
await this.sendCommand('', { Operation: { power: 'On' } });
this.log.info('전원 켜짐. 6초 후 모드를 설정합니다...');
await new Promise(resolve => setTimeout(resolve, 6000));
await this.sendCommand('/mode', { modes: ['DryClean'] });
this.log.info('성공적으로 전원을 켜고 운전 모드를 설정했습니다.');
this.aircoSamsung.getCharacteristic(Characteristic.CurrentHeaterCoolerState).updateValue(Characteristic.CurrentHeaterCoolerState.COOLING);
}
callback(null);
} catch(error) {
this.log.error('전원 상태 설정에 실패했습니다:', error);
callback(error);
}
}
async getCurrentTemperature(callback) {
try {
const state = await this.getCachedState();
callback(null, state.Temperatures[0].current);
} catch (error) {
callback(error);
}
}
async getTargetTemperature(callback) {
try {
const state = await this.getCachedState();
callback(null, state.Temperatures[0].desired);
} catch (error) {
callback(error);
}
}
async setTargetTemperature(value, callback) {
try {
await this.sendCommand('/temperatures/0', { desired: value });
callback(null);
} catch (error) {
callback(error);
}
}
async getSwingMode(callback) {
try {
const state = await this.getCachedState();
if (this.swingModeType === 'wind') {
const mode = state.Wind.direction;
callback(null, mode === "Up_And_Low" ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED);
} else {
const isNano = state.Mode.options.includes("Comode_Nano");
callback(null, isNano ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED);
}
} catch (error) {
callback(error);
}
}
async setSwingMode(value, callback) {
try {
// '400 Bad Request' 오류를 피하기 위해, 변경하려는 옵션만 단독으로 전송합니다.
if (this.swingModeType === 'wind') {
const direction = value === Characteristic.SwingMode.SWING_ENABLED ? "Up_And_Low" : "Fix";
await this.sendCommand('/wind', { direction: direction });
} else {
const newSwingState = value === Characteristic.SwingMode.SWING_ENABLED ? "Comode_Nano" : "Comode_Off";
this.log.info(`무풍 모드 설정 변경: ${newSwingState}`);
await this.sendCommand('/mode', { options: [newSwingState] });
}
callback(null);
} catch (error) {
callback(error);
}
}
async getLockPhysicalControls(callback) {
try {
const state = await this.getCachedState();
const isEnabled = state.Mode.options.includes("Autoclean_On");
this.log.info(`자동 청소 상태: ${isEnabled ? '켜짐' : '꺼짐'}`);
callback(null, isEnabled ? Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED : Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED);
} catch (error) {
callback(error);
}
}
async setLockPhysicalControls(value, callback) {
try {
const newAutocleanState = value === Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED ? 'Autoclean_On' : 'Autoclean_Off';
this.log.info(`자동 청소 설정 변경: ${newAutocleanState}`);
// 다른 옵션을 덮어쓰지 않기 위해 시도했던 '읽고-수정-쓰기' 방식이 400 오류를 유발.
// 따라서 단일 옵션만 보내는 간결한 방식으로 최종 수정.
await this.sendCommand('/mode', { options: [newAutocleanState] });
callback(null);
} catch (error) {
callback(error);
}
}
async getCurrentHeaterCoolerState(callback) {
try {
const state = await this.getCachedState();
const currentMode = state.Mode.modes[0];
const coolModes = ["CoolClean", "Cool", "Dry", "DryClean", "Auto", "Wind"];
if (coolModes.includes(currentMode)) {
callback(null, Characteristic.CurrentHeaterCoolerState.COOLING);
} else {
callback(null, Characteristic.CurrentHeaterCoolerState.IDLE);
}
} catch (error) {
callback(error);
}
}
getTargetHeaterCoolerState(callback) {
this.getCurrentHeaterCoolerState(callback);
}
async setTargetHeaterCoolerState(value, callback) {
try {
if (value === Characteristic.TargetHeaterCoolerState.COOL) {
await this.sendCommand('/mode', { modes: ["DryClean"] });
this.aircoSamsung.getCharacteristic(Characteristic.CurrentHeaterCoolerState).updateValue(Characteristic.CurrentHeaterCoolerState.COOLING);
}
callback(null);
} catch (error) {
callback(error);
}
}
}