지난 글에서 방에 있는 자가발전 스위치 조명을 Home Assistant에 연결했다. 휴대폰으로 제어할 수 있게 됐으니, 다음으로 하고 싶었던 건 침대에 누워서 음성으로 불을 켜고 끄는 것이었다.

방법은 home-assistant-matter-hub로 HA의 엔티티를 Matter 브리지 하나로 묶고, 거실의 Google TV를 허브로 쓰는 것이다. 경로 전체가 로컬 네트워크 안에서 끝나고 클라우드를 거치지 않는다. 구축 자체는 어렵지 않았고, 시간이 걸린 건 페어링이었다. Google Home이 계속 "기기를 찾을 수 없음"을 띄웠는데, 끝까지 파고들어 보니 두 가지 문제가 겹쳐 있었다. 하나는 개발 중인 기기에 대한 Google의 제한이었고, 다른 하나는 내 서버의 방화벽이었다.

아래에 순서대로 정리했다. 왜 Matter를 골랐는지, matter-hub 설정, 페어링이 거치는 단계, 그리고 각 단계에서 막혔을 때 어떻게 확인하고 고쳤는지. 마지막으로 페어링 후 실제로 음성 제어를 써 보면서 겪은 몇 가지 문제를 기록했다.

왜 Matter인가#

HA를 Google Home에 연결하는 방법은 대략 세 가지다.

Nabu Casa Cloud수동 google_assistant 통합Matter 브리지
비용월 구독료무료무료
제어 경로휴대폰 → Google 클라우드 → Nabu Casa → HA휴대폰 → Google 클라우드 → 외부에 공개된 우리 집 HA로컬 네트워크 직결
외부 공개 필요없음/api/google_assistant를 Google이 호출할 수 있게 열어야 함없음
인터넷 끊겨도 동작안 됨안 됨됨
전제 조건유료Google Cloud 프로젝트, OAuth, 서비스 계정집에 Matter 허브가 있어야 함

Matter 브리지에는 Matter 허브가 한 대 필요하다. 처음에는 집에 있는 SwitchBot Hub 3면 될 줄 알았는데, 공식 설명을 찾아보니 그건 브리지일 뿐이고 서드파티 플랫폼의 홈 허브가 따로 필요했다.

그래서 한때는 두 번째 방법으로 갈 생각이었다. 그러다 거실의 Google TV Streamer 4K가 자체적으로 Matter 허브를 지원한다는 게 떠올라서 세 번째로 돌아왔다. 클라우드도 필요 없고 외부에 엔드포인트를 열 필요도 없으니, 내 CrowdSec이 Google 서버를 잘못 차단할 걱정도 없다.

matter-hub 구축#

의 원래 프로젝트(t0bst4r)는 2026년 1월에 유지보수가 중단되어 아카이브됐고, 마지막 버전은 v3.0.4다. 지금은 RiDDiX의 fork가 이어받았다. 나는 처음에 원래 프로젝트를 썼다가 크래시 버그 때문에 fork로 옮겼는데(뒤에서 설명한다), 새로 구축한다면 처음부터 fork의 image를 쓰는 걸 추천한다.

yaml
services:
  matter-hub:
    image: ghcr.io/riddix/home-assistant-matter-hub:latest
    container_name: matter-hub
    restart: unless-stopped
    network_mode: host                     # 필수. Matter는 mDNS와 IPv6로 탐색한다
    environment:
      - HAMH_HOME_ASSISTANT_URL=http://<HA 주소>:8123/
      - HAMH_HOME_ASSISTANT_ACCESS_TOKEN=${HA_TOKEN}
      - HAMH_HTTP_PORT=8482                # 관리 웹 UI
      - HAMH_MDNS_NETWORK_INTERFACE=enp6s0 # 호스트에 NIC가 여러 개일 때 실제 LAN 인터페이스로 고정
      - TZ=Asia/Taipei
    volumes:
      - ./data:/data

