[IoT ๐ /Homebridge] - Homebridge ์ ๊ธฐ๋งคํธ ํ๋ฌ๊ทธ์ธ ์ ์, ๋ธ๋ฃจํฌ์ค ํจํท ๋ถ์ - 1
[IoT ๐ /Homebridge] - [JS] Homebridge ์ ๊ธฐ๋งคํธ ํ๋ฌ๊ทธ์ธ ์ ์, ํ๋ฌ๊ทธ์ธ ๊ตฌํ - 2
์ ๋ฒ ๊ฒ์๊ธ์์, ์ ๊ธฐ๋งคํธ์ ๋ธ๋ฃจํฌ์ค ํต์ ํจํท์ ๋ถ์ํด ๋ณด์๋ค.
์ด์ ๊ทธ ํจํท๋ค์ ํ ๋๋ก Homebridge ํ๋ฌ๊ทธ์ธ์ ์ ์ฉ์์ผ ๋ณด๋๋ก ํ๊ฒ ๋ค.
ํ๋ฌ๊ทธ์ธ ์ ์์ ์ด๋ฏธ ํ ๋ฒ ํด๋ดค์ผ๋ฏ๋ก
Homebridge API ์ค๋ช ์ ์๋ตํ๋๋ก ํ๊ฒ ๋ค.
ํ๋ฌ๊ทธ์ธ ๊ฐ๋ฐ (์ ์ธ์๋ฆฌ ๊ตฌํ)
๋จผ์ node.js ํ๋ก์ ํธ ์์ฑ ํ,
linux ํ๊ฒฝ์์ ๋์๊ฐ node-ble ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ค์นํด ์ฃผ์๋ค.
npm install node-ble
๊ธฐ๋ณธ์ ์ธ ํ๋ธ๋ฆฟ์ง ์ ์ธ์๋ฆฌ ๋ถ๋ถ์ ๊ตฌํํด๋ณด์
์จ๋์กฐ์ ํํธ๋ Thermostat ์ ์ธ์๋ฆฌ ํ์ผ์,
ํ์ด๋จธ๋ ๋ง๋ ํ ์ ์ธ์๋ฆฌ๊ฐ ์๊ธฐ์ Lightburb ์ ์ธ์๋ฆฌ์ ๋ฃ์ ๊ฒ์ด๋ค.
https://developers.homebridge.io/#/service/Thermostat
Homebridge Plugin Developer Documentation
Homebridge plugin developer documentation and API reference.
developers.homebridge.io
https://developers.homebridge.io/#/service/Lightbulb
Homebridge Plugin Developer Documentation
Homebridge plugin developer documentation and API reference.
developers.homebridge.io
๋จผ์ , ์จ๋ ์กฐ์ ์๋น์ค ํ์ผ ๊ตฌํ ๋ถ๋ถ์ด๋ค.
Thermostat ์๋น์ค๋ก ์จ๋์กฐ์ ํ์์ ํ์ผ์ ์ ์ธํ๊ณ ,
TargetTemperature ๋ก ์จ๋์ 15~50๋ ๋ฒ์๋ฅผ 5๋ ๋จ์๋ก ๊ฐ๊ฐ ๋งคํธ์ ๋จ๊ณ์ ๋งคํํ๋ค.
๊ทธ ์ธ์ On/Off ํ์ฌ ์จ๋ ์ํ ํ์ ๋ถ๋ถ๋ ๊ตฌํํ๋ค.
initServices() {
...
this.thermostatService = new this.Service.Thermostat(this.name + ' ์จ๋');
this.thermostatService.getCharacteristic(this.Characteristic.TargetTemperature)
.setProps({ minValue: MIN_TEMP, maxValue: MAX_TEMP, minStep: 5 })
.onSet(this.handleSetTargetTemperature.bind(this))
.onGet(() => this.currentState.targetTemp);
this.thermostatService.getCharacteristic(this.Characteristic.CurrentTemperature)
.setProps({ minValue: MIN_TEMP, maxValue: MAX_TEMP, minStep: 1 })
.onGet(() => this.currentState.currentTemp);
const targetHeatingCoolingStateCharacteristic = this.thermostatService.getCharacteristic(this.Characteristic.TargetHeatingCoolingState);
targetHeatingCoolingStateCharacteristic.setProps({
validValues: [this.Characteristic.TargetHeatingCoolingState.OFF, this.Characteristic.TargetHeatingCoolingState.HEAT]
});
targetHeatingCoolingStateCharacteristic
.onSet(this.handleSetTargetHeatingCoolingState.bind(this))
.onGet(() => {
return this.currentState.currentHeatingCoolingState === this.Characteristic.CurrentHeatingCoolingState.OFF
? this.Characteristic.TargetHeatingCoolingState.OFF
: this.Characteristic.TargetHeatingCoolingState.HEAT;
});
this.thermostatService.getCharacteristic(this.Characteristic.CurrentHeatingCoolingState)
.onGet(() => this.currentState.currentHeatingCoolingState);
this.thermostatService.setCharacteristic(this.Characteristic.TemperatureDisplayUnits, this.Characteristic.TemperatureDisplayUnits.CELSIUS);
...
}
๋ค์์ผ๋ก ์จ๋ ์ค์ ๋ก์ง ๋ถ๋ถ,
๋งคํธ์ ์จ๋ ๋จ๊ณ์, ์ ์ธ์๋ฆฌ์ ์จ๋๋ถ๋ถ์ ์ค์ ํด์ฃผ๋ ๋ถ๋ถ๊ณผ,
ํต์ ์์ ํ๋ฅผ ์ํ ๋๋ฐ์ด์ฑ, ์ค๋ณต ๋ช ๋ น ๋ฐฉ์ง ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค.
handleSetTargetTemperature(value) {
let level = TEMP_LEVEL_MAP[Math.round(value / 5) * 5] || 0;
if (value < MIN_TEMP) level = 0;
if (value >= MAX_TEMP) level = 7;
if (level === this.lastSentLevel && this.currentState.targetTemp === value) {
// ์ด๋ฏธ ์ ์ก๋ ๊ฐ ํจ์ค
return;
}
if (this.setTempTimeout) {
clearTimeout(this.setTempTimeout);
}
this.setTempTimeout = setTimeout(async () => {
try {
await this.sendTemperatureCommand(value, level);
} catch (e) {
// ์จ๋ ์ค์ ์ค ble ํต์ ์ค๋ฅ
}
}, 350);
}
๋ง์ง๋ง์ผ๋ก ์จ๋ ์ ์ด ํจํท์ BLE ํต์ ๋ถ๋ถ์ด๋ค.
์ด ๋ฉ์๋์์๋ ํจํท ์์ฑ, BLE ํต์ ์ ์ก/์ค๋ฅ ์ฒ๋ฆฌ
Homekit ์ ์ธ์๋ฆฌ์์ ์ํ ๋๊ธฐํ ๋ถ๋ถ์ ๊ตฌํํ๋ค.
async sendTemperatureCommand(value, level) {
this.setTempTimeout = null;
const packet = this.createControlPacket(level);
if (this.tempCharacteristic && this.isConnected) {
try {
await this.safeWriteValue(this.tempCharacteristic, packet);
this.lastSentLevel = level;
this.currentState.targetTemp = value;
this.currentState.currentTemp = LEVEL_TEMP_MAP[level];
this.currentState.currentHeatingCoolingState =
level > 0 ? this.Characteristic.CurrentHeatingCoolingState.HEAT : this.Characteristic.CurrentHeatingCoolingState.OFF;
if (level > 0) {
this.currentState.lastHeatTemp = value;
}
this.thermostatService.updateCharacteristic(this.Characteristic.CurrentTemperature, this.currentState.currentTemp);
this.thermostatService.updateCharacteristic(this.Characteristic.CurrentHeatingCoolingState, this.currentState.currentHeatingCoolingState);
this.thermostatService.updateCharacteristic(this.Characteristic.TargetHeatingCoolingState, this.currentState.currentHeatingCoolingState === this.Characteristic.CurrentHeatingCoolingState.OFF
? this.Characteristic.TargetHeatingCoolingState.OFF
: this.Characteristic.TargetHeatingCoolingState.HEAT);
} catch (error) {
// BLE ์ฐ๊ธฐ ์ค๋ฅ
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
}
} else {
if (level === 0) {
return;
} else {
// BLE ์ฐ๊ฒฐ ์์, ๋ช
๋ น ์ ์ก ๋ถ๊ฐ
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
}
}
}
ํ์ด๋จธ ๋ถ๋ถ๋ ๋์ผํ๋ค.
์๋น์ค ์ด๊ธฐํ ๋ฉ์๋์ Lightbulb ์๋น์ค ํ์ ์ผ๋ก ์ ์ธํ ํ,
ํ์ด๋จธ(1~15์๊ฐ)์ 100/15 == ์ฝ 6.67%์ ํ ์๊ฐ์ผ๋ก ์ค์ ํ๋ค.
initServices() {
...
this.timerService = new this.Service.Lightbulb(this.name + ' ํ์ด๋จธ ์ค์ ');
this.timerService.getCharacteristic(this.Characteristic.On)
.onSet(this.handleTimerSwitch.bind(this))
.onGet(() => this.currentState.timerOn);
this.timerService.getCharacteristic(this.Characteristic.Brightness)
.setProps({ minValue: 0, maxValue: 100, minStep: BRIGHTNESS_PER_HOUR })
.onSet(this.handleSetTimerHours.bind(this))
.onGet(() => this.currentState.timerHours * BRIGHTNESS_PER_HOUR);
this.timerService.setCharacteristic(this.Characteristic.Brightness, this.currentState.timerHours * BRIGHTNESS_PER_HOUR);
this.timerService.setCharacteristic(this.Characteristic.On, this.currentState.timerOn);
...
}
๋ง์ฐฌ๊ฐ์ง๋ก ํ์ด๋จธ ๋ก์ง ๋ถ๋ถ,
์์ ๋งํ๋ฏ ์ ์ธ์๋ฆฌ์ 6.67%๋ฅผ ํ ์๊ฐ์ผ๋ก ๊ณ์ฐํด์ฃผ๋ ๋ก์ง๊ณผ,
BLE ์ ์ก ๋ฐ ์ ๋ฐ์ดํธ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค.
async handleSetTimerHours(value) {
let hours = Math.round(value / BRIGHTNESS_PER_HOUR);
if (value > 0 && hours === 0) {
hours = 1;
}
if (hours > MAX_TIMER_HOURS) {
hours = MAX_TIMER_HOURS;
}
if (hours === 0) {
// 0์๊ฐ > OFF
this.handleSetTargetTemperature(MIN_TEMP);
}
await this.sendTimerCommand(hours);
this.currentState.timerHours = hours;
this.currentState.timerOn = hours > 0;
const brightnessToSet = hours * BRIGHTNESS_PER_HOUR;
this.timerService.updateCharacteristic(this.Characteristic.On, this.currentState.timerOn);
this.timerService.updateCharacteristic(this.Characteristic.Brightness, brightnessToSet);
}
์ฌ๊ธฐ๋ BLE ํต์ ๊ณผ, Homekit๊ณผ ์ํ ๋๊ธฐํ,
ํ์ด๋จธ ์ ์ด ํจํท ์์ฑ ๋ถ๋ถ์ ๊ตฌํํด ์ฃผ์๋ค.
async sendTimerCommand(hours) {
const packet = this.createControlPacket(hours); // ํจํท ์ ์ก
if (this.timeCharacteristic && this.isConnected) {
try {
await this.safeWriteValue(this.timeCharacteristic, packet);
} catch (error) {
// BLE ์ค๋ฅ
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
}
} else {
if (hours === 0) {
return;
} else {
// BLE ์ฐ๊ฒฐ ์์, ๋ช
๋ น ์ ์ก ๋ถ๊ฐ
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
}
}
}
ํ๋ฌ๊ทธ์ธ ๊ฐ๋ฐ (BLE ์ฐ๊ฒฐ ๊ตฌํ)
์ด์ ์ค์ ๋งคํธ์ ์๋ฒ๊ฐ ๋ธ๋ฃจํฌ์ค๋ก ํต์ ํ ๋ถ๋ถ์ ๊ตฌํํด ์ฃผ์ด์ผ ํ๋ค.
node-ble ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ฌ์ฉํด ble ์๋น์ค๋ฅผ ์ด๊ธฐํ ํ๊ณ ,
์ฅ์น๊ฐ ์คํ๋ผ์ธ ์ผ ๊ฒฝ์ฐ, ์ฌ์ฐ๊ฒฐ ๋ฃจํ๋ฅผ ์งํํ๋ ๋ถ๋ถ์ ๊ตฌํํ๋ค.
async initializeBleAdapter() {
try {
// ble ์ด๊ธฐํ
const { bluetooth } = NodeBle.createBluetooth();
this.startScanningLoop(); // ์ค์บ ๋ฃจํ ์์
} catch (error) {
// ์ด๊ธฐํ ์คํจ
}
}
async startScanningLoop() {
if (!this.adapter || this.isScanningLoopActive) {
// ์คํ ์ค ํน์ ์ด๋ํฐ ์์
return;
}
this.isScanningLoopActive = true; // ์ฐ๊ฒฐ ๋ฃจํ ์์
while (this.isScanningLoopActive) {
if (!this.isConnected) {
// ์ค์บ ์์
try {
await this.adapter.startDiscovery();
const targetAddress = this.macAddress.toUpperCase();
await this.adapter.stopDiscovery();
const deviceAddresses = await this.adapter.devices();
let targetDevice = null;
let foundAddress = null;
for (const address of deviceAddresses) {
const normalizedAddress = address.toUpperCase().replace(/:/g, '');
if (normalizedAddress === targetAddress) {
targetDevice = await this.adapter.getDevice(address);
foundAddress = address;
break;
}
}
if (targetDevice) {
this.device = targetDevice; // ์ฅ์น ๋ฐ๊ฒฌ
await this.connectDevice();
} else {
if (deviceAddresses.length > 0) {
// ์ฅ์น ๋ฐ๊ฒฌ ์คํจ
} else {
// ์ฅ์น ๋ฐ๊ฒฌ ์คํจ(all)
}
}
} catch (error) {
// BLE ์ค์บ ์ค๋ฅ
}
} else {
// ์ฐ๊ฒฐ ์ ์ง
}
await sleep(this.scanInterval);
}
}
connectDevice ๋ฉ์๋์์๋, ์ค์บ ๋ฃจํ์์ ๋ฐ๊ฒฌ๋ ์ฅ์น์ ์ฐ๊ฒฐ์ ์งํํ๋ค.
discoverCharacteristics ๋ฉ์๋๋ ์ ์ด UUID๋ฅผ ์ ์ฉํ์ฌ,
์จ๋ ๋ฐ ํ์ด๋จธ์ ์ค์ ์ ์ด๋ฅผ ๋ด๋นํ๋ค.
๋ง์ง๋ง์ผ๋ก ์ฐ๊ฒฐ์ด ๋์ด์ก์๋ ์ด๊ธฐํ ํ๋ ๋ถ๋ถ๋ ๊ตฌํํ๋ค.
async connectDevice() {
if (!this.device || this.isConnected) {
return;
}
try {
await this.device.connect(); // ์ฐ๊ฒฐ ์๋
this.isConnected = true; // ์ฐ๊ฒฐ ์ฑ๊ณต
this.device.on('disconnect', () => {
this.disconnectDevice(); // ํด์ ๋จ, ์ฌ์ฐ๊ฒฐ ๋ฃจํ ์คํ
});
// GATT ํ์ ์ le-connection-abort-by-local ๋ฐฉ์ง
await sleep(500);
await this.discoverCharacteristics();
} catch (error) {
this.disconnectDevice(true);
}
}
async discoverCharacteristics() {
if (!this.device) return;
try {
// ํน์ฑ ํ์ ๋์
const gatt = await this.device.gatt();
const service = await gatt.getPrimaryService(this.serviceUuid);
// ๋ฐ๊ฒฌ ์ฑ๊ณต
if (this.charSetUuid) {
this.setCharacteristic = await service.getCharacteristic(this.charSetUuid);
}
this.tempCharacteristic = await service.getCharacteristic(this.charTempUuid);
this.timeCharacteristic = await service.getCharacteristic(this.charTimeUuid);
if (this.tempCharacteristic && this.timeCharacteristic) {
// ์ ์ด์ค๋น ์๋ฃ
if (this.setCharacteristic) {
// ์ฐ๊ฒฐ ํ ์ด๊ธฐํ ํจํท ์ ์ก
await this.sendInitializationPacket();
}
// Keep-Alive ํจํท
this.startKeepAlive();
} else {
// ์จ๋ or ํ์ด๋จธ ํ์ ์คํจ
this.disconnectDevice(true);
}
} catch (error) {
// ํ์ ์ค๋ฅ UUID ํ์ธ
this.disconnectDevice(true);
}
}
disconnectDevice(resetDevice = false) {
this.stopKeepAlive();
const deviceToDisconnect = this.device;
this.isConnected = false;
this.tempCharacteristic = null;
this.timeCharacteristic = null;
this.setCharacteristic = null;
if (resetDevice) {
this.device = null;
}
if (deviceToDisconnect) {
deviceToDisconnect.disconnect().catch(e => {
if (!e.message.includes('not connected') && !e.message.includes('does not exist')) {
// ์์ ์ฐ๊ฒฐ ํด์ ์คํจ
}
});
}
}
๋ค์์ผ๋ก๋ ์ฐ๊ฒฐ ์์ ํ ๋ถ๋ถ์ด๋ค.
์ฌ๊ธฐ์๋, ์ฐ๊ฒฐ ์์ ์ฑ์ ์ํด keep-alive ๋ฉ์ปค๋์ฆ์ ์ ์ฉํ๋ค.
Linux BLE ์คํ(BlueZ)์ 110์ด ํ์์์์ ํํผํ๊ณ ,
์ด๊ธฐ ์ง์ฐ ๋ฐ, ์ฃผ๊ธฐ์ ์ธ keep-alive ํจํท ์ ์ก์ผ๋ก ์์ ์ฑ์ ํ๋ณดํ๋ค.
async sendInitialKeepAlivePacket() {
try {
await this.sendInitializationPacket(true);
// Keep-Alive ํจํท ์ ์ก
} catch (e) {
// ์ ์ก ์คํจ
}
}
startKeepAlive() {
this.stopKeepAlive();
this.keepAliveTimer = setTimeout(() => {
if (!this.isConnected) return;
this.sendInitialKeepAlivePacket();
this.keepAliveInterval = setInterval(async () => {
if (this.isConnected) {
try {
await this.sendInitializationPacket(true);
// keep-alive ํจํท ์ฌ์ ์ก
} catch (e) {
// keep-alive ํจํท ์ ์ก ์คํจ
}
}
}, KEEP_ALIVE_INTERVAL_MS);
}, KEEP_ALIVE_INITIAL_DELAY_MS);
}
stopKeepAlive() {
if (this.keepAliveTimer) {
clearTimeout(this.keepAliveTimer);
this.keepAliveTimer = null;
}
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
this.keepAliveInterval = null;
}
}
async sendInitializationPacket(isKeepAlive = false) {
if (!this.setCharacteristic || !this.isConnected || !this.initPacketHex) {
return;
}
try {
const initPacket = Buffer.from(this.initPacketHex, 'hex');
if (!isKeepAlive) {
// keep-alive ํจํท ์ ์ก ์๋
}
await this.setCharacteristic.writeValue(initPacket, { type: 'command' });
if (!isKeepAlive) {
// ํจํท ์ ์ก ์ฑ๊ณต
await sleep(500);
}
} catch (error) {
if (!isKeepAlive) {
// ํจํท ์ ์ก ์ค๋ฅ
this.disconnectDevice(true);
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
}
throw error;
}
}
ํ๋ฌ๊ทธ์ธ ๊ฐ๋ฐ (์ ํธ๋ฆฌํฐ)
๋ง์ง๋ง์ผ๋ก ํ๋ฌ๊ทธ์ธ์ ์ฌ์ฉํ ์ ํธ ๋ถ๋ถ์ด๋ค.
์ฌ๊ธฐ์๋ ์ง๋ ๊ฒ์๊ธ์์ ๋ถ์ํ ์ ์ด ํจํท์ธ,
[date, cheksum, data, checksum] ๊ตฌ์กฐ์ ํจํท์ ๋ง๋๋ ๋ฉ์๋์
์จ๋ ํ์ด๋จธ ์ ์ด UUID์ ํจํท ์ ์ก์, ์ฌ์๋ ๋ฐ ์ค๋ฅ ์ฒ๋ฆฌ๋ฅผ ๋ด๋นํ๋ ๋ฉ์๋์ด๋ค.
๋ช ๋ น ์ถฉ๋๋ฐฉ์ง๋ฅผ ์ํ ์ง์ฐ ์ฒ๋ฆฌ์, ์ฌ์๋ ํ์๋ฅผ 3ํ๋ก ๋์ด ์์ ์ฑ์ ๋์๋ค.
createControlPacket(value) {
const dataByte = value;
const checkSum = (0xFF - dataByte) & 0xFF;
const buffer = Buffer.alloc(4);
buffer.writeUInt8(dataByte, 0);
buffer.writeUInt8(checkSum, 1);
buffer.writeUInt8(dataByte, 2);
buffer.writeUInt8(checkSum, 3);
return buffer;
}
async safeWriteValue(characteristic, packet, maxRetries = 3, delayMs = WRITE_DELAY_MS) {
if (!this.isConnected) {
throw new Error("Device not connected.");
}
const writeOptions = { type: 'command' };
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await characteristic.writeValue(packet, writeOptions); // ์ฐ๊ธฐ ์๋
await sleep(delayMs);
return true;
} catch (error) {
// ์ฐ๊ธฐ ์ค๋ฅ ๋ฐ์
}
}
}
์ด๋ ๊ฒ ํ๋ฌ๊ทธ์ธ ์ ์์ด ๋๋ฌ๋ค.
์ด์ ์ค์ Homebridge ์๋ฒ์ ๋ฑ๋ก ํ ์ค์ ์ ์งํํด๋ณด์
ํ๋ฌ๊ทธ์ธ ๋ฑ๋ก
์ ๋ฒ ๋ฐ์คํฌํ ์ ์ด ํ๋ฌ๊ทธ์ธ ์ค์น ๋์ ๊ฐ์ ๋ฐฉ๋ฒ์ ์งํํ ๊ฒ์ด๋ฏ๋ก
์ค๋ช ์ ์๋ต ํ๊ณ ๋ฐ๋ก ์ค์น๋ฅผ ์งํํด ์ฃผ์๋ค.
์ค์น ํ, ํ๋ฌ๊ทธ์ธ ๋ชฉ๋ก์ ์ ์์ ์ผ๋ก ๋จ๋ ๊ฒ์ ๋ณผ ์ ์๋ค.

