YOGYUI

현대통신 월패드 '감성조명' 제어 기능 추가 (HA 애드온) 본문

홈네트워크(IoT)/힐스테이트 광교산

현대통신 월패드 '감성조명' 제어 기능 추가 (HA 애드온)

요겨 2024. 6. 7. 11:09
반응형

Add Hyundai HT Wallpad 'Emotion Light' device type (HA Addon)

1. 서론

며칠 전(2024년 6월 4일), 현대통신 월패드 RS-485 연동 홈 어시스턴트(Home Assistant, HA) 애드온 유저분로부터 메일이 한 통 왔다

문제상황: 일부 조명 장치가 제대로 등록되지 않음

 

개발자 입장에서 아주 감사하게 애드온의 패킷 로그 분석 후 원인 파악 및 해결책까지 제시해주셨다

이러면 개발자는 그저 코드 작업만 맘편하게 하면 되기 때문에 문제 해결까지 빠르게 달려나갈 수 있다 ^^

문제원인: 현대통신 월패드에 '감성조명'이라는 디바이스 타입이 따로 존재하며, '일반조명'과는 다른 패킷 구별 바이트를 사용하는 것으로 파악된다 (일반조명: 0x19, 감성조명: 0x15)

※ 유저분의 월패드 모델은 HDHN-3000

 

첨부 사진을 보면 감성조명은 조명 On/Off 제어 외에 '조명 설정' 탭이 있어서 별도로 조명 관련 기능(밝기나 색상 등?)을 설정할 수 있는 것 같은데, 메일에서는 관련 내용은 언급되어 있지 않았다 (내가 거주중인 힐스테이트 광교산에는 감성조명 디바이스가 없어서 더 자세한 정보를 알아내기가 힘들다)

2. RS-485 패킷 분석

2.1. 일반조명과 감성조명의 패킷 차이 분석 (packet specification)

위에서도 간략히 언급되었듯이, 현대통신 RS-485 패킷의 디바이스간 구별 바이트 (패킷의 4번째 바이트)가 일반조명은 0x19를 사용하며 감성조명은 0x15를 사용한다

※ 메일 내용만으로는 구별 바이트 외에 눈에 띄는 차이점은 없는 것으로 보인다

일반조명 패킷에 대한 분석은 힐스테이트 입주 초기인 2022년 6월경에 포스팅한 바 있으므로 참고 (벌써 2년이나 흐르다니 세월 참 빠르다;)

힐스테이트 광교산::조명 제어 RS-485 패킷 분석 (1)

힐스테이트 광교산::조명 제어 RS-485 패킷 분석 (2)

※ 특이사항: 일반 조명은 한 공간내에 존재하는 모든 조명들에 대한 쿼리와 응답 패킷이 존재하는데, 감성 조명은 존재하지 않는 것 같다 (오히려 패킷 예외 사항이 없으므로 코드 구현은 더 수월하다)

 

2.1.1. 쿼리 패킷 (통신 방향: 월패드→디바이스)

  0 1 2 3 4 5 6 7 8 9 10
일반조명 F7 0B 01 19 01 40 XX 00 00 YY EE
감성조명 15
  • 길이 11의 패킷 (패킷의 길이를 나타내는 두번째 바이트 = 0x0B)
  • 4번째 바이트가 0x19면 일반조명, 0x15면 감성조명
  • 5번째 바이트가 0x01이면 '쿼리' 패킷
  • 7번째 바이트(XX)는 공간 인덱스(상위 4비트)와 디바이스 인덱스(하위 4비트)를 가리킴 (모두 1-based)
    예시: 0x41 = 4번째 공간(방)의 첫번째 조명, 0x32 = 3번째 공간(방)의 두번째 조명
  • 10번째 바이트(YY)는 XOR 체크섬 

2.1.2. 명령 패킷 (통신 방향: 월패드→디바이스)

  0 1 2 3 4 5 6 7 8 9 10
