๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
IoT ๐Ÿ /Homebridge

[JS] Homebridge ์ „๊ธฐ๋งคํŠธ ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ฐœ์„ 

by GeekSean 2026. 2. 25.
๋ฐ˜์‘ํ˜•

[IoT ๐Ÿ /Homebridge] - Homebridge ์ „๊ธฐ๋งคํŠธ ํ”Œ๋Ÿฌ๊ทธ์ธ ์ œ์ž‘, ๋ธ”๋ฃจํˆฌ์Šค ํŒจํ‚ท ๋ถ„์„ - 1

[IoT ๐Ÿ /Homebridge] - [JS] Homebridge ์ „๊ธฐ๋งคํŠธ ํ”Œ๋Ÿฌ๊ทธ์ธ ์ œ์ž‘, ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ตฌํ˜„ - 2

 

์ง€๋‚œ๋ฒˆ์— ๋งŒ๋“  ์ „๊ธฐ๋งคํŠธ ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ๊ฐœ์„ ํ•ด๋ณด๋ ค๊ณ  ํ•œ๋‹ค.

 

์ •๋ง ๋งŒ๋“ค๋ฉด์„œ ๊ฝค๋‚˜ ๊ณ ์ƒํ–ˆ๋˜ ์ž‘์—…์ด์—ˆ๋Š”๋ฐ,

๋ช‡ ๋‹ฌ๊ฐ„ ํŒจํ‚ท ๋ถ„์„์„ ๋” ํ•ด๋ณด๊ณ  ์—ฐ๊ตฌํ•ด๋ณธ ๋์—

์—ฐ๊ฒฐ ์•ˆ์ •์„ฑ๋„ ๋†’์ด๊ณ , ๊ธฐ๊ธฐ๋ฅผ ์ˆ˜๋™์œผ๋กœ ์กฐ์ž‘ํ•  ๋•Œ ์•…์„ธ์„œ๋ฆฌ์™€ ๋™๊ธฐํ™”๋ฅผ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋๋‹ค.

 

์•„๋งˆ ์ „๋ฐ˜์ ์ธ ๊ตฌ์กฐ๋ฅผ ์‹น ๊ฐˆ์•„ ์—Ž๋Š” ์ž‘์—…์ด ํ•„์š”ํ•˜๋ฏ€๋กœ,

๊ฑฐ์˜ ์ƒˆ๋กœ ์ œ์ž‘ํ•˜๋Š”๊ฒƒ๊ณผ ๋‹ค๋ฅด์ง€ ์•Š์„ ๊ฒƒ ๊ฐ™๋‹ค.

 

 

ํ”Œ๋Ÿฌ๊ทธ์ธ ์ˆ˜์ •

 

ํ”Œ๋Ÿฌ๊ทธ์ธ ์ฝ”๋“œ์˜ ๋Œ€๋ถ€๋ถ„์„ ๊ฐˆ์•„ ์—Ž์€ ์ด์œ ์ค‘์˜ ํ•˜๋‚˜๊ฐ€, ์—ฐ๊ฒฐ ์•ˆ์ •์„ฑ์ด๋‹ค.

 

์ „๊ธฐ ๋งคํŠธ ์ปจํŠธ๋กค๋Ÿฌ์™€ node-ble๋ฅผ ์ด์šฉํ•œ ๋ฆฌ๋ˆ…์Šค ์„œ๋ฒ„๊ฐ€

๋ฏธ์„ธํ•œ ๋”œ๋ ˆ์ด๋ฅผ ๋™์ž‘ ์ค‘๊ฐ„ ์ค‘๊ฐ„์— ๋„ฃ์–ด์ค˜์•ผ ์•ˆ์ •์  ์œ ์ง€๊ฐ€ ๋˜๋ฏ€๋กœ

๊ทธ ๊ฐ’์„ ํ•˜๋‚˜ํ•˜๋‚˜ ์ฐพ๋А๋ผ ์ž‘์—…์ด ๊ฝค๋‚˜ ์˜ค๋ž˜๊ฑธ๋ ธ๋‹ค.

 

๋จผ์ €, ๋””๋ฐ”์ด์Šค๋ฅผ ํƒ์ƒ‰ํ•˜๋Š” ์Šค์บ” ํ•จ์ˆ˜๋ฅผ ๊ตฌํ˜„ํ–ˆ๋‹ค.

๋งคํŠธ์˜ MAC ์ฃผ์†Œ๋ฅผ ์ฐพ์„ ๋•Œ ๊นŒ์ง€ ๋ฐ˜๋ณตํ•œ๋‹ค.