์ด์ configํ์ผ์ ์ ๋ฒ ๊ฒ์๊ธ์ ํจํท ๋ถ์์์ ์ฐพ์ UUID๊ฐ๊ณผ
๋งคํธ์ MAC ์ฃผ์, ๊ทธ ์ธ ์ค์ ๋ค์ ์์ฑํด ์ฃผ์๋ค.

์ ์ฅ ํ, Home ์ฑ์ ๋ฑ๋ก์, ๋ธ๋ฆฟ์ง๊ฐ ์ ์์ผ๋ก ๋จ๋ ๊ฒ์ ๋ณผ ์ ์๋ค.
์ ์ธ์๋ฆฌ ํ์ผ์ ์จ๋์กฐ์ ํ์ผ๋ก ์ ๋จ๋ ๋ชจ์ต


์ด์ ์ ์์ ์ผ๋ก ์๋๋๋์ง ํ ์คํธ๋ฅผ ํด ๋ณด์๋ค.
์จ๋ ์กฐ์ ๊ณผ ํ์ด๋จธ ์กฐ์ ๋ชจ๋ ์ ์์ ์ผ๋ก ๋์ํ๋ ๊ฒ์ ๋ณผ ์ ์์๋ค.
์ด๋ก์จ ๊ตฌํ ์ ๊ธฐ๋งคํธ๋ ์ ํ ํ ์ํ๊ณ์ ๋ฑ๋กํ๊ฒ ๋์๋ค.
๋๋ถ์ ๋ธ๋ฃจํฌ์ค ํต์ ๊ตฌ์กฐ์ ๋ํ ์ดํด์ ํต์ ๋งค์ปค๋์ฆ๋ ์ดํดํ๋ ๊ณ๊ธฐ๊ฐ ๋์๋ค.
์ด๋ฒ ๊ฒจ์ธ์๋ Siri๋ฅผ ์ด์ฉํด ์ ๊ธฐ ๋งคํธ๋ ์ ์ด ํ ์ ์์ด์ ๋ง์กฑ์ค๋ฝ๋ค
์๋ณธ ๊ฒ์๊ธ
https://blog.naver.com/101artspace/224061230387
Homebridge ์ ๊ธฐ๋งคํธ ํ๋ฌ๊ทธ์ธ ์ ์, ํ๋ฌ๊ทธ์ธ ๊ตฌํ - 2
์ ๋ฒ ๊ฒ์๊ธ์์, ์ ๊ธฐ๋งคํธ์ ๋ธ๋ฃจํฌ์ค ํต์ ํจํท์ ๋ถ์ํด ๋ณด์๋ค. ์ด์ ๊ทธ ํจํท๋ค์ ํ ๋๋ก Homebridge ...
blog.naver.com