일반조명 F7 0B 01 19 02 40 XX YY 00 ZZ EE
감성조명 15
  • 길이 11의 패킷 (패킷의 길이를 나타내는 두번째 바이트 = 0x0B)
  • 4번째 바이트가 0x19면 일반조명, 0x15면 감성조명
  • 5번째 바이트가 0x02이면 '명령' 패킷
  • 7번째 바이트(XX)는 공간 인덱스(상위 4비트)와 디바이스 인덱스(하위 4비트)를 가리킴 (모두 1-based)
    예시: 0x41 = 4번째 공간(방)의 첫번째 조명, 0x32 = 3번째 공간(방)의 두번째 조명
  • 8번째 바이트(YY): 0x01일 경우 ON 명령, 0x02일 경우 OFF 명령
  • 10번째 바이트(ZZ)는 XOR 체크섬 

2.1.3. 응답 패킷 (통신 방향: 월패드←디바이스)

위에서 언급했듯이 감성 조명은 동일 공간 내 모든 조명에 대한 응답 패킷이 존재하지 않고, 개별 조명에 대한 응답 패킷만 존재하므로 일반 조명과의 패킷 비교는 생략하고 패킷 상세만 기재하도록 한다

  0 1 2 3 4 5 6 7 8 9 10 11 12
감성조명 F7 0D 01 15 04 40 XX 00 YY ZZ EE
  • 길이 13의 패킷 (패킷의 길이를 나타내는 두번째 바이트 = 0x0D)
  • 4번째 바이트가 0x15면 감성조명
  • 5번째 바이트가 0x04이면 '응답' 패킷
  • 7번째 바이트(XX)는 공간 인덱스(상위 4비트)와 디바이스 인덱스(하위 4비트)를 가리킴 (모두 1-based)
    예시: 0x41 = 4번째 공간(방)의 첫번째 조명, 0x32 = 3번째 공간(방)의 두번째 조명
  • 9번째 바이트(YY): 조명의 현재 ON/OFF 상태 (0x01일 경우 ON, 0x02일 경우 OFF 명령)
  • 12번째 바이트(ZZ)는 XOR 체크섬

특이점: 일반 조명과 달리 감성 조명은 패킷의 10번째, 11번째 바이트의 값이 무언가 의미를 가지는 것으로 보인다

(메일상 0xFC6C, 0x6C6C, 0x24FF, 0xFFFF 등 디바이스마다 값이 상이함)

HDHN-3000 월패드의 감성 조명 관련 설정 기능과 관련된 값들인 것 같은데, 어떤 값인지는 메일에 기재되어 있지 않다

2.2. 코드 설계 방안

메일을 받자마자 든 생각은 0x15, 0x19 두 패킷 구별 바이트를 동일한 클래스의 멤버 변수로 구분하는 걸 고려했는데, 추후 감성조명과 관련된 기능을 추가해야 할 것 같은 불길한(?) 예감이 들어 기존의 일반 조명과 구별된 감성 조명(영어로는 내 맘대로 EmotionLight라고 명명) 클래스를 별도로 만들고 RS-485 패킷 및 MQTT 명령을 따로 핸들링해주는 것이 더 좋겠다는 판단이 들었다

메일을 받자마자 금방 작업 완료할 수 있을 것처럼 답장으로 보냈는데, 설계 방안을 바꾼 뒤 시간이 좀 필요할 것 같다고 정정 메일을 다시 보냈다 ^^;;

3. 코드 작업

생각한 것보다 소스코드 변경 사항이 많아 간단하게 핵심 변경 사항만 짚어보도록 한다

(그만큼 내가 코드를 정교하게 모듈화, 캡슐화하지 못했다는 소리라 슬프기만 하다 ㅠ)

3.1. Device Type 추가

common.py의 디바이스 타입 관련 열거형 클래스 DeviceType에 EMOTIONLIGHT 항목을 추가했다 (정수값은 12)