설정에서 신경 쓸 부분 몇 가지:

  • network_mode: host는 빼면 안 된다. Matter의 탐색은 mDNS(UDP 5353)와 IPv6를 쓰는데, bridge 모드에서는 이걸 받지 못한다.
  • 5353(mDNS), 5540/5541(Matter), 8482(관리 웹 UI) 포트를 쓴다. 문서에도 열어 두라고 나와 있다.
  • 토큰은 HA의 장기 액세스 토큰을 쓴다. 왼쪽 아래 프로필 → 보안 → 맨 아래 "장기 액세스 토큰". 나는 .env에 넣고 권한을 600으로 뒀다.
  • 내 호스트에는 LAN 말고도 WireGuard, Incus 대역, NanoKVM의 USB NIC, 그리고 docker bridge가 잔뜩 있어서, 인터페이스를 지정하지 않으면 log에 wg0: send Unknown system error -126이 계속 찍힌다. HAMH_MDNS_NETWORK_INTERFACE를 LAN 쪽 NIC로 지정하니 사라졌다. 이건 log에만 영향을 줄 뿐, 뒤에 나오는 페어링 문제와는 관계없다.

컨테이너가 뜨면 :8482 관리 웹 UI에서 bridge를 만든다. 조명 두 개만 넣고 싶어서 entity_id로 필터링하려 했는데, 드롭다운에 그 옵션이 없었다.

pattern에 entity ID 전체를 적으면 정확히 일치하는 것만 고른다. domain으로 switch를 고르면 모든 스위치가 다 넘어가고, platform으로 esphome을 고르면 WiFi 신호, IP 같은 진단 센서까지 딸려 가니 이 두 가지는 조심해야 한다.

저장하면 페어링 코드와 QR 코드가 생성된다. Google Home 앱에서는 + → 기기 설정 → 이미 기기가 있나요? → Matter 기기 순서로 들어간다.

페어링이 거치는 단계#

먼저 페어링 흐름을 나눠 두면, 뒤에서 어느 단계에서 문제가 생겼는지 대조하기 쉽다.

휴대폰이 mDNS로브리지를 찾음 Google이VID / PID 확인 휴대폰이 IPv6로UDP 5540에 연결 페어링 완료휴대폰과 허브가 연결됨

골치 아픈 점은 두 번째와 세 번째 단계가 실패했을 때 Google Home이 똑같은 화면을 보여 준다는 것이다.

게다가 두 경우 모두 matter-hub의 log가 비어 있어서, 휴대폰이 브리지를 찾았는지조차 알 수 없다. 나는 두 단계 모두에서 막혔다.

1단계: mDNS 탐색#

처음에는 네트워크 분리를 의심했다. 휴대폰과 Google TV는 둘 다 5G에 붙어 있고 서버는 유선이다. 하지만 페어링 단계에서 통해야 하는 건 휴대폰과 서버 사이고, Google TV는 페어링이 끝난 뒤에야 허브 역할을 넘겨받는다. 그러니 TV가 어느 대역에 있는지는 페어링에 영향을 주지 않는다.

휴대폰이 브리지를 정말 찾았는지 확인하는 가장 직접적인 방법은 LAN의 mDNS를 엿듣는 것이다. 서버에서 멀티캐스트 그룹에 가입해 5353을 듣는 작은 프로그램을 짜서 돌려 봤더니 결과는 이랬다.

text
휴대폰 _matterc._udp 질의   ×30
서버 응답                  ×30
matter-hub가 받은 연결       0

휴대폰이 30번 질의했고 서버도 30번 응답했지만, 휴대폰은 한 번도 연결해 오지 않았다. 즉 1단계는 통과했고, 문제는 그 뒤에 있다.

TIP

avahi가 설치되어 있다면 avahi-browse -rt _matterc._udp로도 브리지가 광고하는 내용을 볼 수 있다. 다음 절에서 쓸 TXT 필드도 여기에 포함된다.

2단계: Google의 VID/PID 확인#