async startScanningLoop() {
    while (true) {
        if (!this.isConnected) {
            try {
                try { await this.adapter.stopDiscovery(); } catch(e) {}
                await this.adapter.startDiscovery();
                await sleep(CONFIG.SCAN_DURATION_MS);
                await this.adapter.stopDiscovery();
                const devices = await this.adapter.devices();
                for (const addr of devices) {
                    if (addr.toUpperCase().replace(/:/g, '') === this.macAddress.toUpperCase()) {
                        this.device = await this.adapter.getDevice(addr);
                        await this.connectDevice();
                        break;
                    }
                }
            } catch (e) {}
        }
        await sleep(CONFIG.RECONNECT_DELAY_MS);
    }
}
 

 

๋‹ค์Œ์œผ๋กœ ๊ธฐ๊ธฐ์™€ ๋ฌผ๋ฆฌ์  ์—ฐ๊ฒฐ์„ ๋‹ด๋‹นํ•˜๋Š” ๋ฉ”์„œ๋“œ์ด๋‹ค.

ํŠนํžˆ, le-connection-abort-by-local๊ณผ ๊ฐ™์ด ๋ธ”๋ฃจํˆฌ์Šค ์Šคํƒ ์˜ค๋ฅ˜ ๋ฐœ์ƒ์‹œ

๊ฐ•์ œ๋กœ ์–ด๋Œ‘ํ„ฐ ํ•˜๋“œ์›จ์–ด๋ฅผ ๋ฆฌ์…‹์‹œํ‚ค๋Š” ๋ช…๋ น์–ด๋ฅผ ์„œ๋ฒ„์— ์ „์†กํ•˜๊ฒŒ ๊ตฌํ˜„ํ–ˆ๋‹ค.

async connectDevice() {
    try {
        await this.withTimeout(this.device.connect(), CONFIG.CONNECT_TIMEOUT_MS, "Device Connect");
        this.isConnected = true;

        this.device.removeAllListeners('disconnect'); // ์—ฐ๊ฒฐ ์œ ์‹ค ๊ฐ์ง€
        this.device.once('disconnect', () => this.cleanup());

        await this.discoverCharacteristics();
    } catch (e) {
        if (e.message.includes('le-connection-abort-by-local')) {
            this.abortCount++;
            if (this.abortCount >= 3) { // 3ํšŒ ์˜ค๋ฅ˜์‹œ ๋ฆฌ์…‹
                exec('sudo hciconfig hci0 down && sleep 1 && sudo hciconfig hci0 up');
                this.abortCount = 0;
            }
        }
        this.cleanup();
    }
}
 

 

์ด์ œ ์—ฐ๊ฒฐ ์„ฑ๊ณต ํ›„ ์ฒ˜๋ฆฌ๋ฅผ ํ•˜๋Š” ๋ฉ”์„œ๋“œ์ด๋‹ค.

์ด ๋ถ€๋ถ„์ด ์ œ์ผ ๊ณต์„ ๋งŽ์ด ๋“ค์ธ ๋ถ€๋ถ„์ธ๋ฐ,

 

GATT ์•ˆ์ •ํ™” ๋Œ€๊ธฐ, ์ดˆ๊ธฐ ์ธ์ฆ ํŒจํ‚ท, ๋””๋ฐ”์ด์Šค ์ƒํƒœ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก, ์ƒํƒœ ํ™•์ธ ํŒจํ‚ท ๋“ฑ

์ „๊ธฐ ๋งคํŠธ์™€ ์•ˆ์ •์ ์ด๊ณ  ์ง€์†์ ์ธ ์—ฐ๊ฒฐ์„ ์œ„ํ•ด, ํ•„์š”ํ•œ ์ฒ˜๋ฆฌ๋“ค์„ ๋ชจ๋‘ ๊ตฌํ˜„ํ•˜๊ณ 

์ค‘๊ฐ„์ค‘๊ฐ„ ์ง€์—ฐ์‹œ๊ฐ„์„ ๋‘์–ด, ์—ฐ๊ฒฐ์ด ์ž์ฃผ ๋Š๊ธฐ๋Š” ํ˜„์ƒ์„ ๋ณด์™„ํ–ˆ๋‹ค.

