# homebridge-smartthings-km81

삼성 에어컨·세탁기·건조기를 **HomeKit**에 연결하는 Homebridge 플러그인입니다.

가장 큰 특징은 **로컬 제어**입니다. 대부분의 기기를 SmartThings 클라우드를 거치지 않고 집 안 네트워크에서 직접 제어합니다. 인터넷이 끊겨도 동작하고, 반응이 빠르며, 클라우드 API 사용량에 영향을 받지 않습니다.

---

## 지원 기기

| 기기 | 통신 방식 | 클라우드 |
|---|---|---|
| 삼성 에어컨 (2016~2018년경, 2in1 포함) | 로컬 TCP 8888 | 불필요 |
| 삼성 에어컨 (2023년 이후) | 로컬 CoAP over DTLS | 선택 |
| 삼성 건조기 | 로컬 CoAP over DTLS 또는 클라우드 | 선택 |
| 삼성 세탁기 (2-in-1 포함) | 로컬 TCP 8888 또는 클라우드 | 선택 |
| 삼성 정수기 | 로컬 CoAP over DTLS | 불필요 |

HomeKit에는 이렇게 보입니다.

- **에어컨** — 냉난방기 (전원·온도·모드·무풍·잠금)
- **세탁기·건조기** — 스프링클러 밸브(남은 시간 카운트다운)
  - 운전이 끝나면 알림을 받고 싶으면 설정에서 **`종료 알림 센서 활성화`을 켜세요**(기본은 꺼짐).
    켜면 모션 센서가 하나 더 생기고, 그걸로 홈 앱 자동화를 걸 수 있습니다.
- **정수기** — ⚠️**홈킷에는 안 나옵니다.** 홈킷에 담을 값어치가 있는 건 잠금 정도인데,
  필터 잔여량·출수량 같은 계량값은 홈킷이 담는 그릇이 아닙니다(부피 특성이 아예 없어
  `조도 센서` 같은 **거짓 표시**로 우회해야 합니다). 그래서 **Home Assistant 로만 중계**합니다 —
  필터 잔여량·상태·출수 중·잠금 3종·온수 온도·출수량·살균 일정·자가진단.
  ⚠️MQTT 를 꺼 두면 이 기기는 **아무 데도 나가지 않습니다**(홈킷 액세서리가 없으니까요).

---

## 먼저 알아야 할 것 — 기기를 어떻게 지목하나

**구형 에어컨을 뺀 나머지 기기**(신형 에어컨·세탁기·건조기)는 IP만으로는 부족합니다.
플러그인이 "이 설정 항목이 어느 기기인지" 알아야 하는데, 그 수단이 둘 중 하나입니다.

| 방법 | 무엇을 적나 | 언제 쓰나 | SmartThings 연결 |
|---|---|---|---|
| **`장치 이름`** (`deviceLabel`) | SmartThings 앱에 보이는 이름을 **글자 하나까지 똑같이** | 처음 설정할 때 | ★**필요** |
| **`SmartThings deviceId`** | `a1b2c3d4-…` 형태의 고유 번호 | 이름으로 한 번 붙인 뒤 | 불필요 |