서버의 응답을 뜯어 보면 TXT 레코드에 VP=65521+32768이 있다. 16진수로 바꾸면 Vendor ID 0xFFF1, Product ID 0x8000이다. 0xFFF1은 Matter가 테스트용으로 예약해 둔 벤더 코드로, matter-hub처럼 CSA 인증을 거치지 않은 프로젝트는 다 이걸 쓴다.

Google 개발자 문서에 따르면, Google Home에서 개발 중인 Matter 기기를 페어링하려면 먼저 Google Home Developer Console에서 Matter integration을 만들어야 하고, VID와 PID가 기기와 일치해야 한다. 테스트용 VID로는 연합이 할당한 0xFFF1부터 0xFFF4까지 중에서 고를 수 있다.

matter-hub가 광고하는 값은 0xFFF1 / 0x8000인데, 내 계정에는 integration이 하나도 없었다.

NOTE

matter.js 문서에는 Google이 미인증 기기를 만나면 "미인증 기기 페어링 허용" 확인 창을 띄운다고 적혀 있지만, 그건 예전 버전의 동작이다. 내가 실제로 겪은 건 확인 창 없이 바로 "기기를 찾을 수 없음"이 뜨는 것이었다.

등록 절차:

프로젝트 만들기

Google Home Developer Console에 Google Home 앱과 같은 계정으로 로그인해서 프로젝트를 하나 만든다.

Matter integration 추가

Add Matter integration을 누르면 처음에는 안내 페이지가 나온다. Next: Develop, Next: Setup을 차례로 누른다.

제품 정보 입력

Product name은 아무렇게나 정하고, Device type은 Outlet(On-Off Plug-in Unit)을 고른다.

VID와 PID 입력

Vendor ID는 Test VID의 0xFFF1을 고르고, Product ID에는 0x8000을 넣는다. 브리지가 광고하는 값과 일치해야 한다.

저장하고 앱으로 돌아가 다시 스캔하니 화면이 "근처에서 이 기기를 감지함"으로 바뀌었다. 이 단계는 통과다.

3단계: IPv6 연결#

계속을 누르자 또 "기기를 찾을 수 없음"이 떴다.

이번에도 matter-hub의 log는 비어 있었다. 먼저 한 가지 가능성을 배제했다. 휴대폰이 질의를 계속 반복하길래 무선 AP가 유선 쪽 멀티캐스트를 버리는 게 아닐까 의심했고, 그래서 서버의 응답을 휴대폰에 직접 유니캐스트로 보내는 작은 프로그램을 짜서 122번 보냈다. 그래도 연결은 없었다. 그러니 휴대폰이 레코드를 받은 건 확실하다.

남은 건 연결 자체였다. 광고에는 IPv6 주소만 들어 있고(Matter 규격상 IPv6를 쓰게 되어 있다), 휴대폰은 이 주소로 UDP 5540에 연결해야 한다. 이전에 같은 대역의 NanoKVM에서 ping6로 이 주소들을 테스트했을 때 모두 응답이 왔기 때문에, 이쪽은 더 파 보지 않았었다.

그래서 이번엔 TCP로 테스트했다. HA의 8123을 대상으로, 같은 기기, 같은 주소에 ping, IPv4, IPv6로 각각 붙어 봤다.

bash
ping -6 -c 3 <서버 IPv6 주소>
curl -4 -m 5 -s -o /dev/null -w '%{http_code}\n' 'http://<서버 IPv4 주소>:8123/'
curl -6 -m 5 -s -o /dev/null -w '%{http_code}\n' 'http://[<서버 IPv6 주소>]:8123/'

결과:

text
ping6        → 통함(0.6ms)
IPv4 8123    → 200
IPv6 8123    → Connection timed out

IPv6 ping은 통하는데 TCP는 timeout이 난다. timeout은 connection refused와 다르다. refused는 상대가 거절한 것이고, timeout은 패킷이 버려져서 아무 응답도 없는 것이다. 보통은 방화벽이다.

원인: ufw의 LAN 규칙이 IPv4에만 있었다#