async discoverCharacteristics() {
    try {
        const gatt = await this.withTimeout(this.device.gatt(), CONFIG.GATT_TIMEOUT_MS, "GATT Server");
        await sleep(CONFIG.GATT_WAIT_MS);
        const service = await this.withTimeout(gatt.getPrimaryService(this.serviceUuid), CONFIG.GATT_TIMEOUT_MS, "Primary Service");

        this.setChar = await service.getCharacteristic(this.charSetUuid)
        this.tempChar = await service.getCharacteristic(this.charTempUuid);
        this.timeChar = await service.getCharacteristic(this.charTimeUuid);

        await this.writeRaw(this.setChar, Buffer.from(this.initPacketHex, 'hex'));
        await sleep(CONFIG.AUTH_WAIT_MS);  // ์ธ์ฆํŒจํ‚ท ์ „์†ก

        await this.tempChar.startNotifications();
        this.tempChar.on('valuechanged', (data) => this.handleUpdate(data, 'temp'));
        await sleep(CONFIG.NOTIFY_STEP_MS); 
        await this.timeChar.startNotifications();
        this.timeChar.on('valuechanged', (data) => this.handleUpdate(data, 'timer'));
        await sleep(CONFIG.NOTIFY_READY_MS); // ์ƒํƒœ ๋ฆฌ์Šค๋„ˆ ๋“ฑ๋ก

        await this.writeRaw(this.tempChar, this.createControlPacket(0x12));
        await sleep(CONFIG.POST_INIT_WAIT_MS); // ์ƒํƒœํ™•์ธ ํŒจํ‚ท ์ „์†ก

        this.startPingLoop();
    } catch (e) {
        this.isConnected = false;
        if (this.device) await this.device.disconnect().catch(() => {});
    }
}
 

 

๊ทธ๋ฆฌ๊ณ  ์‹ค์ œ ๋ฐ์ดํ„ฐ๋ฅผ ์ „์†กํ•˜๋Š” ๋ถ€๋ถ„,

๋ธ”๋ฃจํˆฌ์Šค ์ „์†ก์˜ ์•ˆ์ •์„ฑ์„ ๋†’์ด๊ธฐ ์œ„ํ•ด, ์žฌ์‹œ๋„ ๋กœ์ง๊ณผ ์ง€์—ฐ์‹œ๊ฐ„์„ ๋„ฃ์—ˆ๋‹ค.

async writeRaw(characteristic, packet) {
    if (!this.isConnected || !characteristic) return false;
    for (let i = 0; i < CONFIG.RETRY_COUNT; i++) {
        try {
            await characteristic.writeValue(packet, { type: 'command' });
            return true;
        } catch (e) {
            await sleep(CONFIG.WRITE_DELAY_MS);
        }
    }
    return false;
}
 

 

๋งˆ์ง€๋ง‰์œผ๋กœ ๊ธฐ๊ธฐ์™€์˜ ์„ธ์…˜์ด ๋Š๊ธฐ์ง€ ์•Š๋„๋ก,

ํŠน์ • ์‹œ๊ฐ„๋งˆ๋‹ค ์ƒํƒœ ํ™•์ธ ํŒจํ‚ท์„ ์ „์†กํ•˜์—ฌ ์—ฐ๊ฒฐ์ด ์œ ์ง€๋˜๊ฒŒ ํ–ˆ๋‹ค.

startPingLoop() {
    this.stopPingLoop();
    this.pingInterval = setInterval(async () => {
        if (!this.isConnected || !this.tempChar) {
            this.stopPingLoop();
            return;
        }
        try {
            await this.writeRaw(this.tempChar, this.createControlPacket(0x12));
        } catch (e) {}
    }, CONFIG.PING_INTERVAL_MS);
}
 

 

์—ฐ๊ฒฐ ์•ˆ์ •์„ฑ์€ ์ด๋ ‡๊ฒŒ ๋

์ด์ œ ์ˆ˜๋™์œผ๋กœ ๊ธฐ๊ธฐ๋ฅผ ์กฐ์ž‘ ์‹œ, ๊ธฐ๊ธฐ์˜ ์ƒํƒœ๋ณ€ํ™”๋ฅผ ๊ฐ์ง€ํ•˜๋Š” ๋กœ์ง์„ ๊ตฌํ˜„ํ•ด๋ณด์ž

 

๋จผ์ € ๋ฐ”์ด๋„ˆ๋ฆฌ ๋ฐ์ดํ„ฐ๋ฅผ ์‹ค์ œ ์ˆซ์ž๋กœ ๋ณ€ํ™˜ ํ›„,

์˜จ๋„ or ํƒ€์ด๋จธ์˜ ์•…์„ธ์„œ๋ฆฌ๋ฅผ ์—…๋ฐ์ดํŠธ ์‹œํ‚ค๋Š” ๋กœ์ง์„ ๊ตฌํ˜„ํ–ˆ๋‹ค.