- **둘 다 비워 두면 그 기기는 만들어지지 않습니다.** 로그에 `deviceLabel이 비어있는 SmartThings 장치 설정을 건너뜁니다`가 뜹니다.
- ⚠️★**`장치 이름`으로 찾는 것은 곧 SmartThings 클라우드 조회입니다** — 그래서 이름만 적는
  방식은 [OAuth 연결](#smartthings-클라우드를-쓸-때)이 **먼저** 끝나 있어야 합니다.
  (구형 에어컨은 이 절과 무관합니다. IP와 토큰만 있으면 됩니다.)
- `장치 이름`만 적어도 동작하지만, 그러면 부팅할 때마다 SmartThings에 목록을 물어봅니다.
- 한 번 붙고 나면 로그에 이런 줄이 나옵니다:

  ```
  ↳ deviceId=a1b2c3d4-… — config에 적어두면 다음 부팅부터 클라우드 조회를 건너뜁니다
  ```

  이 값을 `SmartThings deviceId`에 적어 두세요. 그 뒤로는 **부팅할 때 클라우드를 아예 부르지 않습니다.**

> 구형 에어컨은 SmartThings와 무관하게 IP+토큰으로만 동작하므로 이 항목이 없습니다.

---

## 설치

Homebridge UI의 플러그인 검색에서 `homebridge-smartthings-km81`을 설치하거나:

```bash
npm install -g homebridge-smartthings-km81
```

> **도커로 홈브릿지를 쓰신다면** `-g`는 홈브릿지가 보지 않는 곳에 설치될 수 있습니다.
> 그럴 땐 컨테이너 안에서 이렇게 설치하세요.
>
> ```bash
> npm install --prefix /var/lib/homebridge homebridge-smartthings-km81
> ```

설치 후 Homebridge UI의 플러그인 설정 화면에서 기기를 추가합니다. 모든 항목에 한국어 설명이 붙어 있습니다.

---

## 기기별 설정

### 구형 에어컨 (2016~2018년경)

`장치 종류`를 **구형 에어컨**으로 고르고 아래를 채웁니다.

| 항목 | 값 |
|---|---|
| 이름 | HomeKit에 표시할 이름 |
| 에어컨 IP | 공유기에서 고정 IP로 잡아두세요 |
| 인증 토큰 | [토큰 추출](#기기-토큰-추출하기) 참고 |

**냉방 버튼을 누르면 무엇이 되나** — 홈킷 에어컨 타일에는 냉방·난방·자동만 있습니다(북미 냉난방
시스템 기준). 한국 냉방기에 맞춰 이 플러그인은 **냉방만** 쓰고, 그 버튼이 실제로 어떤 모드를
보낼지 `냉방 버튼 → 보낼 모드`에서 고릅니다.

| 고를 수 있는 값 | 기기 동작 |
|---|---|
| 냉방 / 냉방청정 | 냉방(청정은 공기청정 동반) |
| 제습 / 제습청정 | 제습 |
| **송풍** | 바람만 — 냉방하지 않습니다 |
| **자동** | 기기가 알아서 (삼성 앱의 자동 계열 모드) |

> 송풍·자동은 냉방을 하지 않으므로 **홈킷에서 온도를 바꿔도 의미가 없을 수 있습니다.**
> 전원이 켜져 있으면 홈킷 타일은 어느 모드든 '냉방 중'으로 표시됩니다.
> 기기가 지원하지 않는 모드를 고르면 로그가 **지원 목록과 함께** 알려 줍니다.

### 2in1 (실외기 하나에 실내기 둘) — ★읽기/쓰기 인덱스가 교차인 기기가 있습니다

**같은 IP로 항목을 두 개** 만들고, 각 항목에 `장치 인덱스 (읽기)`와 `장치 인덱스 (쓰기)`를 넣습니다.
기기가 **상태를 보고하는 순서**와 **명령을 받는 순서**가 모델에 따라 **반대**인 경우가 있어 둘이 나뉘어 있습니다.

#### 먼저 이 조합으로 해 보세요

쓰기를 읽기와 **교차**로 둔 조합입니다.

| 항목 | `장치 인덱스 (읽기)` | `장치 인덱스 (쓰기)` |
|---|---|---|
| 거실 에어컨 | **1** | **0** |
| 침실 에어컨 | **0** | **1** |

```json
{ "deviceType": "legacyAc", "name": "거실 에어컨",
  "ip": "192.168.1.3", "token": "…",
  "deviceIndex": 1, "setDeviceIndex": 0 },
{ "deviceType": "legacyAc", "name": "침실 에어컨",
  "ip": "192.168.1.3", "token": "…",
  "deviceIndex": 0, "setDeviceIndex": 1 }
```

> 같은 IP·같은 토큰을 두 항목에 그대로 넣고, **두 인덱스만 다르게** 둡니다.

★**이대로 안 되면 반대로 해 보세요** — 방마다 숫자를 맞바꾸는 것입니다
(거실 읽기 0/쓰기 1, 침실 읽기 1/쓰기 0). 어느 방이 인덱스 0인지는 실외기에 실내기를 물린
**설치 시점 배선**에 달려 있어 집마다 다릅니다. 두 조합 중 하나는 맞습니다.

#### 인덱스가 틀렸을 때 나타나는 증상

아래 중 하나라도 보이면 인덱스 조합이 맞지 않는 것입니다 — 위의 "반대로" 조합을 먼저 시도하세요.

- 홈 앱에서 **엉뚱한 방이 켜진다**
- 상태(온도·전원)는 잘 보이는데 **제어가 안 된다**
- 켜기를 눌러도 반응이 없고, 로그에 `전원 → 켜짐`은 있는데 그 뒤 `전송 →` 줄이 **없다**
- 로그에 `켜기가 반영되지 않았습니다` + `쓰기 인덱스가 읽기와 교차일 수 있습니다` 경고가 뜬다

> 왜 이런 증상이 되나: 쓰기가 옆 방으로 나가면 **자기 상태가 안 바뀝니다.**
> 그러면 다음에 반대 방향을 눌렀을 때 플러그인이 "이미 그 상태네" 하고 명령을 생략해
> 아무 일도 일어나지 않습니다.

#### 두 조합 다 안 될 때 — 시험 2개로 직접 정하기 (읽기와 쓰기는 **따로** 확인합니다)

위 두 조합은 "쓰기가 교차"라는 전제를 공유합니다. **읽기만 교차**이거나 **둘 다 정상**인 기기도
있을 수 있으므로, 그때는 아래로 각각 판정하세요.

**시험 ① 읽기** — 리모컨으로 **한쪽 방만** 켠 뒤, 홈 앱에서 어느 타일이 켜지는지 봅니다.

| 결과 | 판정 |
|---|---|
| 켠 방의 타일이 켜짐 | 읽기 정상 — 그대로 |
| **반대 방** 타일이 켜짐 | 읽기 교차 — 두 항목의 **읽기** 인덱스를 서로 바꿈 |

**시험 ② 쓰기** — 홈 앱에서 한쪽 타일을 켠 뒤, 실제로 어느 방이 켜지는지 봅니다.

| 결과 | 판정 |
|---|---|
| 누른 타일의 방이 켜짐 | 쓰기 정상 — 그대로 |
| **반대 방**이 켜짐 (또는 아무 반응 없음) | 쓰기 교차 — 두 항목의 **쓰기** 인덱스를 서로 바꿈 |

두 시험은 서로 독립입니다 — 읽기만 교차, 쓰기만 교차, 둘 다 교차가 모두 가능합니다.

### 신형 에어컨·시스템 에어컨·건조기 (2023년 이후)

시스템 에어컨은 `장치 종류`에서 **시스템 에어컨**을 고릅니다. 적는 항목과 동작은
신형 에어컨과 같습니다.

**스윙 토글로 무엇을 켤지** — `스윙(Swing) 토글 ↔ 기능`에서 고릅니다. 기기 종류마다 목록이 다릅니다.

| 장치 종류 | 고를 수 있는 값 |
|---|---|
| 구형 에어컨 | 무풍 / **회전** / 사용 안 함 |
| 신형 에어컨 | 무풍 / **회전** / 사용 안 함 |
| 시스템 에어컨 | 무풍 / **상하좌우** / **상하 바람** / **좌우 바람** / 사용 안 함 |

> **회전**은 기기에 물어 지원하는 방향으로 돕니다 — 어느 방향인지 몰라도 됩니다.
> 시스템 에어컨은 보통 여러 방향을 지원하므로 직접 고르게 해 두었습니다.
> 토글을 끄면 항상 **고정**을 보냅니다. 기기가 지원하지 않는 방향을 고르면 로그가
> 지원 목록과 함께 알려 줍니다.
>
> ⚠️바람방향은 **전송 경로가 로컬일 때만** 동작합니다 — SmartThings 클라우드에는 이 기능이 없습니다.

> 시스템 에어컨은 홈 앱에서 온도를 **0.5℃ 단위**로 맞춥니다(신형·구형 에어컨은 1℃).
> 기기가 1℃ 단위여도 문제되지 않습니다 — 플러그인이 기기에 맞는 값으로 바꿔 보냅니다.

> ⚠️**이 종류를 쓰다가 2.7.x 이하로 되돌리려면, 먼저 `장치 종류`를 신형 에어컨으로 바꾸세요.**
> 구버전은 `시스템 에어컨`을 모르기 때문에 그 기기를 "설정에 없는 것"으로 보고, 홈킷에 남아
> 있던 액세서리를 **경고 없이 지웁니다.** 그러면 그 기기에 걸어 둔 홈 앱 자동화와 방 배치가
> 함께 사라집니다. (2.8.0부터는 반대 방향 — 모르는 종류를 만나면 지우지 않고 경고합니다.)

> 온도를 읽고 쓰는 리소스 경로는 보드마다 다릅니다. 천장형과 일부 벽걸이는 표준 경로가
> 없고 제조사 경로만 있습니다. 플러그인이 첫 조회 때 기기에 물어 판별하므로 설정할 것은
> 없습니다. 제조사 경로를 쓰게 되면 로그에 한 줄 남습니다.
>
> ```
> [거실 에어컨] 이 기기에는 표준 온도 리소스가 없어 제조사 경로(/temperatures/vs/0)를 씁니다
> ```

설정 방법이 두 가지입니다. **로컬로만 쓸 거라면 A가 짧습니다.**

| | A. 로컬 전용 | B. 클라우드 폴백까지 |
|---|---|---|
| 적을 것 | **기기 IP만** | 장치 이름 + 기기 IP |
| SmartThings 연결 | **불필요** | 필요 |
| 로컬이 실패하면 | 그 기기는 제어되지 않음 | 클라우드로 넘어감 |

#### A. 로컬 전용 — 기기 IP만

`장치 종류`를 **신형 에어컨**·**시스템 에어컨**·**건조기** 중에서 고르고, `전송 경로`를 **로컬**,
`기기 IP`를 채운 뒤 **`로컬 실패 시 클라우드 사용`을 끕니다.** 장치 이름과 deviceId는 비워 둡니다.

플러그인이 부팅할 때 그 IP의 기기에 물어 `deviceId`를 알아냅니다. 로그에 이렇게 나옵니다.

```
SmartThings 연결 없이 로컬 전용으로 동작합니다 (클라우드 호출 0회).
[신형 에어컨] deviceId를 기기에서 확인했습니다 — Samsung Window A/C
```

한 번 알아낸 값은 저장되므로, 이후 부팅에서는 기기가 꺼져 있어도 됩니다.
`clientId`·`clientSecret`·`redirectUri`는 비워 두면 됩니다.

> ⚠️**세탁기는 이 방식이 안 됩니다.** 8888 토큰 방식이라 기기에 물어보는 경로가 없습니다.
> 세탁기는 B를 쓰거나 deviceId를 직접 적어야 합니다.

#### B. 클라우드 폴백까지 — 전체 흐름

```
1단계  SmartThings OAuth 연결          ← 계정당 1회. 아래 'SmartThings 클라우드를 쓸 때' 절
   ↓
2단계  장치 이름 + 기기 IP 입력 → 재시작   ← 이름으로 기기를 찾아 붙습니다
   ↓
3단계  로그에서 deviceId를 복사해 설정에 붙여넣기   ← 이제 부팅 때 클라우드 조회 0회
```

2단계까지만 해도 동작합니다. 3단계는 부팅 시 클라우드 조회를 없애는 과정입니다.

#### 1단계 — SmartThings 연결

[SmartThings 클라우드를 쓸 때](#smartthings-클라우드를-쓸-때) 절을 먼저 끝내세요.
⚠️여기에 **https 리버스 프록시**가 필요합니다.

#### 2단계 — 기기 설정

`장치 종류`를 **신형 에어컨**·**시스템 에어컨**·**건조기** 중에서 고르고 아래를 채웁니다.

| 항목 | 값 |
|---|---|
| **장치 이름** | SmartThings 앱에 보이는 이름과 **글자 하나까지 똑같이**. 앱에서 이름이 겹치지 않게 해 두세요 |
| **전송 경로** | **로컬** |
| **기기 IP** | 공유기에서 고정 IP로 잡아두세요. 포트는 자동으로 찾습니다 |
| SmartThings deviceId | **2단계에서는 비워 둡니다.** 3단계에서 채웁니다 |

기기 토큰은 필요 없습니다(구형과 다른 점).

**준비물 3가지** — 이게 없으면 로컬이 안 붙습니다:

1. ★**1단계(SmartThings 연결)가 끝나 있을 것.** 이름으로 기기를 찾는 것이 곧 클라우드 조회입니다.
   OAuth 없이 이름만 적으면 `'…'에 해당하는 장치를 SmartThings에서 찾지 못했습니다`가 뜹니다.
2. ★**파이썬 3.11 이상 + pip** — 신형 기기는 DTLS로 통신하는데 Node에 DTLS가 없어 파이썬 도우미를 씁니다.
   플러그인이 **첫 기동 때 자동으로 설치**합니다(`smartthings-local`).
   ⚠️**3.11 미만이면 설치가 안 됩니다** — 라즈베리파이 OS Bullseye(3.9)·구형 데비안이 여기 해당합니다.
   새 파이썬을 깐 뒤 설정 `localPythonBin`에 그 경로(예: `/usr/bin/python3.12`)를 지정하세요.
3. **처음 한 번의 인터넷** — 두 곳에 접속합니다: **PyPI**(파이썬 패키지)와
   **`connect-v2.samsungiotcloud.com:443`**(기기와 통신할 인증서 발급).
   기기를 격리 VLAN에 두셨다면 이 두 곳이 열려야 합니다. 한 번 끝내면 그 뒤로는 인터넷 없이 동작합니다.

#### 3단계 — deviceId를 받아 적기

2단계로 재시작하면 로그에 이 줄이 나옵니다.

```
'승준 에어컨' (smartAc) HomeKit 추가/갱신
  ↳ deviceId=3ea2a924-…  — config에 적어두면 다음 부팅부터 클라우드 조회를 건너뜁니다
```

이 값을 `SmartThings deviceId` 칸에 붙여넣고 재시작하면, 그 뒤로는 부팅할 때
**클라우드를 부르지 않습니다.**

#### OAuth 항목이 필요 없는 조건

`clientId`·`clientSecret`·`redirectUri`는 **클라우드를 실제로 쓰는 기기가 하나라도 있을 때만**
필요합니다. 아래를 모두 만족하는 기기만 있으면 비워 둬도 됩니다.

- `전송 경로`가 **로컬**이고 `기기 IP`가 적혀 있다
- `로컬 실패 시 클라우드 사용`이 꺼져 있다
- (세탁기처럼 8888 토큰을 쓰는 기기라면) `SmartThings deviceId`도 적혀 있다

하나라도 어긋나면 OAuth 항목이 필요하다는 오류가 뜨고, 어떻게 하면 안 필요한지 함께 나옵니다.

**파이썬 도우미와 인증서는 어디에 생기나** — 홈브릿지 저장 폴더 밑 `.km81-local/`입니다
(도커면 `/homebridge/.km81-local`, 라즈베리파이 등에 직접 설치하셨으면 `/var/lib/homebridge/.km81-local`).
`node_modules` 밖이라 플러그인을 업데이트해도 남습니다. 다른 곳에 두려면 설정 `localStateDir`.
파이썬 버전을 바꾸면(예: `localPythonBin` 변경) 의존성을 다시 설치합니다.

> **로컬이 안 붙을 때 — 이 로그를 먼저 보세요**
>
> | 로그 | 뜻과 조치 |
> |---|---|
> | `로컬 경로 의존성 최초 설치 — 잠시 걸립니다` → `설치됨` | 정상입니다 |
> | `파이썬이 시스템 보호(PEP 668)로 설치를 막았습니다` | 플러그인이 **자동으로 다시 시도**합니다. 그냥 두세요 |
> | `파이썬 3.11 이상이 필요합니다` | 새 파이썬 설치 후 `localPythonBin`에 경로 지정 |
> | `설치할 수 있는 버전을 찾지 못했습니다` | 파이썬 버전이 낮습니다. 위와 같이 조치하세요 |
> | `권한 문제로 실패` | `localStateDir`에 홈브릿지가 쓸 수 있는 폴더를 지정 |
> | `파이썬을 찾을 수 없습니다` | 파이썬을 설치하거나, 설정 `localPythonBin`에 실행 경로를 지정 |
> | `파이썬에 pip이 없습니다` | `python3 -m ensurepip --upgrade` 또는 `apt install python3-pip` |
> | `네트워크 문제로 실패` | 최초 1회는 PyPI 접속이 필요합니다. 연결 후 재시작 |
> | `의존성 설치 실패 (코드 N) — pip 출력: …` | 그 pip 출력이 원인입니다. 안내된 명령을 직접 돌려보세요 |
> | `인증서 발급 실패: Hash algorithm "sha1" not supported…` | **v2.6.7에서 해결**됐습니다. 업데이트하세요 |
>
> 첫 기동에서 `로컬 경로 최초 설치가 진행 중입니다`가 보이면 **정상**입니다 — 몇 분 뒤 자동으로
> 로컬 제어로 전환됩니다. 설치가 실패해도 **다음 재시작에서 자동으로 다시 시도**합니다.
> 그동안 `로컬 실패 시 클라우드 사용`이 켜져 있으면 클라우드로 계속 동작하고,
> **꺼져 있으면 그 기기는 제어되지 않습니다**(홈 앱에 `응답 없음`).

### 세탁기

`장치 종류`를 **세탁기**로 고릅니다.

| 항목 | 값 |
|---|---|
| 장치 이름 / SmartThings deviceId | [위 절](#먼저-알아야-할-것--기기를-어떻게-지목하나) 참고 — **하나는 반드시 필요** |
| 전송 경로 | **로컬** |
| 기기 IP | 공유기에서 고정 IP로 잡아두세요 |
| 기기 토큰 | [아래](#기기-토큰-추출하기)에서 얻습니다 |

- 토큰을 비워두면 클라우드로 동작합니다.
- ★**세탁기는 전원을 끄면 네트워크에서 사라집니다.** 그래서 꺼져 있는 동안 로그에
  `전원 꺼짐 — 로컬 응답 없음`이 한 줄 뜨는 것이 **정상**이고, 홈 앱에는 '완료' 상태로 보입니다.
  설정 직후 세탁기가 꺼져 있다면 이 줄이 곧 "제대로 붙었다"는 신호입니다.
- **2-in-1**(애드워시+콤팩트워시)은 기본적으로 하나로 합쳐 보이고, 둘 중 하나만 돌아도 "가동 중"으로 표시합니다. `세탁조를 따로 표시`를 켜면 각각 별도 액세서리가 됩니다.

### SmartThings 클라우드를 쓸 때

로컬로 붙지 않는 기기, 또는 로컬 실패 시 폴백을 쓰려면 SmartThings OAuth가 필요합니다.

> **처음 설치할 때는 대체로 한 번은 필요합니다.**
> 신형 에어컨·세탁기·건조기는 `SmartThings deviceId`로 지목하는 게 가장 깔끔한데, 그 ID를 알려면
> 한 번은 SmartThings에 물어봐야 하기 때문입니다(로그가 알려줍니다 — [위 절](#먼저-알아야-할-것--기기를-어떻게-지목하나)).
>
> 한 번 받아 적은 뒤에 **모든 기기를 로컬로 쓰고 폴백도 전부 끄면**, 그때부터는
> 클라우드를 아예 부르지 않습니다(토큰 유지용 하루 1회 갱신도 자동으로 꺼집니다).
> 다만 OAuth 설정을 지우면 시작할 때 "인증이 필요합니다" 안내가 계속 뜹니다 — 동작에는 지장 없습니다.

#### 먼저 알아야 할 것 — Redirect URI는 반드시 `https`

SmartThings는 Redirect URI로 **`https://`만 받습니다.** `http://192.168.0.10:8999/callback` 같은 주소는 **등록 자체가 거부됩니다.**

그런데 이 플러그인이 띄우는 인증 서버는 **평문 HTTP, 포트 8999 고정**입니다. 그래서 둘을 이어 줄 것이 필요합니다.

```
SmartThings ──https──▶ 리버스 프록시 ──http──▶ 홈브릿지 :8999
             (인터넷)   (TLS 종료)              (인증 서버)
```

**리버스 프록시**(Nginx Proxy Manager, Caddy, Cloudflare Tunnel 등)로 도메인 하나를 8999로 넘기면 됩니다. 이미 홈브릿지 UI를 외부에서 https로 쓰고 있다면 같은 방식으로 하나 더 만들면 됩니다.

인증은 **한 번만** 하면 되므로, 프록시를 상시 두기 싫다면 인증할 때만 잠깐 열었다 닫아도 됩니다.

#### 절차

1. [SmartThings 개발자 워크스페이스](https://smartthings.developer.samsung.com/workspace/)에서 **New Project → Device Integration → SmartThings Cloud Connector → OAuth-In**을 만듭니다.
   (터미널이 편하면 `npm i -g @smartthings/cli` 뒤 `smartthings apps:create`로도 같은 것을 만들 수 있습니다.)

2. 권한(Scope)은 **세 개 모두** 선택합니다.
   - `r:devices:*` (상태 읽기)
   - `w:devices:*` (설정 쓰기)
   - `x:devices:*` (명령 실행)
   > 하나라도 빠지면 인증은 되는데 제어가 안 됩니다.

3. **Redirect URI**를 등록합니다. 예: `https://homebridge.example.com/oauth/callback`
   - 경로(`/oauth/callback`)는 원하는 대로 정해도 됩니다. 플러그인은 **경로만 보고** 콜백을 받습니다.
   - 이 주소가 리버스 프록시를 거쳐 **홈브릿지의 8999 포트**에 닿아야 합니다.

4. 발급된 **Client ID**와 **Client Secret**, 그리고 방금 등록한 **Redirect URI**를 플러그인 설정에 그대로 넣습니다. 세 값은 워크스페이스에 등록한 것과 **글자 하나까지 같아야** 합니다.

5. Homebridge를 재시작하면 로그에 이런 안내가 나옵니다.

   ```
   ====================[ 스마트싱스 인증 필요 ]====================
   1. 임시 인증 서버가 포트 8999에서 실행 중입니다.
   2. 아래 URL을 복사하여 웹 브라우저에서 열고 …
   인증 URL: https://api.smartthings.com/oauth/authorize?client_id=…
   ```

6. 그 **인증 URL을 브라우저에서 열고** SmartThings 계정으로 로그인해 권한을 허용합니다. 승인하면 브라우저가 Redirect URI로 이동하고, 플러그인이 토큰을 받아 저장합니다. 이후에는 자동으로 갱신되므로 다시 할 일이 없습니다.

#### 인증이 안 될 때

| 증상 | 원인과 조치 |
|---|---|
| 워크스페이스가 Redirect URI를 거부 | `https`가 아니거나 IP 주소입니다. 도메인 + https로 등록하세요. |
| 승인 후 브라우저가 오류 페이지 | 그 도메인이 홈브릿지 8999에 닿지 않는 것입니다. 프록시 설정을 확인하세요. |
| **브라우저는 성공했는데 로그가 조용함** | 콜백이 홈브릿지까지 못 온 것입니다. 10분 뒤 로그에 `콜백이 오지 않았습니다` 안내가 뜹니다 — 프록시가 그 주소를 8999로 넘기는지 확인하세요. |
| 로그에 `포트 8999를 사용할 수 없습니다` | 다른 프로세스가 8999를 쓰고 있습니다. |
| 승인은 됐는데 기기 제어가 안 됨 | 권한 세 개를 다 골랐는지 확인하고, 워크스페이스에서 고친 뒤 다시 인증하세요. |
| `redirectUri가 유효한 URL 형식이 아닙니다` | 설정에 넣은 값에 오타가 있거나 `https://`가 빠졌습니다. |

---

## 기기 토큰 추출하기

TCP 8888로 통신하는 기기(구형 에어컨, 세탁기)는 **기기가 발급하는 토큰**이 있어야 합니다. 한 번 받으면 계속 쓸 수 있습니다.

### 원리

기기에 "토큰을 달라"고 요청하면, 기기가 **당신의 컴퓨터로 되전화를 걸어** 토큰을 건네줍니다. 그래서 두 가지가 필요합니다.

- 되전화를 받을 **수신 대기 프로그램** (포트 8889)
- 요청할 때 **어디로 걸어야 하는지 알려 주는 것** — `Host` 헤더

> ⚠️ 이 `Host` 헤더를 빠뜨리면 기기가 자기 자신에게 요청을 보내고 토큰이 오지 않습니다.

### 준비물

- 기기와 **같은 네트워크**에 있는 컴퓨터 (요청과 수신을 **같은 컴퓨터에서** 해야 합니다)
- Python 3
- 플러그인에 들어 있는 인증서 — `node_modules/homebridge-smartthings-km81/cert/cert.pem`

아래 두 스크립트를 그 인증서와 **같은 폴더**에 두고 실행하세요.

### 1단계 — 수신 대기

`listener.py`로 저장하고 실행합니다.

```python
import re, socket, ssl, threading

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1
ctx.set_ciphers('ALL:@SECLEVEL=0')
ctx.verify_mode = ssl.CERT_NONE
ctx.load_cert_chain('cert.pem')

def handle(sock):
    data = b''
    sock.settimeout(10)
    try:
        while b'}' not in data and len(data) < 65536:
            chunk = sock.recv(4096)
            if not chunk:
                break
            data += chunk
    except Exception:
        pass
    m = re.search(r'"DeviceToken"\s*:\s*"([^"]+)"', data.decode('utf-8', 'replace'))
    if m:
        print('\n★ 토큰:', m.group(1), '\n')
    sock.sendall(b'HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
    sock.close()

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('0.0.0.0', 8889))      # bind가 먼저 — 실패하면 여기서 즉시 멈춥니다
srv.listen(5)
print('대기 중 — 0.0.0.0:8889')
while True:
    raw, addr = srv.accept()
    print('연결 수신:', addr[0])
    try:
        conn = ctx.wrap_socket(raw, server_side=True)
    except Exception as e:
        print('TLS 실패:', e)
        continue
    threading.Thread(target=handle, args=(conn,), daemon=True).start()
```

```bash
python3 listener.py
```

`대기 중` 문구가 떠야 합니다. 안 뜨면 8889를 다른 프로그램이 쓰고 있는 것이니 정리하고 다시 실행하세요.

> 기기에 따라 **TLS가 아니라 평문으로** 되전화를 거는 경우가 있습니다.
> `연결 수신`은 찍히는데 곧바로 `TLS 실패`가 뜬다면 그 경우입니다.
> 그럴 땐 위 코드에서 `ctx.wrap_socket(...)` 줄을 빼고 `conn = raw`로 바꿔 한 번 더 시도해 보세요.

### 2단계 — 기기 준비

**에어컨** — 전원을 **끕니다** (콘센트는 그대로).

**세탁기** — 전원을 켜고, 문을 닫고, 패널의 **스마트 컨트롤(원격 제어) 버튼을 짧게** 눌러 램프를 켭니다.

> ⚠️ 3초 이상 길게 누르면 Wi-Fi 페어링(AP) 모드로 들어가 네트워크에서 빠집니다. 그러면 껐다 켜고 다시 하세요.

### 3단계 — 토큰 요청

`request.py`로 저장하고, IP 두 개를 자기 환경에 맞게 고쳐 **새 터미널에서** 실행합니다.

```python
import ssl
from http.client import HTTPSConnection

DEVICE_IP   = '192.168.1.50'    # 기기 IP
LISTENER_IP = '192.168.1.100'   # 이 스크립트를 실행하는 컴퓨터 IP

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False                      # 순서 주의: verify_mode보다 먼저
ctx.verify_mode = ssl.CERT_NONE
ctx.minimum_version = ssl.TLSVersion.TLSv1
ctx.maximum_version = ssl.TLSVersion.TLSv1_2    # TLS 1.3을 보내면 기기가 멈춥니다
ctx.set_ciphers('DEFAULT@SECLEVEL=0')
ctx.load_cert_chain('cert.pem')

body = '{}'
conn = HTTPSConnection(DEVICE_IP, 8888, context=ctx, timeout=15)
conn.putrequest('POST', '/devicetoken/request', skip_host=True, skip_accept_encoding=True)
conn.putheader('Host', f'{LISTENER_IP}:8889')    # ★되전화 주소. 빠뜨리면 실패합니다
conn.putheader('Content-Type', 'application/json')
conn.putheader('DeviceToken', 'xxxxxxxxxxx')     # 그대로 두세요 (자리표시자)
conn.putheader('Content-Length', str(len(body)))
conn.endheaders(body.encode())
r = conn.getresponse()
print(r.status, r.reason)
```

```bash
python3 request.py
```

`200 OK`가 나오면 요청이 접수된 것입니다.

### 4단계 — 기기를 켭니다

**에어컨** — 전원을 켭니다.
**세탁기** — 전원을 껐다가 다시 켭니다.

몇 초 안에 수신 대기 창에 토큰이 찍힙니다.

```
연결 수신: 192.168.1.50

★ 토큰: aB3dEf7hIj
```

이 값을 플러그인 설정의 `인증 토큰`(에어컨) 또는 `기기 토큰`(세탁기)에 넣으면 됩니다.

### 잘 안 될 때

| 증상 | 원인과 조치 |
|---|---|
| `403 … previous request` | 직전 요청이 처리 중입니다. **정상**이니 1분 기다렸다 다시 하세요. |
| `200 OK`인데 토큰이 안 옴 | `Host` 헤더의 IP가 **수신 대기 중인 컴퓨터**의 것이 맞는지 확인하고, 4단계(전원 껐다 켜기)를 다시 하세요. |
| 수신 창에 아무 연결도 안 잡힘 | 방화벽이 8889 인바운드를 막는지, 컴퓨터와 기기가 같은 네트워크인지 확인하세요. |
| 연결 자체가 안 됨 | 기기가 켜져 있는지, IP가 맞는지 확인하세요. 세탁기는 전원을 끄면 네트워크에서 사라집니다. |
| `curl`로는 실패함 | 최신 `curl`은 TLS 1.0을 거부합니다. 위 Python 스크립트를 쓰세요. |

---

## MQTT 브리지 (Home Assistant 중계)

로컬로 제어 중인 기기의 상태를 MQTT 브로커로 내보내, Home Assistant가 **자동 검색**으로
엔티티를 만들게 합니다. HA는 기기에 직접 붙지 않습니다 — 기기와의 세션(신형 DTLS·구형 8888)은
이 플러그인이 단독으로 소유하고, HA는 브로커만 봅니다. 세션이 기기당 하나뿐이라 HA가 직접 붙으면
홈브릿지와 서로 끊기기 때문입니다.

### 먼저 필요한 것

- **MQTT 브로커** — 예: Mosquitto (Home Assistant 애드온으로 한 번에 깔거나, 도커로 띄웁니다).
  브로커에 **이 플러그인용 계정**(사용자 이름/비밀번호)을 하나 만들어 두세요.
- **HA에 MQTT 통합이 연결돼 있어야** 자동 검색이 동작합니다 (HA 설정 → 기기 및 서비스 → MQTT).

### 설정

| 항목 | 값 |
|---|---|
| MQTT 브리지 사용 | 켬 (기본은 꺼짐 — 꺼져 있으면 아무 것도 하지 않습니다) |
| 브로커 주소 | 브로커가 도는 서버의 IP (보통 HA/NAS와 같은 주소) |
| 브로커 포트 | 기본 1883 그대로 |
| 사용자 이름 / 비밀번호 | **브로커에 만든 계정**입니다 (HA 로그인 계정이 아닙니다) |
| 기본 토픽 / HA 자동 검색 접두어 / 재발행 주기(초) | 기본값 그대로 두면 됩니다 |

저장하고 재시작하면 로그에 `MQTT 브리지 연결됨`과 기기별 `중계 시작`이 뜨고,
잠시 뒤 HA의 MQTT 통합 아래에 기기들이 **자동으로** 나타납니다. HA 쪽에서는 아무 설정도 필요 없습니다.

브로커가 없거나 값이 잘못돼도 **홈킷 동작에는 영향이 없습니다** — 이 브리지는 곁가지이고,
실패는 자기 선에서 끝납니다.

- **에어컨**: 켜기/끄기·온도·무풍·자동건조·`디스플레이 조명`을 HA에서 조작할 수 있습니다. HA 조작은 홈킷과
  **완전히 같은 경로**를 타므로, 끄기 억제 창·켜기 후속 순서 같은 안전 장치가 그대로 적용됩니다(조명만은 재점등
  위험이 없어 곧바로 전송). 모니터링 센서로 `순시 전력`·`누적 전력량`·`습도`·`필터 사용률`을 내보냅니다 —
  이 값들은 클라우드에 없고 로컬에서만 얻어지므로, 클라우드를 끊어도 유지됩니다.
- **세탁기·건조기**: **읽기 전용**입니다(기기가 원격 시작 명령을 받지 않습니다). `가동 중` 여부·`동작 상태`·`남은 시간`을
  내보냅니다. 건조기는 로컬에서 `진행률`과 `누적 전력량`도 얻어 함께 내보냅니다(세탁기는 구형 8888이라 이 둘은
  제공되지 않습니다). ★**세탁기는 전원을 끄면 네트워크에서 사라지는데, 그건 고장이 아니라 '대기 중' 상태**로
  표현됩니다 — HA에서 `사용 불가`로 뜨지 않습니다. `사용 불가`는 오직 이 플러그인(브리지)이 멈췄을 때만 뜹니다.

모니터링 센서는 로컬 기기(신형 DTLS)에서만 나오며, HomeKit 특성에 없는 값이라 플러그인이 직접 주기 조회해 발행합니다.
상태는 변화가 있을 때와 주기적으로(기본 60초) 함께 발행되므로, HA나 브로커가 재시작해도 값이 곧 다시 채워집니다.

> 참고: HA에서 만들어지는 엔티티 이름(entity_id)은 기기 표시 이름을 로마자로 옮긴 형태입니다(예: 승준 에어컨 →
> `climate.seungjun_eeokeon`). 설치 시점의 기기 이름에 따라 정해지며, 자동화에서 참조할 때는 실제 생성된
> entity_id를 확인해 쓰세요.

### 잘 안 될 때

| 증상 | 원인과 조치 |
|---|---|
| 로그: `브로커 주소(host)가 비어 있어 시작하지 않습니다` | 브리지를 켰는데 주소를 안 넣은 것입니다. 브로커 IP를 넣으세요 |
| 로그: `MQTT 브로커에 아직 접속하지 못했습니다` | 주소·포트·계정을 확인하세요. 브로커 쪽 방화벽/포트(1883)도 함께 |
| 연결은 됐는데 HA에 기기가 안 생김 | HA에 **MQTT 통합**이 있는지, `HA 자동 검색 접두어`가 HA 설정(기본 `homeassistant`)과 같은지 확인하세요 |
| 세탁기가 HA에서 `대기 중`으로만 보임 | 정상입니다 — 전원이 꺼져 있는 것입니다. 돌리면 `운전 중`으로 바뀝니다 |

---

## 자주 묻는 것

**클라우드 없이 쓸 수 있나요?**
네. 모든 기기에 `SmartThings deviceId`를 적고 로컬로 설정한 뒤 `로컬 실패 시 클라우드 사용`을 전부 끄면
SmartThings API를 **한 번도** 부르지 않습니다(토큰 유지용 하루 1회 갱신도 자동으로 꺼집니다).
다만 로컬이 실패했을 때 기댈 곳도 없어집니다.

**로컬과 클라우드를 섞어 쓸 수 있나요?**
네. 기기마다 따로 정합니다. 로컬로 두고 `로컬 실패 시 클라우드 사용`을 켜 두는 조합을 권합니다.
이 경우 플러그인이 하루 한 번 클라우드 토큰을 갱신해, 폴백이 정작 필요한 순간에 만료돼 있지 않도록 유지합니다.
읽기는 **한 번 실패했다고 바로 클라우드로 넘어가지 않고 다음 차례를 한 번 기다립니다** —
잠깐의 끊김 때문에 불필요한 클라우드 호출이 생기지 않게 하기 위해서입니다.
반대로 버튼을 눌러 보내는 명령은 기다리지 않고 즉시 폴백합니다.

**세탁기를 껐는데 HomeKit에 계속 "동작 중"으로 보입니다.**
세탁기는 전원을 끄면 네트워크에서 사라집니다. 플러그인은 잠시 기다렸다가 "꺼짐"으로 판단해 정리합니다.
운전 중이었다면 **일부러 몇 분 기다립니다** — 와이파이가 잠깐 끊긴 것을 "세탁 끝"으로 오인해
거짓 알림을 보내지 않기 위해서입니다.

**토큰이 만료되나요?**
기기를 초기화하지 않는 한 계속 유효합니다.

**기기 IP가 바뀌면?**
공유기에서 고정 IP(주소 예약)로 잡아두세요. 바뀌면 설정도 고쳐야 합니다.

**세탁기에서 코스나 온도를 바꿀 수 있나요?**
아니요. 기기가 원격 변경을 받지 않습니다. 상태 조회와 종료 알림만 됩니다.

---

## 로그 읽는 법

기기별 로그에는 앞에 `[기기 이름]`이 붙습니다. 자주 보게 되는 줄만 모았습니다.

### 정상입니다 (아무것도 안 해도 됩니다)

| 로그 | 뜻 |
|---|---|
| `[세탁기] 전원 꺼짐 — 로컬 응답 없음` | 세탁기 전원이 꺼져 있습니다. 하루에 한 번만 찍히고 조용해집니다 |
| `[세탁기] 전원 켜짐 — 로컬 연결됨` | 다시 켜져서 붙었습니다 |
| `[건조기] 포트 자동 탐지 → 49155` / `DTLS 포트 확인됨` | 신형 기기와 통신 준비 완료 |
| `[건조기] 로컬 복귀 — N회 실패 후 정상화` | 잠깐 클라우드로 돌다가 로컬로 돌아왔습니다 |
| `모든 SmartThings 기기가 config의 deviceId로 연결됨` | 부팅할 때 클라우드를 안 불렀다는 뜻 — 가장 좋은 상태입니다 |

### 확인해 보세요

| 로그 | 무엇을 하나 |
|---|---|
| `deviceLabel이 비어있는 SmartThings 장치 설정을 건너뜁니다` | 그 기기에 `장치 이름` 또는 `SmartThings deviceId`를 넣으세요 |
| `[기기] 인증 실패 (status 401)` | 토큰이 틀렸습니다. 구형 에어컨·세탁기는 토큰을 다시 받으세요 |
| `[기기] 로컬 경로가 계속 실패해 사실상 클라우드로 동작 중입니다` | IP가 바뀌었거나 기기가 응답하지 않습니다 |
| `[기기] 알 수 없는 냉방 모드 '...'` | 설정의 냉방 모드 값이 잘못됐습니다(안내에 쓸 수 있는 값이 함께 나옵니다) |
| `설정의 'coolCommand'는 옛 이름입니다` | `coolModeCommand`로 옮기세요. 그대로 두면 옛 값이 계속 이깁니다 |
| `클라우드 재인증이 필요합니다` | SmartThings 인증을 다시 하세요. 로컬 기기는 계속 동작합니다 |

더 자세한 내용이 필요하면 그 기기 설정에서 **디버그 로그**를 켜세요.
평소에는 조용하도록 반복되는 실패를 눌러 두기 때문에, 원인을 파고들 때는 디버그가 필요합니다.

---

## 문제가 생기면

1. **로그를 먼저** — 위 표에서 해당 줄을 찾아보세요.
2. **기기 IP 확인** — 공유기에서 고정 IP(주소 예약)로 잡혀 있나요?
3. **같은 네트워크인가** — 홈브릿지와 기기가 같은 대역에 있어야 합니다(VLAN 분리 주의).
4. 그래도 안 되면 디버그 로그를 켜고 다시 재현해 보세요.

---

## 보안 참고

- 기기 토큰은 `config.json`에 평문으로 저장됩니다. 파일 권한을 확인하세요.
- 로컬 통신은 구형 기기가 요구하는 TLS 1.0을 씁니다. 같은 네트워크 안에서만 오가는 통신입니다.

---

## 라이선스

MIT
