UNPKG

homebridge-smartthings-ac-km81

Version:

SmartThings 에어컨을 HomeKit에 연동하는 고기능 커스텀 플러그인 (CLI 기반 OAuth2 자동 인증 지원)

253 lines (220 loc) 11.2 kB
// lib/SmartThings.js v2.2.8 'use strict'; const axios = require('axios'); const fs = require('fs').promises; const path = require('path'); const { LRUCache } = require('lru-cache'); const { default: axiosRetry } = require('axios-retry'); const CAPABILITY = { OPTIONAL_MODE: 'custom.airConditionerOptionalMode', AUTO_CLEANING: 'custom.autoCleaningMode', }; class SmartThings { constructor(log, api, config) { this.log = log; this.api = api; this.config = config; this.tokenPath = path.join(this.api.user.persistPath(), 'smartthings_ac_token.json'); this.tokens = null; this.isRefreshing = false; this.pendingRequests = []; this.client = axios.create({ baseURL: 'https://api.smartthings.com/v1', timeout: 10000, }); axiosRetry(this.client, { retries: 3, retryDelay: (retryCount) => { this.log.info(`API 요청 재시도 (${retryCount}번째)...`); return retryCount * 1000; }, retryCondition: (error) => { const status = error.response?.status; // 네트워크 오류, 5xx 서버 오류 또는 429(Too Many Requests)일 때 재시도 return axiosRetry.isNetworkOrIdempotentRequestError(error) || status >= 500 || status === 429; } }); this.setupInterceptors(); this.cache = new LRUCache({ max: 100, ttl: 1000 * 5 }); // 5초 캐시 this.statusPromises = new Map(); } setupInterceptors() { this.client.interceptors.request.use( config => { if (this.tokens && this.tokens.access_token) { config.headers.Authorization = `Bearer ${this.tokens.access_token}`; } return config; }, error => Promise.reject(error) ); this.client.interceptors.response.use( response => response, async error => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; if (!this.isRefreshing) { this.isRefreshing = true; try { const newAccessToken = await this.refreshToken(); this.isRefreshing = false; this.onRefreshed(null, newAccessToken); originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return this.client(originalRequest); } catch (refreshError) { this.isRefreshing = false; this.onRefreshed(refreshError, null); this.log.error('토큰 갱신 최종 실패! 재인증이 필요할 수 있습니다.'); return Promise.reject(refreshError); } } return new Promise((resolve, reject) => { this.pendingRequests.push({ resolve: (newAccessToken) => { originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; resolve(this.client(originalRequest)); }, reject }); }); } return Promise.reject(error); } ); } onRefreshed(err, newAccessToken) { this.pendingRequests.forEach(({ resolve, reject }) => err ? reject(err) : resolve(newAccessToken)); this.pendingRequests = []; } async init() { try { this.tokens = JSON.parse(await fs.readFile(this.tokenPath, 'utf8')); this.log.info('저장된 OAuth 토큰을 성공적으로 불러왔습니다.'); return true; } catch (e) { this.log.warn('저장된 토큰이 없습니다. 사용자 인증이 필요합니다.'); return false; } } async getInitialTokens(code) { this.log.info('인증 코드를 사용하여 첫 토큰 발급을 시도합니다...'); const tokenUrl = 'https://api.smartthings.com/oauth/token'; const authHeader = 'Basic ' + Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64'); try { const response = await axios.post(tokenUrl, new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: this.config.redirectUri, client_id: this.config.clientId, // client_id는 body에도 포함될 수 있음 }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': authHeader, }, }); await this.saveTokens(response.data); } catch (e) { this.log.error(`초기 토큰 발급 실패: ${e.response?.status}`, e.response?.data || e.message); throw new Error('초기 토큰 발급에 실패했습니다. 코드가 유효한지 확인하세요.'); } } async refreshToken() { if (!this.tokens || !this.tokens.refresh_token) { throw new Error('리프레시 토큰이 없어 갱신할 수 없습니다.'); } this.log.info('액세스 토큰 갱신을 시도합니다...'); const tokenUrl = 'https://api.smartthings.com/oauth/token'; const authHeader = 'Basic ' + Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64'); try { const response = await axios.post(tokenUrl, new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.tokens.refresh_token, }), { headers: { 'Authorization': authHeader, 'Content-Type': 'application/x-www-form-urlencoded', }, }); await this.saveTokens(response.data); return this.tokens.access_token; } catch (error) { this.log.error(`토큰 갱신 실패:`, error.message); throw error; } } async saveTokens(tokens) { try { this.tokens = tokens; await fs.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), 'utf8'); this.log.info('토큰을 성공적으로 저장/갱신했습니다.'); } catch (e) { this.log.error('토큰 파일 저장 중 심각한 오류 발생:', e.message); } } async getDevices() { try { const res = await this.client.get('/devices'); return res.data.items || []; } catch (e) { this.log.error('디바이스 목록 조회 오류:', e.message); throw e; } } async getStatus(deviceId) { const cacheKey = `status-${deviceId}`; const cachedData = this.cache.get(cacheKey); if (cachedData) return cachedData; if (this.statusPromises.has(deviceId)) { return this.statusPromises.get(deviceId); } const promise = this.client.get(`/devices/${deviceId}/status`) .then(res => { const data = res.data.components.main; this.cache.set(cacheKey, data); return data; }) .catch(e => { this.log.error(`[${deviceId}] 상태 조회 실패:`, e.message); throw new Error(`[${deviceId}] 상태 조회에 실패했습니다.`); }) .finally(() => { this.statusPromises.delete(deviceId); }); this.statusPromises.set(deviceId, promise); return promise; } async sendCommand(deviceId, command) { const commands = Array.isArray(command) ? command : [command]; try { // Optimistic Update: Invalidate cache immediately const cacheKey = `status-${deviceId}`; this.cache.delete(cacheKey); await this.client.post(`/devices/${deviceId}/commands`, { commands }); this.log.info(`[명령 전송] ID: ${deviceId}, 명령:`, JSON.stringify(commands)); } catch (e) { this.log.error(`[명령 전송 실패] ID: ${deviceId}, 오류:`, e.message); throw e; } } // --- Capability Getter Functions --- async getCapabilityValue(deviceId, capability, attribute, defaultValue) { const status = await this.getStatus(deviceId); // 네임스페이스(custom.*)를 포함한 capability 처리 const capObject = status[capability.split('.').pop()] || status[capability]; return capObject?.[attribute]?.value ?? defaultValue; } async getPower(deviceId) { return await this.getCapabilityValue(deviceId, 'switch', 'switch', 'off') === 'on'; } async getMode(deviceId) { return await this.getCapabilityValue(deviceId, 'airConditionerMode', 'airConditionerMode', 'off'); } async getCurrentTemperature(deviceId) { return await this.getCapabilityValue(deviceId, 'temperatureMeasurement', 'temperature', 18); } async getCoolingSetpoint(deviceId) { return await this.getCapabilityValue(deviceId, 'thermostatCoolingSetpoint', 'coolingSetpoint', 18); } async getWindFree(deviceId) { return await this.getCapabilityValue(deviceId, CAPABILITY.OPTIONAL_MODE, 'acOptionalMode', 'off') === 'windFree'; } async getAutoClean(deviceId) { return await this.getCapabilityValue(deviceId, CAPABILITY.AUTO_CLEANING, 'autoCleaningMode', 'off') === 'on'; } // --- Capability Setter Functions --- setPower(deviceId, on) { return this.sendCommand(deviceId, { component: 'main', capability: 'switch', command: on ? 'on' : 'off' }); } setMode(deviceId, mode) { return this.sendCommand(deviceId, { component: 'main', capability: 'airConditionerMode', command: 'setAirConditionerMode', arguments: [mode] }); } setTemperature(deviceId, value) { return this.sendCommand(deviceId, { component: 'main', capability: 'thermostatCoolingSetpoint', command: 'setCoolingSetpoint', arguments: [value] }); } setWindFree(deviceId, enable) { return this.sendCommand(deviceId, { component: 'main', capability: CAPABILITY.OPTIONAL_MODE, command: 'setAcOptionalMode', arguments: [enable ? 'windFree' : 'off'] }); } setAutoClean(deviceId, enable) { return this.sendCommand(deviceId, { component: 'main', capability: CAPABILITY.AUTO_CLEANING, command: 'setAutoCleaningMode', arguments: [enable ? 'on' : 'off'] }); } } module.exports = SmartThings;