์ž˜๋ชป๋œ ํŒจํ‚ท์€ ๊ฑธ๋Ÿฌ์ง€๊ณ , ์ค‘๋ณต์œผ๋กœ ์—…๋ฐ์ดํŠธ๋ฅผ ๋ฐฉ์ง€ํ•˜์—ฌ ๋ถˆํ•„์š”ํ•œ ์‹œ์Šคํ…œ ๋ถ€ํ•˜๋ฅผ ์ค„์˜€๋‹ค.

handleUpdate(data, type) {
    const val = this.parsePacket(data, type);
    if (val === null) return;
    if (type === 'temp') {
        const tempValue = CONFIG.LEVEL_TEMP_MAP[val];
        if (tempValue !== undefined) {
            if (this.currentState.currentTemp !== tempValue || this.currentState.currentHeatingCoolingState !== (val > 0 ? 1 : 0)) {
                this.currentState.currentTemp = tempValue;
                this.currentState.targetTemp = tempValue;
                this.currentState.currentHeatingCoolingState = (val > 0) ? 1 : 0;
                if (val > 0) this.currentState.lastHeatTemp = tempValue;

                this.thermostatService.updateCharacteristic(this.Characteristic.CurrentTemperature, tempValue);
                this.thermostatService.updateCharacteristic(this.Characteristic.TargetTemperature, tempValue);
                this.thermostatService.updateCharacteristic(this.Characteristic.CurrentHeatingCoolingState, this.currentState.currentHeatingCoolingState);
                this.thermostatService.updateCharacteristic(this.Characteristic.TargetHeatingCoolingState, this.currentState.currentHeatingCoolingState);
            }
        }
    } else if (type === 'timer') {
        if (this.currentState.timerHours !== val) {
            this.currentState.timerHours = val;
            this.currentState.timerOn = (val > 0);

            this.timerService.updateCharacteristic(this.Characteristic.On, this.currentState.timerOn);
            this.timerService.updateCharacteristic(this.Characteristic.Brightness, val * CONFIG.BRIGHTNESS_PER_HOUR);
        }
    }
}
 

 

๋งˆ์ง€๋ง‰์œผ๋กœ, ๋ฉ”์„œ๋“œ ์ค‘๊ฐ„์ค‘๊ฐ„ ์ƒ์ˆ˜ ํ˜•ํƒœ๋กœ ์„ ์–ธํ•œ

์ง€์—ฐ์‹œ๊ฐ„์ด๋‚˜ ์ธํ„ฐ๋ฒŒ ์‹œ๊ฐ„, ํƒ€์ž„์•„์›ƒ ์‹œ๊ฐ„ ๋“ฑ๋“ฑ์€ ์ƒ๋‹จ์— ํ•œ๋ฒˆ์— ์„ ์–ธํ•˜์—ฌ

์ˆ˜์น˜ ๋ณ€๊ฒฝ์„ ์šฉ์ดํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ฐœ์„ ํ–ˆ๋‹ค.

 

๊ทธ๋ฆฌ๊ณ , ๊ธฐ์กด ํ”Œ๋Ÿฌ๊ทธ์ธ์˜ ํƒ€์ผ ์˜จ๋„๊ฐ€ ์ง๊ด€์ ์ด์ง€ ์•Š์•„์„œ

15~50๋„(5๋„์”ฉ) > 36๋„~42๋„๋กœ ๋ณ€๊ฒฝํ–ˆ๋‹ค.

const CONFIG = {
    // Timeout, Interval
    RECONNECT_DELAY_MS: 20000,    // ์—ฐ๊ฒฐ ์œ ์‹ค ์‹œ ์žฌ์‹œ์ž‘๊นŒ์ง€ ๋Œ€๊ธฐ (๊ธฐ๊ธฐ ์„ธ์…˜ ์ •๋ฆฌ ์‹œ๊ฐ„)
    GATT_TIMEOUT_MS: 10000,       // GATT ์„œ๋ฒ„ ๋ฐ ์„œ๋น„์Šค ํƒ์ƒ‰ ํƒ€์ž„์•„์›ƒ
    PING_INTERVAL_MS: 30000,      // Keep-alive ํ•‘ ์ฃผ๊ธฐ
    ...

    // Wait Time
    GATT_WAIT_MS: 3000,           // ์—ฐ๊ฒฐ ์„ฑ๊ณต ํ›„ GATT ํƒ์ƒ‰ ์ „ ๋Œ€๊ธฐ (๊ธฐ๊ธฐ ์•ˆ์ •ํ™”)
    AUTH_WAIT_MS: 1000,           // ์ธ์ฆ ํŒจํ‚ท ์ „์†ก ํ›„ ๋Œ€๊ธฐ
    ...

    LEVEL_TEMP_MAP: { 0: 0, 1: 36, 2: 37, 3: 38, 4: 39, 5: 40, 6: 41, 7: 42 },
    DEFAULT_HEAT_TEMP: 38,
    BRIGHTNESS_PER_HOUR: 100 / 15
};
 

 