@unique
class DeviceType(IntEnum):
    UNKNOWN = 0
    LIGHT = auto()
    OUTLET = auto()
    THERMOSTAT = auto()
    AIRCONDITIONER = auto()
    GASVALVE = auto()
    VENTILATOR = auto()
    ELEVATOR = auto()
    SUBPHONE = auto()
    HEMS = auto()
    BATCHOFFSWITCH = auto()
    DOORLOCK = auto()
    EMOTIONLIGHT = auto()

3.2. 감성조명 디바이스 클래스 추가

EmotionLight.py 파일을 추가한 뒤 EmotionLight 클래스를 추가했다 (Light 클래스 코드와 유사도 99%!)

다만, MQTT 발행 및 구독 토픽의 디바이스 관련 토큰이 light가 아니라 emotionlight로 일반 조명과 구별되게 구현했으며, Home Assistant Discovery는 일반 조명과 완전히 동일하게 구현했다

패킷 생성 구문의 4번째 바이트만 0x19에서 0x15로 바꿔주면 감성 조명 클래스는 개발 완료!

※ 물론 감성 조명 관련 특수 기능 추가 요구사항 발생 시 EmotionLight 클래스에 작업해주면 된다

from Device import *

class EmotionLight(Device):
    def __init__(self, name: str = 'EmotionLight', index: int = 0, room_index: int = 0):
        super().__init__(name, index, room_index)
        self.dev_type = DeviceType.EMOTIONLIGHT
        self.unique_id = f'emotionlight_{self.room_index}_{self.index}'
        self.mqtt_publish_topic = f'home/state/emotionlight/{self.room_index}/{self.index}'
        self.mqtt_subscribe_topic = f'home/command/emotionlight/{self.room_index}/{self.index}'

    def setDefaultName(self):
        self.name = 'EmotionLight'

    def publishMQTT(self):
        obj = {"state": self.state}
        if self.mqtt_client is not None:
            self.mqtt_client.publish(self.mqtt_publish_topic, json.dumps(obj), 1)

    def configMQTT(self, retain: bool = False):
        if self.mqtt_client is None:
            return

        topic = f'{self.ha_discovery_prefix}/light/{self.unique_id}/config'
        obj = {
            "name": self.name,
            "object_id": self.unique_id,
            "unique_id": self.unique_id,
            "state_topic": self.mqtt_publish_topic,
            "command_topic": self.mqtt_subscribe_topic,
            "schema": "template",
            "state_template": "{% if value_json.state %} on {% else %} off {% endif %}",
            "command_on_template": '{"state": 1}',
            "command_off_template": '{"state": 0 }'
        }
        self.mqtt_client.publish(topic, json.dumps(obj), 1, retain)

    def makePacketQueryState(self) -> bytearray:
        packet = bytearray([0xF7, 0x0B, 0x01, 0x15, 0x01, 0x40])
        packet.append((self.room_index << 4) + (self.index + 1))
        packet.extend([0x00, 0x00])
        packet.append(self.calcXORChecksum(packet))
        packet.append(0xEE)
        return packet

    def makePacketSetState(self, state: bool) -> bytearray:
        packet = bytearray([0xF7, 0x0B, 0x01, 0x15, 0x02, 0x40])
        packet.append((self.room_index << 4) + (self.index + 1))
        if state:
            packet.extend([0x01, 0x00])
        else:
            packet.extend([0x02, 0x00])
        packet.append(self.calcXORChecksum(packet))
        packet.append(0xEE)
        return packet

3.3. Packet Parser 수정

RS-485 패킷 파싱을 담당하는 PacketParser.py도 4번째 바이트가 0x15인 감성조명 패킷을 제대로 핸들링할 수 있도록 수정해줬다