이 서버는 를 쓰고, 기본적으로 들어오는 연결을 모두 막는다. 일주일 전에 데스크톱에서 HA 웹 UI에 접속이 안 됐을 때 이런 규칙을 추가했었다.

bash
sudo ufw allow from 192.168.50.0/24 to any port 8123 proto tcp comment 'HA UI from LAN'

출발지를 IPv4 대역으로 지정했으니 ufw는 IPv4 규칙만 만든다. sudo ufw status verbose로 보면 확실히 드러난다(발췌).

text
22/tcp               ALLOW IN    Anywhere
8123/tcp             ALLOW IN    192.168.50.0/24    # HA UI from LAN
22/tcp (v6)          ALLOW IN    Anywhere (v6)

출발지를 지정하지 않은 Anywhere 규칙에는 모두 대응하는 (v6)가 있지만, 출발지를 LAN으로 한정한 규칙에는 하나도 없다. 다시 말해 이 기기는 IPv4로는 LAN에 필요한 포트를 열어 뒀지만, IPv6로는 LAN에 포트를 하나도 열지 않은 상태였다.

ping6가 통한 건 ufw의 기본 규칙이 ICMPv6를 허용하기 때문이고(IPv6의 이웃 탐색에 필요하다), mDNS도 기본 규칙으로 허용된다. 그래서 탐색과 ping은 정상이고, 실제로 연결을 맺는 TCP와 UDP만 막혔다. 그리고 Google Home은 이 상황도 "기기를 찾을 수 없음"으로 표시한다.

해결#

LAN의 IPv6도 IPv4처럼 들어올 수 있게 규칙 두 개를 추가했다.

bash
sudo ufw allow from fc00::/7  comment 'LAN IPv6 (ULA)'
sudo ufw allow from fe80::/10 comment 'LAN IPv6 (link-local)'

fc00::/7은 사설 주소이고, fe80::/10은 이다. 둘 다 인터넷에서 라우팅되어 들어올 수 없다. 추가하고 나니 IPv6로 8123에 붙는 결과가 timeout에서 200으로 바뀌었고, 다시 페어링하자 성공했다.

fe80::/10은 빼면 안 된다. 페어링 성공 후의 log를 보면 휴대폰과 Google TV가 matter-hub와 맺은 두 연결 모두 출발지가 fe80::로 시작하는 link-local 주소였다. ULA만 열면 페어링이 끝난 뒤에도 똑같이 연결이 안 된다.

WARNING

이 두 줄은 LAN 안의 모든 IPv6 기기가 이 기기의 모든 포트에 접속할 수 있게 해 주는 것이라, IPv4에서 포트별로 연 것보다 범위가 넓다. 우리 집 LAN에는 내 기기만 있어서 이렇게 설정했다. LAN에 신뢰할 수 없는 기기가 있다면 이 두 범위에 대해 Matter용 5540/5541만 열어도 되지만, fe80::/10은 반드시 포함해야 한다.

페어링 이후#

페어링에 성공한 뒤 기기를 몇 개 더 넣었다. 스탠드 조명, 초인종 투광등, 에어컨, 제습기, 공기청정기, 캣타워 전원까지 해서 총 8개다. HA의 script, automation, input_boolean은 넣지 않았다. Google Home에서 전부 콘센트로 잡혀서 콘센트만 잔뜩 생기기 때문이다. 감시 카메라 스위치도 음성으로 잘못 건드릴까 봐 넣지 않았다.

그다음에 세 가지 문제를 만났다.

음성 명령에는 방 이름이 꼭 들어가야 한다#

처음으로 Google에게 "불 꺼줘"라고 했더니, 꺼진 건 스탠드 조명과 초인종 투광등이었고 방 조명은 그대로였다.

방의 조명 두 개는 HA에서 switch라서 matter-hub가 콘센트(OnOffPlugInUnit)로 내보내는데, Google의 "불 켜줘", "불 꺼줘"는 기기 유형이 조명인 항목에만 적용된다. Google Home 앱에서 기기를 길게 누르고 → 설정 → 기기 유형을 "조명"으로 바꾼 다음, 기기마다 방을 지정해 줬다.