๊ทธ ์™ธ์˜ ์ œ์–ด ํŒจํ‚ท ์ƒ์„ฑ ๋ฐ ์ „์†ก์ด๋‚˜ ์•…์„ธ์„œ๋ฆฌ ์„ ์–ธ ๋“ฑ์€

์ด์ „ ๊ฒŒ์‹œ๊ธ€์—์„œ ๊ตฌํ˜„ํ–ˆ๋˜ ๋กœ์ง๊ณผ ํฌ๊ฒŒ ๋‹ค๋ฅด์ง€ ์•Š์•„์„œ ์ƒ๋žตํ•˜๋„๋ก ํ•˜๊ฒ ๋‹ค.

 

 

ํ…Œ์ŠคํŠธ

 

๊ธฐ์กด์—๋Š” ์—ฐ๊ฒฐ๋„ ๋ ๊นŒ ๋ง๊นŒ ํ–ˆ๊ณ , ์ œ์–ด ์‹คํŒจ์œจ์ด 70ํผ์„ผํŠธ๊ฐ€ ๋„˜์—ˆ๋Š”๋ฐ,

์ˆ˜๋™ ์ œ์–ด๋„ ํ•ด๋ณด๊ณ  ๋ชจ๋“  ๋™์ž‘์„ ์š”์ฒญ ํ•ด๋„ ๋Š๊น€์ด ์—†์—ˆ๋‹ค.

๋ฌด์—‡๋ณด๋‹ค 3์ผ์งธ ์ง€์† ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋„ ๋Š๊น€์ด ์ „ํ˜€ ์—†์—ˆ๋‹ค.

 

๋งˆ์ง€๋ง‰์œผ๋กœ ์ƒˆ๋กœ ๊ตฌํ˜„ํ•œ ๊ธฐ๊ธฐ ์ˆ˜๋™์กฐ์ž‘ ํ…Œ์ŠคํŠธ๋ฅผ ํ•ด๋ณด์ž

 

๊ธฐ๊ธฐ๋ฅผ ์ˆ˜๋™์œผ๋กœ ์กฐ์ž‘ํ•ด๋ณด๋ฉด,

ํ™ˆ๋ธŒ๋ฆฟ์ง€ ์•…์„ธ์„œ๋ฆฌ์—๋„ ์ •์ƒ์ ์œผ๋กœ ๋™๊ธฐํ™” ๋˜๋Š” ๋ชจ์Šต์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

 

์ด๋ ‡๊ฒŒ, ๋ถˆ์™„์ „ํ–ˆ๋˜ ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์™„์„ฑํ•˜๊ฒŒ ๋˜์—ˆ๋‹ค.

๋ช‡ ๋‹ฌ๊ฐ„ ๋ธ”๋ฃจํˆฌ์Šค์— ๋Œ€ํ•ด ์ƒ…์ƒ…ํžˆ ๋’ค์ง€๋ฉฐ ๊ณต๋ถ€ํ•ด๋ณด๊ณ ,

ํŒจํ‚ท๋„ ๋ถ„์„ํ•˜๋ฉด์„œ ๊ณ ์ƒ์ข€ ํ–ˆ์—ˆ๋Š”๋ฐ, ์™„์„ฑํ•˜๋‹ˆ๊นŒ ๋„ˆ๋ฌด ๋ฟŒ๋“ฏํ–ˆ๋‹ค.

๋‹ค์Œ์—๋Š” ๋” ๋‚œ์ด๋„ ์žˆ๋Š” ์ž‘์—…๋„ ๋„์ „ํ•ด ๋ด์•ผ๊ฒ ๋‹ค.

 

 

์›๋ณธ ๊ฒŒ์‹œ๊ธ€

https://blog.naver.com/101artspace/224149380892

 

[JS] Homebridge ์ „๊ธฐ๋งคํŠธ ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ฐœ์„ 

์ด๋ฒˆ์—๋Š” ์ €๋ฒˆ์— ๋งŒ๋“  ์ „๊ธฐ๋งคํŠธ ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ๊ฐœ์„ ํ•ด๋ณด๋ ค๊ณ  ํ•œ๋‹ค. https://blog.naver.com/101artspace/22405...

blog.naver.com

 

๋ฐ˜์‘ํ˜•