class PacketParser:
    def interpretPacket(self, packet: bytearray):
        if self.type_interpret == ParserType.REGULAR:
            if packet[3] == 0x15:  # 감성조명
                self.handleEmotionLight(packet)
                packet_info['device'] = 'emotion light'
            elif packet[3] == 0x19:  # 일반조명
                self.handleLight(packet)
                packet_info['device'] = 'emotion light'
            # 후략
    
    def handleEmotionLight(self, packet: bytearray):
        room_idx = packet[6] >> 4
        if packet[4] == 0x01:  # 상태 쿼리
            pass
        elif packet[4] == 0x02:  # 상태 변경 명령
            pass
        elif packet[4] == 0x04:  # 각 방별 On/Off
            dev_idx = packet[6] & 0x0F
            if dev_idx == 0:  # 일반 쿼리 (존재하는 모든 디바이스)
                self.log(f'Warning: Un-implemented packet interpreter (zero device index, {self.prettifyPacket(packet)})')
            else:  # 상태 변경 명령 직후 응답
                state = 0 if packet[8] == 0x02 else 1
                result = {
                    'device': DeviceType.EMOTIONLIGHT, 
                    'index': dev_idx - 1,
                    'room_index': room_idx,
                    'state': state
                }
                self.updateDeviceState(result)

3.4. 감성조명 관련 MQTT 구독/발행 코드 수정

Home.py의 MQTT 메시지 구독 관련 코드도 수정해줬다

class Home:
    def updateDeviceState(self, result: dict):
        dev_type: DeviceType = result.get('device')
        dev_idx: int = result.get('index')
        room_idx: int = result.get('room_index')
        device = self.findDevice(dev_type, dev_idx, room_idx)
        if dev_type in [
                DeviceType.LIGHT,
                DeviceType.EMOTIONLIGHT,
                DeviceType.OUTLET,
                DeviceType.GASVALVE,
                DeviceType.BATCHOFFSWITCH]:
            state = result.get('state')
            device.updateState(state)
     
     def onMqttClientMessage(self, _, userdata, message):
         if 'command/emotionlight' in topic:
            self.onMqttCommandEmotionLight(topic, msg_dict)
     
     def onMqttCommandEmotionLight(self, topic: str, message: dict):
        splt = topic.split('/')
        room_idx = int(splt[-2])
        dev_idx = int(splt[-1])
        device = self.findDevice(DeviceType.EMOTIONLIGHT, dev_idx, room_idx)
        if 'state' in message.keys():
            self.send_command(
                device=device,
                category='state',
                target=message['state']
            )

3.5. Github Commit

3.1. ~ 3.4. 외에도 변경사항이 더 많은데, 자세히 알아보고 싶다면 아래 커밋 페이지에서 코드 변경점을 살펴볼 수 있다

https://github.com/YOGYUI/HomeNetwork/commit/af3402f24740941423e2b3088cd44f4126d09cc1

 

- 감성조명 디바이스 타입 추가 · YOGYUI/HomeNetwork@af3402f

- 어플리케이션 버전 관리 추가

github.com

4. 테스트

감성조명이 설치된 월패드 실물을 구할 수가 없기에 알파테스트를 수행할 수가 없어 곧바로 유저분께 답신을 드려 베타테스트를 시작했다

별 문제없이 감성조명의 단순 ON/OFF 기능은 문제없이 잘 동작한다고 한다!

실제 동작하는 사진이나 동영상도 같이 보내주셨으면 포스팅이 더 멋있어졌을(?)건데 약간 아쉽긴 하다 ㅎㅎ

5. 결론

베타테스트 완료 후 HA 애드온 버전 업데이트도 진행했다 (1.0.9)

대략 2달만에 진행된 업데이트!

DockerHub의 아키텍쳐별 이미지도 전부 1.0.9 버전으로 업데이트했으며, HA에서 애드온 업데이트도 가능하다

※ 감성조명이 없는 월패드라면 굳이 애드온 업데이트를 진행하지 않아도 된다


P.S.

이번에도 감사하게 치킨 쿠폰을 선물받았다 ^^

이미 사용 완료한 쿠폰 ^^

싱가포르와의 월드컵 2차예선전을 관전하며 신나게 뜯었다 (인증 사진 찍는것도 잊을만큼 화끈하게 골을 때려박아 7대0 승리! ㅋㅋ)

 

반응형