바꾸고 나니 다른 문제가 생겼다. 이제 다 조명이 됐으니 "불 켜줘"라고만 하면 Google이 스탠드 조명, 엄마 방 조명까지 한꺼번에 켠다. 그래서 실제로는 매번 "Hey Google, 내 방 불 켜줘"라고 말한다. 방을 말해야 그 조명만 움직인다.

에어컨 때문에 브리지 전체가 크래시#

에어컨을 추가하자 bridge 전체가 죽고 계속 재시작했고, 다른 기기들도 덩달아 오프라인이 됐다.

text
climate_leng_qi.thermostat
minHeatSetpointLimit (1600) must be ≤ minCoolSetpointLimit (1600) − minSetpointDeadBand (200)
Failed to update bridge due to error: [constraint]

Matter의 온도조절기 규격은 난방 하한이 냉방 하한보다 최소 2°C 낮아야 한다고 요구하는데, 내 에어컨은 두 하한이 모두 16°C였다. 설정이 이미 data/에 기록되어 있어서 시작할 때마다 이 문제에 부딪혔고, 관리 웹 UI에도 들어갈 수 없었다. 결국 컨테이너를 먼저 멈추고, 일회용 컨테이너로 bridge 설정 파일을 고쳐서 에어컨을 뺀 다음 다시 시작해야 했다.

원래 프로젝트는 이미 아카이브되어 더 이상 고쳐지지 않는다. RiDDiX의 fork는 8월에 온도조절기의 온도 데드밴드 처리를 수정했고(#435), 이후 나와 똑같은 상황을 보고한 사람도 있다(#454 "Thermostat with equal min_temp for heat/cool crashes entire bridge process"). 마이그레이션은 image를 fork 것으로 바꾸기만 하면 된다. data/는 건드릴 필요가 없고, fabric과 node의 ID도 그대로라 Google Home에서 다시 페어링할 필요가 없다. 바꾸고 나니 에어컨이 RoomAirConditioner로 인식되어 정상적으로 추가됐다.

에어컨은 절대 온도로만 지정할 수 있다#

"에어컨 1도 올려줘"에는 반응이 없었고, log는 이랬다.

text
Invoke « thermostat.setpointRaiseLower
Validation-Error 0x80: Missing mandatory field mode in field mode

이 명령은 규격상 모드와 조정량을 함께 보내야 하는데, Google이 보낸 패킷에 모드 필드가 빠져 있는 것 같다. "에어컨 26도로 설정해줘"는 목표 온도를 쓰는 방식이라 정상적으로 동작하고, 에어컨 켜기와 끄기도 문제없으니 당분간은 이렇게 쓰기로 했다.

점검 순서#

표로 정리했다. "기기를 찾을 수 없음"이 뜨면 순서대로 확인해 보면 된다.

확인 항목확인 방법내 결과
휴대폰이 mDNS 응답을 받았는가5353을 엿듣거나 avahi-browse -rt _matterc._udp받음, 30번 질의에 30번 응답
브리지가 쓰는 VIDTXT의 VP 필드, 65521이 곧 0xFFF1테스트 VID
Developer Console에 등록했는가같은 Google 계정, VID/PID 일치안 했음, 등록 후 "근처에서 감지됨" 표시
IPv6의 TCP/UDP가 통하는가curl -6으로 TCP 서비스에 붙어 본다, ping만 보면 안 됨ping 통함, TCP timeout
방화벽에 IPv6 LAN 규칙이 있는가sudo ufw status verbose에서 (v6)가 있는지 확인없음
link-local을 허용했는가fe80::/10추가 후 페어링 성공

이제 침대에 누워서 "Hey Google, 내 방 불 꺼줘" 한마디면 불이 꺼진다. 지난 글의 RF 스위치와 이어 붙이면서 전체 경로를 다 걸어온 셈이다. 다만 "내 방"이라는 말은 빼먹으면 안 된다.

參考連結