homebridge-smartthings-ac-km81
Version:
SmartThings 에어컨을 HomeKit에 연동하는 커스텀 플러그인 (무풍/자동건조/어린이 보호용 잠금장치 매핑, 별도 스위치 노출, OAuth2)
277 lines (246 loc) • 8.98 kB
JavaScript
// lib/SmartThings.js v2.4.7
'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',
SWITCH: 'switch',
MODE: 'airConditionerMode',
COOL_SETPOINT: 'thermostatCoolingSetpoint',
TEMP: 'temperatureMeasurement'
};
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: (n) => {
this.log.info(`SmartThings API 재시도 (${n}회차)...`);
return n * 1000;
},
retryCondition: (err) => {
const s = err.response?.status;
return axiosRetry.isNetworkOrIdempotentRequestError(err) || s === 429 || (s >= 500 && s < 600);
}
});
this._setupInterceptors();
this.cache = new LRUCache({ max: 100, ttl: 5 * 1000 });
this.statusPromises = new Map();
}
_setupInterceptors() {
this.client.interceptors.request.use(
(cfg) => {
if (this.tokens?.access_token) cfg.headers.Authorization = `Bearer ${this.tokens.access_token}`;
return cfg;
},
(e) => Promise.reject(e)
);
this.client.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
if (!this.isRefreshing) {
this.isRefreshing = true;
try {
const newAccess = await this.refreshToken();
this.isRefreshing = false;
this._flushWaiters(null, newAccess);
original.headers.Authorization = `Bearer ${newAccess}`;
return this.client(original);
} catch (e) {
this.isRefreshing = false;
this._flushWaiters(e, null);
this.log.error('토큰 갱신 실패: 재인증 필요할 수 있습니다.');
return Promise.reject(e);
}
}
return new Promise((resolve, reject) => {
this.pendingRequests.push({
resolve: (newAccess) => {
original.headers.Authorization = `Bearer ${newAccess}`;
resolve(this.client(original));
},
reject
});
});
}
return Promise.reject(error);
}
);
}
_flushWaiters(err, token) {
for (const w of this.pendingRequests) err ? w.reject(err) : w.resolve(token);
this.pendingRequests = [];
}
async init() {
try {
this.tokens = JSON.parse(await fs.readFile(this.tokenPath, 'utf8'));
this.log.info('저장된 OAuth 토큰을 성공적으로 불러왔습니다.');
return true;
} catch {
this.log.warn('저장된 토큰이 없습니다. 사용자 인증이 필요합니다.');
return false;
}
}
async getInitialTokens(code) {
const tokenUrl = 'https://api.smartthings.com/oauth/token';
const auth = 'Basic ' + Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
try {
const resp = await axios.post(
tokenUrl,
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: auth } }
);
await this._saveTokens(resp.data);
} catch (e) {
this.log.error(`초기 토큰 발급 실패: ${e.response?.status}`, e.response?.data || e.message);
throw new Error('초기 토큰 발급 실패. 코드/리디렉트 URL을 확인하세요.');
}
}
async refreshToken() {
if (!this.tokens?.refresh_token) throw new Error('리프레시 토큰 없음');
const tokenUrl = 'https://api.smartthings.com/oauth/token';
const auth = 'Basic ' + Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString('base64');
try {
const resp = await axios.post(
tokenUrl,
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.tokens.refresh_token
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: auth } }
);
await this._saveTokens(resp.data);
return this.tokens.access_token;
} catch (e) {
this.log.error('토큰 갱신 실패:', e.message);
throw e;
}
}
async _saveTokens(tokens) {
this.tokens = tokens;
await fs.writeFile(this.tokenPath, JSON.stringify(tokens, null, 2), 'utf8');
this.log.info('토큰 저장/갱신 완료');
}
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 key = `status-${deviceId}`;
const cached = this.cache.get(key);
if (cached) return cached;
if (this.statusPromises.has(deviceId)) return this.statusPromises.get(deviceId);
const p = this.client
.get(`/devices/${deviceId}/status`)
.then((res) => {
const data = res.data?.components?.main || {};
this.cache.set(key, data);
return data;
})
.catch((e) => {
this.log.error(`[${deviceId}] 상태 조회 실패:`, e.message);
throw new Error(`[${deviceId}] 상태 조회에 실패했습니다.`);
})
.finally(() => this.statusPromises.delete(deviceId));
this.statusPromises.set(deviceId, p);
return p;
}
async sendCommand(deviceId, command) {
const commands = Array.isArray(command) ? command : [command];
try {
this.cache.delete(`status-${deviceId}`);
await this.client.post(`/devices/${deviceId}/commands`, { commands });
this.log.info(`[명령 전송] ${deviceId} -> ${JSON.stringify(commands)}`);
} catch (e) {
this.log.error(`[명령 전송 실패] ${deviceId}:`, e.message);
throw e;
}
}
async _getCap(deviceId, capability, attribute, def) {
const s = await this.getStatus(deviceId);
const shortKey = capability.split('.').pop();
const obj = s[shortKey] || s[capability];
const v = obj?.[attribute]?.value;
return v == null ? def : v;
}
// Getters
async getPower(deviceId) {
return (await this._getCap(deviceId, CAPABILITY.SWITCH, 'switch', 'off')) === 'on';
}
async getCurrentTemperature(deviceId) {
return Number(await this._getCap(deviceId, CAPABILITY.TEMP, 'temperature', 18));
}
async getCoolingSetpoint(deviceId) {
return Number(await this._getCap(deviceId, CAPABILITY.COOL_SETPOINT, 'coolingSetpoint', 18));
}
async getWindFree(deviceId) {
return (await this._getCap(deviceId, CAPABILITY.OPTIONAL_MODE, 'acOptionalMode', 'off')) === 'windFree';
}
async getAutoClean(deviceId) {
return (await this._getCap(deviceId, CAPABILITY.AUTO_CLEANING, 'autoCleaningMode', 'off')) === 'on';
}
// Setters
setPower(deviceId, on) {
return this.sendCommand(deviceId, { component: 'main', capability: CAPABILITY.SWITCH, command: on ? 'on' : 'off' });
}
setMode(deviceId, mode) {
return this.sendCommand(deviceId, {
component: 'main',
capability: CAPABILITY.MODE,
command: 'setAirConditionerMode',
arguments: [mode]
});
}
setTemperature(deviceId, value) {
return this.sendCommand(deviceId, {
component: 'main',
capability: CAPABILITY.COOL_SETPOINT,
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;