ZS06은 USB에 꽂아 쓰는 지그비 IR 송수신기다(Zigbee2MQTT에는 Tuya ZS06으로 잡힌다). 이 기기는 버튼을 저장하지 않는다. 학습을 시키면 기기 안에 버튼 슬롯이 생기는 것이 아니라, 방금 받은 적외선 신호의 펄스 길이가 Base64 문자열로 돌아온다. 송신은 그 문자열을 다시 펄스로 풀어서 적외선으로 내보내는 것이 전부다. 그래서 리모컨 버튼으로 만들 수 있는 신호만이 아니라, 규격만 알면 어떤 신호든 만들어서 보낼 수 있다. 이 글은 LG 천장형 에어컨의 공개된 28비트 규격을 ZS06으로 직접 보내 본 기록이다. 여기서 한 일은 전부 ZS06이 IR 신호를 n개의 슬롯에 저장하는 기기가 아니었기 때문에 가능했다.
집에서는 Zigbee2MQTT에 ZS06(TS1201)을 붙여 두고, LG 천장형 실내기를 손리모컨 PLR-CA0SD로 켜고 끄고 있었다. Broadlink나 투야 앱, Home Assistant의 IR 통합은 쓰지 않는다. 허브는 MQTT이고, ZS06은 지그비 라우터 역할도 겸하는 USB 동글이다. 한 방에서 신호 규격을 알아내면 같은 문자열을 다른 방 ZS06에도 그대로 쓸 수 있으니, 방마다 매번 학습할 필요가 없다.
아래 내용은 다음 환경에서 확인한 것이다.
PLR-CA0SD. 신호는 NEC처럼 보이지만 실제로는 28비트 LG 규격이다(주소 0x88, 헤더 약 8.8ms / 4.2ms, 38kHz).TS1201, 제조사 코드는 _TZ3290_…로 잡힌다.ZS06이라는 이름은 짚고 넘어갈 필요가 있다. ZS06은 특정 회사의 제품명이 아니다. Tuya의 지그비 모듈을 넣어 만든 IR 송수신기를 Zemismart, Aubess, QA, Ekaza 같은 여러 업체가 각자의 모델명으로 파는데, 이 기기들은 지그비 모델명을 TS1201로, 제조사 코드를 _TZ3290_로 시작하는 문자열로 보고한다. Zigbee2MQTT는 이런 기기를 전부 “Tuya ZS06”이라는 정의 하나로 묶어서 지원한다. 그래서 어느 브랜드 제품을 사도 Zigbee2MQTT 화면에는 Tuya ZS06으로 뜬다. 여기서 Tuya는 기기를 만든 회사가 아니라 이 기기들이 올라탄 플랫폼(지그비 모듈, 앱, 클라우드)의 이름이다. 이 집 기기도 제조사 코드가 _TZ3290_nba3knpsarkawgnt로, Zigbee2MQTT 정의에 적힌 코드 목록에는 없는 무명 OEM 제품이지만 모델명이 TS1201이라서 같은 정의로 잡혔다. 이 글에서 ZS06이라고 쓰는 것은 그 분류명이다.
벽걸이형이라도 같은 28비트 LG 규격을 쓰는 기종이면 그대로 적용될 것이다. 다만 LG2라고 부르는 변종은 헤더가 약 3.2ms / 9.9ms라서 이 집에서 잰 값과 다르다. 와이파이 만능리모컨, RF 433MHz, ZS08만 있는 구성은 여기서 다루지 않는다.
스마트홈을 위한 IR 송수신기라고 하면, “이 버튼은 켜기, 저 버튼은 끄기”처럼 기기 안에 버튼을 저장해 두는 장치라고 생각하기 쉽다. ZS06은 그 생각과는 다른 기계였다. Zigbee2MQTT 문서를 보면 송신 필드는 ir_code_to_send 하나뿐이다. 학습 결과는 learned_ir_code 필드로 한 번 올라올 뿐 기기가 보관하지 않는다. 다음 학습이 이전 값을 덮어쓴다.
그래서 역할이 나뉜다. 지그비가 문자열을 나르고, ZS06이 그 문자열을 적외선으로 바꾸고, LG 실내기가 그 적외선을 해석한다. 가운데 있는 ZS06은 자기가 내보내는 신호가 무슨 뜻인지 모른다.
기기가 받는 명령도 그만큼 단순하다. 지그비로 보면 적외선과 관련된 클러스터가 둘뿐이다. 하나는 학습을 켜고 끄는 제어 명령이고({"study":0}으로 시작, {"study":1}로 종료), 다른 하나는 코드를 조각으로 주고받는 전송 절차다. 학습이 끝나면 기기가 “받은 신호의 길이는 N바이트”라고 알리고, 허브가 조각을 하나씩 당겨 와서 Base64로 만든다. 보낼 때는 허브가 매번 코드 전체를 올린다. “저장된 몇 번 코드를 다시 보내라” 같은 명령은 없다. 이 집의 ZS06 다섯 대를 Zigbee2MQTT에서 들여다봐도 노출된 항목은 학습 켜기, 학습 결과, 보낼 코드뿐이었다. 그러니 신호를 기억하는 쪽은 기기가 아니라 허브다.
이 점이 이 글 전체의 출발점이다. 뒤에서 하는 일, 즉 규격대로 계산한 신호를 보내고, 한 방에서 알아낸 신호를 모든 방에서 쓰고, 버튼을 학습하지 않고 자동화하는 것은 전부 ZS06이 신호를 슬롯에 저장하는 기기가 아니기 때문에 가능하다.
MQTT 토픽 zigbee2mqtt/<이름>/set에 JSON 하나를 보내면, 코디네이터가 지그비로 나르고 ZS06이 38kHz 적외선으로 바꾼다. 실내기는 그 적외선을 LG 프레임으로 읽는다.
처음에는 저장 공간이 없는 기계를 있는 것처럼 썼다. LED가 켜지면 버튼을 하나 누르고, 돌아온 Base64 문자열에 이름을 붙였다. 켜기, 끄기, +1, −1, 파워냉방. 문자열이 서로 달랐으니 버튼 다섯 개를 복사한 셈이라고 생각했다.
그 생각은 +1과 −1에서 깨졌다. 같은 버튼이라도 리모컨 화면의 온도가 다를 때 누르면 다른 문자열이 온다. 버튼이 “1도 올려라”라는 상대적인 명령을 보내는 것이 아니라, 그 순간 리모컨이 갖고 있던 전체 상태(모드, 온도, 풍량)를 통째로 보내고 있었던 것이다. 켜기 버튼을 학습한 값에도 27°C 중풍이라는 상태가 실려 있었다. 파워냉방은 온도와 무관한 제트 전용 신호였다.
그래서 온도만 바꿔 가며 모아 보기로 했다. 풍량을 강풍으로 고정하고 20°C부터 30°C까지 1도씩 올리면서 매번 학습 창을 열고 버튼을 눌렀다. 돌아온 열한 개 문자열은 모두 달랐다. 압축을 풀고 28비트로 늘어놓자 온도 자리의 4비트(니블)만 1씩 올라갔다. 그런데 20°C라고 이름 붙인 코드를 공개 규격(니블 + 15)으로 읽으면 19°C였고, 마지막 30°C 코드는 29°C였다. 학습할 때 붙인 이름이 실제보다 1도씩 앞서 있었던 것이다. 손으로 29°C까지 올렸다는 기억과도 맞았다.
처음 붙인 이름과, 28비트로 읽은 실제 값
| 학습할 때 붙인 이름 | 28비트 | 실제 내용 |
|---|---|---|
| 끄기 | 0x88C0051 | 공개 규격의 끄기 명령과 같음 |
| 켜기 | 0x8800C2E | 켜기 비트 + 27°C 중풍. “켜기”가 아니라 그 순간의 상태 |
| +1 | 0x8808F29 | 냉방 30°C 중풍. 상대 명령이 아님 |
| −1 | 0x8808C26 | 냉방 27°C 중풍 |
| 파워냉방 | 0x8810089 | 제트. 온도와 무관 |
| 19°C 강풍이라고 붙인 것 | 0x880834F | 냉방 18°C 강풍 |
점에 마우스를 올리면 그 코드의 온도 니블과 프레임 값이 보인다.
여기서부터는 버튼을 더 학습하지 않았다. 끄기 신호가 공개 규격과 비트 단위로 같고 온도가 니블 하나로 표현된다면, 나머지 조합은 규격대로 계산해서 만들 수 있다. 학습은 규격이 맞는지 확인하는 표본으로만 쓰고, 실제 운영은 계산으로 한다.
학습 창은 이렇게 연다.
{"learn_ir_code": "ON"}
윗면 LED가 켜져 있는 동안 리모컨을 15–30cm 거리에서 한 번 누른다. 너무 가까이 대면 수신부가 포화된다. 잠시 뒤 상태 토픽에 Base64 문자열이 올라온다. {"learn_ir_code":"OFF"}는 취소가 아니다. Zigbee2MQTT는 ON이든 OFF든 같은 학습 시작 명령을 보내기 때문이다. 잘못 학습했으면 받은 값을 버리고 창을 다시 여는 수밖에 없다. 창이 열린 상태에서 다시 ON을 보내면 처음부터 다시 시작된다. 한 번 열 때 버튼 하나만 받고, 기기는 10초 정도만 기다린다.
이 Base64는 명령 코드가 아니다. 적외선이 켜진 시간과 꺼진 시간을 마이크로초 단위로 나열하고, 각각을 16비트 리틀엔디안으로 묶은 뒤, FastLZ와 비슷한 방식으로 압축한 결과다. 블록 헤더의 상위 3비트가 0이면 리터럴(최대 32바이트)이고, 아니면 앞부분을 복사하라는 뜻이다. 푸는 방법은 mildsunrise의 기록과 Zigbee2MQTT의 decodeTuyaTimings 함수에 정리돼 있다.
실제 학습 결과를 하나 풀어 보면 구조가 이렇다. 아래는 위 학습 루프에서 받은 냉방 27°C 강풍 코드다. Base64 80자를 풀면 58바이트가 나오고, 이 58바이트의 압축을 풀면 118바이트, 즉 16비트 타이밍 59개가 된다.
압축을 푸는 쪽은 58바이트를 앞에서부터 읽으면서 출력을 차례로 쌓는다. 블록은 두 종류다. 리터럴 블록은 “다음 N바이트는 이 값 그대로”라는 뜻이라 헤더 뒤에 데이터가 따라온다. 복사 블록은 “지금까지 쌓은 출력에서 뒤로 D바이트 떨어진 곳부터 N바이트를 그대로 가져와라”는 지시라, 헤더 2바이트만 있고 데이터는 없다. 그래서 복사 블록이 만든 타이밍은 새로 받은 값이 아니라 앞에 이미 나온 값의 반복이다. 이 코드에서 새로 받은 값은 리터럴 26바이트뿐이고 나머지 92바이트는 전부 복사로 채워졌다. 적외선 신호가 그만큼 반복적이라는 뜻이다.
예를 들어 첫 복사 헤더 C0 01은 “8바이트를 2바이트 뒤에서”라는 뜻이다. 2바이트 뒤는 직전 타이밍 하나(504)이므로, 그 값이 네 번 되풀이되어 타이밍 7번부터 10번까지 504가 된다. 다음 헤더 40 0F는 “4바이트를 16바이트 뒤에서”라서 타이밍 3번과 4번(1595, 504)이 11번과 12번에 다시 나타난다. 헤더 첫 바이트의 상위 3비트가 길이, 나머지 5비트와 둘째 바이트가 뒤로 갈 거리다. 타이밍은 마크(켜짐)와 스페이스(꺼짐)가 번갈아 나오고, 세 번째 값부터 두 개씩 묶으면 비트 하나가 된다.
펄스 59개는 헤더 한 쌍, 데이터 28쌍, 그리고 마지막 마크 하나다. NEC 규격을 예상했다면 꽤 짧은 길이다. 이 세대의 LG 에어컨은 이 짧은 프레임을 쓴다.
헤더는 8837µs 켜짐 / 4225µs 꺼짐으로, LG 규격의 공칭값 8500 / 4250에 가깝다. 그다음부터 마크(켜짐)는 약 500µs로 일정하고, 스페이스(꺼짐)가 짧으면 0, 길면(약 1580µs) 1이다. 이렇게 28비트를 앞에서부터 모으면 다음과 같다.
1000 1000 1100 0000 0000 0101 0001
0x88 0xC0 0x05 0x1
즉 0x88C0051이다. IRremoteESP8266의 ir_LG.h에 있는 kLgAcOffCommand와 같은 값이다. 마지막 4비트는 체크섬으로, 명령 16비트 0xC005를 4비트씩 더한 값의 하위 4비트다(C+0+0+5=0x11 → 1). 주소 0x88은 합산에 넣지 않는다.
가로축은 누적 시간(마이크로초)이다.
같은 28비트를 필드로 나눠 보면 끄기는 전용 명령이고, 온도 변경은 상태를 통째로 담는 다른 형식이다. Arduino-IRremote의 ac_LG.h와 IRremoteESP8266이 같은 구성을 쓴다.
frame = (0x88 << 20) | (cmd << 4) | nibble_sum(cmd)
켜기 0x00 | ((temp−15)<<4) | fan ← 냉방 비트 0x0800을 지움
냉방 0x08 | ((temp−15)<<4) | fan ← 이미 켜진 뒤
난방 0x0C | …
자동 0x0B | …
제트 0x1008
예약 꺼짐 0x9000 + 분 (0–1439, 지금부터)
온도 니블은 °C − 15로, 16–30°C를 표현한다. 이 집 리모컨에서 강풍은 니블 4, 중풍은 2였다. 파워냉방 버튼을 학습한 값은 온도 프레임이 아니라 제트 명령 0x8810089였다. 18°C 강풍은 0x880834F이니, 두 신호는 주소 0x88만 같고 나머지는 전혀 다르다.
칸에 마우스를 올리면 그 비트가 속한 필드가 보인다. 끄기와 제트는 특수 명령이라, 온도 자리의 비트가 상태 프레임과는 다른 뜻으로 쓰인다.
손리모컨의 +1 / −1 버튼을 학습해 보면, 문자열에는 그 순간의 화면 상태가 통째로 들어 있다. 상대 명령이 아니다. 그러니 27°C로 맞추려고 +1을 여러 번 보낼 필요가 없다. 냉방 27°C 강풍 프레임 하나를 보내면 된다. 1시간 뒤에 끄는 것도 마찬가지다. 예약은 시각이 아니라 지금부터 몇 분 뒤로 지정하고, 명령은 0x9000 + 분이다.
아래는 그 계산기다. 모드, 온도, 풍량을 고르면 28비트와 파형이 함께 바뀐다. ZS06은 이 숫자의 뜻을 모른다. 그저 적외선으로 바꿔서 내보낼 뿐이다.
비트 칸의 색은 위 필드 그림과 같다. 만들어진 파형의 타이밍은 공칭값(헤더 8800/4200, 마크 500, 1은 1600, 0은 550)이다. 학습한 파형과는 몇 마이크로초씩 다르지만 같은 비트를 담고 있다.
냉방만 놓고 보면 온도 15단계에 이 리모컨의 풍량 네 가지(약, 중, 강, 자동)를 곱해 60가지 조합이 나온다. 손리모컨 앞면의 버튼 수보다 많다. 격자의 색은 온도 니블 값을 그대로 따른다. 칸을 클릭하면 위 계산기가 그 조합으로 맞춰진다.
색이 진할수록 높은 온도다. 행이 풍량, 열이 온도다.
특수 명령과 예약 시간까지 같은 규칙으로 펼치면 서로 다른 28비트 프레임이 3802개 나온다. 손리모컨 앞면에 있는 버튼은 스물몇 개다. 3802개의 대부분은 예약이다. 예약 켜짐과 꺼짐이 각각 0분부터 1439분(23시간 59분)까지 1분 단위로 1440개씩이다. 특정 시각을 지정하는 예약은 이 규격에 없다.
운전 상태는 켜기, 냉방, 난방, 자동 네 모드에 온도 15단계와 풍량 니블 여덟 가지를 곱한 480개다. 슬립은 0분부터 420분까지다.
방을 늘릴 때 리모컨을 매번 학습할 필요가 없다. 신호는 방이 아니라 실내기 기종에 속하기 때문이다. 한 방에서 얻은 끄기 프레임을 다른 방 ZS06에 그대로 보내면 된다. ZS06은 자기가 어느 방에 있는지 모른다. 적외선을 내보낼 뿐이다.
방마다 달라지는 것은 어느 USB 동글이 어느 실내기를 비추느냐뿐이다. 프레임 문자열은 모든 방이 공유한다. 27°C 강풍 프레임을 한 번 만들면 어느 방에 보내도 같은 JSON이다.
한 실내기가 두 ZS06의 적외선을 같이 받으면 두 번 반응한다. 천장형은 실내기 두 대가 리모컨 하나를 나눠 쓰는 설치도 흔하다.
규칙을 알고 나면 버튼 순서를 흉내 낼 필요가 없다. 방 A는 켜서 27°C 강풍으로 두고 60분 뒤 꺼지게 했고, 방 B는 28°C 강풍으로 두고 30분 뒤 꺼지게 했다. 켜기는 0x0800 비트를 뺀 프레임, 온도는 켜진 뒤의 냉방 프레임, 예약은 0x9000 + 분이다. 방마다 토픽만 바꾸고 같은 생성 코드를 돌렸다.
그날 보낸 순서
| 순서 | 방 | 신호 | 프레임 |
|---|---|---|---|
| 1 | 방 A | 켜기 27°C 강풍 | 0x8800C40 |
| 2 | 방 B | 켜기 28°C 강풍 | 0x8800D41 |
| 3 | 방 A | 냉방 27°C 강풍 | 0x8808C48 |
| 4 | 방 B | 냉방 28°C 강풍 | 0x8808D49 |
| 5 | 방 A | 60분 뒤 꺼짐 | 0x88903C8 |
| 6 | 방 B | 30분 뒤 꺼짐 | 0x88901E8 |
같은 ZS06에는 0.8초쯤 간격을 두고 보냈다. 확인은 MQTT 에코가 아니라 실내기의 삐 소리와 바람으로 한다. 예약은 시각이 아니라 보낸 시점부터의 상대 시간이다.
학습한 Base64를 그대로 보내거나, 위 규칙으로 만든 프레임을 같은 방식(리터럴 블록만 쓰는 FastLZ)으로 압축해서 보내면 된다. 토픽은 기기 하나당 하나다.
{"ir_code_to_send": "<Base64>"}
적외선에는 주소가 없다. 같은 프레임을 받는 실내기는 모두 반응한다. 천장형 실내기 두 대가 리모컨 하나를 나눠 쓰는 방이라면, ZS06 하나의 적외선이 두 대에 같이 닿는 것이 정상이다. 한 대만 끄려면 ZS06을 그쪽 수신부에만 향하게 하고 다른 쪽을 가려야 한다.
직접 만든 코드를 실제로 쓰기 전에는, 끄기와 제트처럼 공개 규격 값과 학습한 값이 비트 단위로 같은지 한 번 대조해 보는 편이 안전하다. 이 집에서는 그 둘이 맞았다. 상하/좌우 스윙, 자동건조, 예약은 같은 규칙으로 만들 수 있지만 기종에 따라 무시될 수 있다. Arduino-IRremote는 좌우 스윙 코드를 미검증이라고 적어 두었다.
처음에는 버튼 복사기를 산 줄 알았다. 저장 공간이 없다는 것을 알고 나니 순서가 뒤집혔다. 학습은 운영 수단이 아니라 규격이 맞는지 확인하는 표본이었고, 끄기와 제트가 공개 규격과 비트 단위로 같다는 것을 확인한 뒤로는 리모컨을 다시 들 일이 없었다.
지금 이 집 에어컨을 움직이는 것은 손에 든 PLR-CA0SD가 아니라 계산식 한 줄과 문자열 몇 개다. 계산식은 한 방에서 확인했지만 방이 아니라 기종에 속하므로, 어느 방 ZS06에 보내도 똑같이 통한다. ZS06은 그 계산식을 모른 채 적외선만 내보낸다. ZS06은 리모컨이 아니다. 계산식이 리모컨이 됐다.
여기서 분명히 해 둘 점은, 이런 유연한 방식이 가능했던 이유가 ZS06의 구조 자체에 있다는 것이다. 만약 ZS06이 흔한 만능리모컨처럼 IR 신호를 n개의 슬롯에 저장해 두고 “몇 번 슬롯을 재생하라”는 식으로 동작하는 기기였다면, 리모컨에 없는 조합을 만들 수도, 한 방에서 알아낸 신호를 다른 방에 그대로 쓸 수도, 예약 시간을 분 단위로 계산해서 보낼 수도 없었을 것이다. 저장하지 않고 받은 문자열을 그대로 적외선으로 바꿔 주기 때문에, 신호를 만드는 일을 전부 허브 쪽에서 계산으로 처리할 수 있었다.
남은 확인도 같은 틀 안에 있다. 스윙과 자동건조는 계산식으로 만들 수는 있지만 이 실내기가 받아 주는지 아직 확인하지 않았다. 남은 방들은 이미 확인한 문자열을 보내고 삐 소리를 들으면 끝이다. 버튼을 학습할 일은 다시 없다.
| 주장 | 종류 | 근거 |
|---|---|---|
| ZS06은 학습 슬롯이 없고 펄스 문자열만 주고받는다 | 확인 | Z2M 노출 항목은 learn_ir_code / learned_ir_code / ir_code_to_send뿐. zigbee-herdsman-converters zosung.ts: 제어 명령은 study 0/1 둘, 송신은 매번 코드 전체를 조각으로 업로드, 저장이나 재생 명령 없음. 이 집 ZS06 5대의 클러스터도 zosungIRTransmit, zosungIRControl과 표준 클러스터뿐 |
| ZS06은 제품명이 아니라 Zigbee2MQTT의 분류명이다 | 확인 | zigbee-herdsman-converters tuya.ts의 정의: model ZS06, vendor Tuya, zigbeeModel TS1201, whiteLabel에 Zemismart, Aubess, QA, Ekaza, Tuya UFO-R4Z. 이 집 기기 코드 _TZ3290_nba3knpsarkawgnt는 목록에 없고 TS1201로 매칭 |
| Base64는 압축된 타이밍이다 | 확인 | mildsunrise, Z2M decodeTuyaTimings |
끄기 실측은 0x88C0051 | 확인 | 학습 파형 59펄스를 28비트로 읽음. IRremoteESP8266 kLgAcOffCommand와 일치 |
파워냉방은 제트 0x1008 | 확인 | 학습한 값 0x8810089 = Arduino LG_JET_ON + 체크섬 |
| 온도 = 니블 + 15, 강풍 니블 4 | 확인 | 연속 학습 11개가 니블만 1씩 증가. 18°C 강풍 0x0834 |
| +1/−1은 상대 명령이 아니다 | 판단 | 학습한 값이 그 순간의 온도와 풍량을 담은 상태 프레임이었다 |
| 강풍 11개는 온도 니블만 1씩 증가한다 | 확인 | 20–30으로 이름 붙인 코드의 28비트가 19–29°C에 해당. 문자열은 11개 모두 다름 |
| 한 번 알아낸 프레임을 모든 방 ZS06에 쓸 수 있다 | 판단 | ZS06은 기종을 해석하지 않고 펄스만 낸다. 방마다 달라지는 것은 어느 실내기를 비추느냐뿐 |
| 방 A 27°C 60분 꺼짐, 방 B 28°C 30분 꺼짐 | 확인 | 만든 프레임 6개를 각 방 토픽으로 송신, 기기 상태 에코까지 수신. 켜기와 냉방은 실내기 반응을 현장에서 확인 |
| 예약은 상대 시간 | 확인 | Arduino LG_TIMER_OFF + minutes, 0–1439 |
| 만든 프레임이 학습한 파형과 똑같이 통한다 | 추정 | 끄기/제트는 비트가 같고, 켜기/냉방 생성분은 실내기가 받았다. 예약과 스윙 계열은 확인이 남았다 |
| 좌우 스윙 코드 | 가설 | Arduino가 AKB73757604 리모컨 기준으로 적어 두고 미검증이라고 표시 |