YOGYUI

현대통신 월패드 RS-485 연동 소스코드(python) 개선 작업 본문

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

현대통신 월패드 RS-485 연동 소스코드(python) 개선 작업

요겨 2023. 6. 19. 10:16
반응형

Hyundai Wallpad RS-485 Python Source Code Enhancement

 

지난주 목요일 (6월 15일) 힐스테이트 소스코드 관련 지원 요청 이메일을 받았다

소스코드가 워낙에 조악하게 기능 구현에만 충실하게 짜놨다보니 디버깅 혹은 원격지원 관련해서는 이렇게 이메일로 받아볼 수 밖에 없는 안타까운 현실..

 

그렇다 하더라도 괜히 AWS같은 클라우드를 도입하기에는 딱히 코드로 부가 수익을 내는게 아니기때문에 부담스럽... 뭔가 정식으로 제품을 출시하지 않을 바에야 그냥 앞으로도 이렇게 유저분들이 보내주시는 수동 에러 리포트에 대응하면서 코드를 개선해나갈 생각 ㅋㅋ

1. 문제점 파악

에러 리포트 이메일에 첨부된 로그는 다음과 같다


<23:22:10.406552> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 19 01 40 10 00 00 B5 EE
<23:22:10.730232> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0D 01 19 04 40 10 00 02 02 02 B4 EE
<23:22:10.890279> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 1F 01 40 20 00 00 83 EE
<23:22:10.891901> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 34 01 41 10 00 00 00 00 9F EE
<23:22:10.953304> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 1C 01 1F 04 40 20 00 21 01 00 00 00 00 00 00 02 22 01 00 00 00 00 00 00 02 92 EE
<23:22:11.348932> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0B 01 1B 01 43 11 00 00 B5 EE
<23:22:11.412824> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 1B 04 43 11 00 03 00 00 B5 EE
<23:22:11.484604> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0E 01 2A 01 40 10 00 19 00 1B 03 82 EE
<23:22:11.485020> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 19 01 40 20 00 00 85 EE
<23:22:11.814052> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0C 01 19 04 40 20 00 02 02 87 EE
<23:22:12.040436> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 1F 01 40 30 00 00 93 EE
<23:22:12.041157> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 34 01 41 10 00 00 00 00 9F EE
<23:22:12.117436> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 1C 01 1F 04 40 30 00 31 01 00 3C 00 00 00 00 02 32 01 00 31 00 00 00 00 02 8F EE
<23:22:12.439258> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0B 01 18 01 46 10 00 00 B2 EE
<23:22:12.531168> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 22 01 18 04 46 10 00 04 1B 1C 04 1C 15 04 1C 19 04 1C 17 00 00 00 00 00 00 00 00 00 00 00 00 9E EE
<23:22:12.665028> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::list index out of range ({'device': <DeviceType.LIGHT: 1>, 'index': 1, 'room_index': 3, 'state': 0})
<23:22:12.737188> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 34 01 41 10 00 00 00 00 9F EE
<23:22:13.070259> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 19 01 40 30 00 00 95 EE
<23:22:13.144309> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0C 01 19 04 40 30 00 02 02 97 EE
<23:22:13.206216> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0B 01 1B 01 43 11 00 00 B5 EE
<23:22:13.269856> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 1B 04 43 11 00 03 00 00 B5 EE
<23:22:13.271038> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 1F 01 40 40 00 00 E3 EE
<23:22:13.340985> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0E 01 2A 01 40 10 00 19 00 1B 03 82 EE
<23:22:13.341555> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 1C 01 1F 04 40 40 00 41 01 00 00 00 00 00 00 02 42 01 00 00 00 00 00 00 02 F2 EE
<23:22:13.731892> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::list index out of range ({'device': <DeviceType.LIGHT: 1>, 'index': 1, 'room_index': 4, 'state': 0})
<23:22:13.803251> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 34 01 41 10 00 00 00 00 9F EE
<23:22:14.136822> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 19 01 40 40 00 00 E5 EE
<23:22:14.208030> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0C 01 19 04 40 40 00 02 02 E7 EE
<23:22:14.208347> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::'NoneType' object has no attribute 'outlets' ({'device': <DeviceType.OUTLET: 2>, 'index': 0, 'room_index': 5, 'state': 1})
<23:22:14.208502> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::'NoneType' object has no attribute 'outlets' ({'device': <DeviceType.OUTLET: 2>, 'index': 1, 'room_index': 5, 'state': 1})
<23:22:14.284661> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0B 01 1B 01 43 11 00 00 B5 EE
<23:22:14.351339> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0D 01 1B 04 43 11 00 03 00 00 B5 EE
<23:22:14.351739> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 0B 01 1F 01 40 50 00 00 F3 EE
<23:22:14.421201> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0E 01 2A 01 40 10 00 19 00 1B 03 82 EE
<23:22:14.422636> [ParserVarious (0x7F36C42FABB0)] Unknown packet (??): F7 1C 01 1F 04 40 50 00 51 01 00 00 00 00 00 00 02 52 01 00 5F 00 00 00 00 02 BD EE
<23:22:14.748416> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 0B 01 18 01 46 10 00 00 B2 EE
<23:22:14.841085> [ParserLight (0x7F36C42FA5E0)] Unknown packet: F7 22 01 18 04 46 10 00 04 1B 1C 04 1C 15 04 1C 19 04 1C 17 00 00 00 00 00 00 00 00 00 00 00 00 9E EE
<23:22:14.974426> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::'NoneType' object has no attribute 'lights' ({'device': <DeviceType.LIGHT: 1>, 'index': 0, 'room_index': 5, 'state': 0})
<23:22:14.975034> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::'NoneType' object has no attribute 'lights' ({'device': <DeviceType.LIGHT: 1>, 'index': 1, 'room_index': 5, 'state': 0})
<23:22:14.975284> [Home (0x7F36C42C6FD0)] handlePacketParseResult::Exception::'NoneType' object has no attribute 'lights' ({'device': <DeviceType.LIGHT: 1>, 'index': 2, 'room_index': 5, 'state': 0})


  • 두 개의 RS-485 포트에 후킹을 위해 장착한 컨버터 모듈(USB-to-RS485 혹은 EW-11)에서 수신되는 패킷을 해석하는 'ParserLight'와 'ParserVarious' 객체 모두 'Unknown packet' 로그 발생
  • 'NoneType' object has no attribute 'lights' 로그 발생

두 번째 이슈는 config.xml 파일을 수정하면 해결될 것으로 보이는데, 첫번째 문제는 상당히 문제가 심각했다

(실제로 Unknown packet 로그 뒤에 붙는 패킷 raw bytes를 보면 정상적인 패킷으로 보이기 때문)

 

좀 더 현상 파악을 위해 유저분과 이메일을 주고받으면서 알게된 약간은 충격적인 사실

RS-485 포트를 하나만 사용하는 환경이 있다!

조명, 난방, 가스(밸브), 대기전력(아울렛), 일괄소등 스위치, 엘리베이터, HEMS 모두 월패드 뒷면의 하나의 RS-485 포트('1번 포트')만 사용하고 있는 상황.. 

 

기존 코드 UML
힐스테이트 광교산 월패드 후면 RS-485 체결 형태

우리집 (힐스테이트 광교산)은 월패드 뒷면의 RS-485 포트 두개가 모두 사용되고 있는 환경이라 각 포트별로 특화된 패킷 파서 클래스를 구현해둔 상태

※ 각 패킷 파서는 패킷의 4번째 바이트(buffer[3])의 값에 따라 디바이스 종류를 판별

  • ParserLight: 조명/콘센트(아울렛) 관련 패킷을 해석
    buffer[3] == 0x19: 조명 → handleLight 메서드 호출
    buffer[3] == 0x1E: 현관 도어락 
    buffer[3] == 0x1F: 콘센트(아울렛) → handleOutlet 메서드 호출
    buffer[3] == 0x43: 에너지 사용량(콘센트) 쿼리
    이외에는 모두 'Unknown packet'으로 처리
  • ParserVarious: 기타 디바이스 패킷 해석
    buffer[3] == 0x18: 난방 → handleThermostat 메서드 호출
    buffer[3] == 0x1B: 가스차단기 → handleGasValve 메서드 호출
    buffer[3] == 0x1C: 시스템에어컨 → handleAirconditioner 메서드 호출
    buffer[3] == 0x2A: 일괄소등 스위치 → handleBatchOffSwitch 메서드 호출
    buffer[3] == 0x2B: 환기(전열교환기) → handleVentilator 메서드 호출
    buffer[3] == 0x34: 엘리베이터 → handleElevator 메서드 호출
    buffer[3] == 0x43: HEMS  handleEnergyMonitoring 메서드 호출
    buffer[3] == 0x44: 현재 시간
    buffer[3] == 0x48: ??? (정체 파악 못함)
    이외에는 모두 'Unknown packet'으로 처리
  • ParserSubPhone: 주방 서브폰 (baud 3840) 패킷 해석
    주방 서브폰 RS-485 포트 사용하지 않을 경우 의미없음

결국 1개의 RS-485 포트에 'ParserLight'와 'ParserVarious' 두 객체를 모두 연동할 경우 객체가 각각 처리할 수 있는 패킷 외에는 모두 'Unknown packet' 오류를 출력할 수 밖에 없는 구조...

 

무엇이 문제인지는 판별 완료!

2. 코드 구조 개선 방안

메일 주신 분께서는 급한대로 '조명'이라도 사용을 원하셨다

그런데 아무리 짱구를 굴려봐도 기존 코드 구조를 조금 수정해서는 위에서 발견한 문제를 해결할 수 없겠다는 생각이 들어서 소스코드를 대폭 수정하기로 결심했다


가장 시급한 건 패킷 파서 클래스를 하나로 통합하는 것이다

각 포트별로 해석되는 패킷이 모두 동일한 구조를 가지며, 4번째 바이트값이 디바이스 종류를 결정하는데 겹치는 값이 없으므로 손쉽게 다음과 같이 통합할 수 있다

파서 클래스 통합 방안

※ 엄밀히 말하면 패킷 구조가 동일한 것은 아니다
조명, 가스 등의 9600 baud를 사용하는 패킷은 F7 XX XX ... EE 구조이며, 3840 baud를 사용하는 주방 서브폰 패킷은 7F XX ... EE 구조를 가지기 때문에 패킷 파서에서 이를 유의해서 처리해줘야 한다
패킷 자체의 '타입'을 지정해주면 될듯?

또한, 기존에는 'Room' 클래스를 구현해 Room 안에 조명, 아울렛, 난방, 에어컨 객체를 멤버변수로 가지게 구현해뒀었다

(Light 및 Outlet과 Room의 릴레이션은 N:1, Thermostat 및 Airconditioner와 Room의 릴레이션은 1:1)

기존 Device/Room/Home UML

또한 Home은 Room에 포함되지 않은 디바이스 (엘리베이터, 가스밸브, 전열교환기 등)들을 각각 멤버변수로 가지고 있어 실제 환경에서 '무조건' 연동되어 있다는 가정이 전제된 구조로 구현되어 있었다

(사용하지 않게 하기 위해서는 별도로 수정이 필요했음)

 

힐스테이트 광교산과는 구조가 다른 환경 (방 개수가 다름 / 환기나 일괄 소등 등 디바이스의 유무 등)에서도 유연하게 돌아가게 하기 위해 다음과 같이 수정해줘야 한다

  • Home 객체가 별도로 클래스 종속적인 멤버변수들을 가지지 않고, 디바이스들의 부모 클래스인 Device를 리스트로 가짐 (Home과 Device의 릴레이션의 1:N)
  • 'Room' 클래스는 제거
  • 각 Device는 공통적으로 'room_index' 멤버변수를 가짐

코드 수정방안 UML

3. 코드 구현

2.1. 패킷 파서 클래스 통합

기존의 PacketLight와 PacketVarious 및 PacketSubphone 3 클래스 내부 구현 코드를 PacketParser 클래스 하나로 통합해줬다 (코드 자체가 길어질 뿐이지 그다지 어려운 작업은 아님)

체크포인트는 'ParserType' 열거형 클래스로 RS-485 포트의 타입을 지정할 수 있게 했다는 정도?

(9600 baud 패킷 형태와 3840 baud 주방 서브폰의 패킷 형태가 상이함)

- handlePacket 메서드에서 각 타입 분기에 따라 raw byte stream을 어떻게 끊어서 해석할 지가 결정된다

@unique
class ParserType(IntEnum):
    REGULAR = 0         # 일반 RS-485 (baud 9600)
    SUBPHONE = auto()   # 주방 서브폰 (baud 3840)
    UNKNOWN = auto()

class PacketParser:
    name: str = "Parser"
    rs485: RS485Comm
    buffer: bytearray
    enable_console_log: bool = False
    chunk_cnt: int = 0
    max_chunk_cnt: int = 1e6
    max_buffer_size: int = 200
    line_busy: bool = False
    type_interpret: ParserType = ParserType.REGULAR

    packet_storage: List[dict]
    max_packet_store_cnt: int = 100

    def __init__(self, rs485_instance: RS485Comm, name: str, type_interpret: ParserType = ParserType.REGULAR):
        self.buffer = bytearray()
        self.sig_parse_result = Callback(dict)
        self.name = name
        self.rs485 = rs485_instance
        self.rs485.sig_send_data.connect(self.onSendData)
        self.rs485.sig_recv_data.connect(self.onRecvData)
        self.type_interpret = type_interpret
        self.packet_storage = list()

    def __repr__(self):
        repr_txt = f'<{self.name}({self.__class__.__name__} at {hex(id(self))})>'
        return repr_txt

    def release(self):
        self.buffer.clear()

    def sendPacket(self, packet: Union[bytes, bytearray], log: bool = True):
        self.log_send_result = log
        self.rs485.sendData(packet)

    def sendString(self, packet_str: str):
        self.rs485.sendData(bytearray([int(x, 16) for x in packet_str.split(' ')]))

    def onSendData(self, data: bytes):
        if self.log_send_result:
            msg = ' '.join(['%02X' % x for x in data])
            self.log(f"Send >> {msg}")
        self.log_send_result = True

    def onRecvData(self, data: bytes):
        self.line_busy = True
        if len(self.buffer) > self.max_buffer_size:
            self.buffer.clear()
            self.line_busy = False
        self.buffer.extend(data)
        self.handlePacket()

    def log(self, message: str):
        writeLog(f"<{self.name}> {message}", self)

    def handlePacket(self):
        if self.type_interpret is ParserType.REGULAR:
            idx = self.buffer.find(0xF7)
            if idx > 0:
                self.buffer = self.buffer[idx:]
            if len(self.buffer) >= 2:
                packet_length = self.buffer[1]
                if len(self.buffer) >= packet_length:
                    if self.buffer[0] == 0xF7 and self.buffer[packet_length - 1] == 0xEE:
                        self.line_busy = False
                        packet = self.buffer[:packet_length]
                        try:
                            checksum_calc = self.calcXORChecksum(packet[:-2])
                            checksum_recv = packet[-2]
                            if checksum_calc == checksum_recv:
                                self.interpretPacket(packet)
                            else:
                                pacstr = self.prettifyPacket(packet)
                                self.log(f'Checksum Error (calc={checksum_calc}, recv={checksum_recv}) ({pacstr})')
                            self.buffer = self.buffer[packet_length:]
                        except IndexError:
                            buffstr = self.prettifyPacket(self.buffer)
                            pacstr = self.prettifyPacket(packet)
                            self.log(f'Index Error (buffer={buffstr}, packet_len={packet_length}, packet={pacstr})')
        elif self.type_interpret is ParserType.SUBPHONE:
            idx = self.buffer.find(0x7F)
            if idx > 0:
                self.buffer = self.buffer[idx:]
            if len(self.buffer) >= 2:
                idx2 = self.buffer.find(0xEE)
                if idx2 > 0:
                    self.line_busy = False
                    packet = self.buffer[:idx2 + 1]
                    self.interpretPacket(packet)
                    self.buffer = self.buffer[idx2 + 1:]
            elif len(self.buffer) == 1 and self.buffer[0] == 0xEE:
                self.line_busy = False
                self.buffer.clear()
        elif self.type_interpret is ParserType.UNKNOWN:
            self.buffer.clear()
            self.log(f'Invalid Parser Type ({self.type_interpret})')
    
    def interpretPacket(self, packet: bytearray):
        store: bool = True
        packet_info = {'packet': packet, 'timestamp': datetime.datetime.now()}
        try:
            if self.type_interpret == ParserType.REGULAR:
                if packet[3] == 0x18:  # 난방
                    self.handleThermostat(packet)
                    packet_info['device'] = 'thermostat'
                    store = self.enable_store_packet_header_18
                elif packet[3] == 0x19:  # 조명
                    self.handleLight(packet)
                    packet_info['device'] = 'light'
                    store = self.enable_store_packet_header_19
                elif packet[3] == 0x1B:  # 가스차단기
                    self.handleGasValve(packet)
                    packet_info['device'] = 'gasvalve'
                    store = self.enable_store_packet_header_1B
                elif packet[3] == 0x1C:  # 시스템에어컨
                    self.handleAirconditioner(packet)
                    store = self.enable_store_packet_header_1C
                    packet_info['device'] = 'airconditioner'
                elif packet[3] == 0x1E:  # 현관 도어락 (?)
                    self.log(f'Doorlock Packet: {self.prettifyPacket(packet)}')
                    packet_info['device'] = 'doorlock'
                    store = self.enable_store_packet_header_1E
                elif packet[3] == 0x1F:  # 아울렛 (콘센트)
                    self.handleOutlet(packet)
                    packet_info['device'] = 'outlet'
                    store = self.enable_store_packet_header_1F
                elif packet[3] == 0x2A:  # 일괄소등 스위치
                    self.handleBatchOffSwitch(packet)
                    packet_info['device'] = 'multi function switch'
                    store = self.enable_store_packet_header_2A
                elif packet[3] == 0x2B:  # 환기 (전열교환기)
                    self.handleVentilator(packet)
                    packet_info['device'] = 'ventilator'
                    store = self.enable_store_packet_header_2B
                elif packet[3] == 0x34:  # 엘리베이터
                    self.handleElevator(packet)
                    packet_info['device'] = 'elevator'
                    store = self.enable_store_packet_header_34
                elif packet[3] == 0x43:  # 에너지 모니터링
                    self.handleEnergyMonitoring(packet)
                    packet_info['device'] = 'hems'
                    store = self.enable_store_packet_header_43
                elif packet[3] == 0x44:  # maybe current date-time?
                    if packet[4] == 0x0C:  # broadcasting?
                        packet_info['device'] = 'timestamp'
                        if self.enable_trace_timestamp_packet:
                            year, month, day = packet[8], packet[9], packet[10]
                            hour, minute, second = packet[11], packet[12], packet[13]
                            millis = packet[14] * 100 + packet[15] * 10 + packet[16]
                            dt = datetime.datetime(year, month, day, hour, minute, second, millis * 1000)
                            self.log(f'Timestamp Packet: {self.prettifyPacket(packet)}')
                            self.log(f'>> {dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]}')
                    else:
                        packet_info['device'] = 'unknown'
                        self.log(f'Unknown packet (44): {self.prettifyPacket(packet)}')
                    store = self.enable_store_packet_header_44
                elif packet[3] == 0x48:  # ??
                    packet_info['device'] = 'unknown'
                    store = self.enable_store_packet_header_48
                else:
                    self.log(f'Unknown packet: {self.prettifyPacket(packet)}')
                    packet_info['device'] = 'unknown'
                    store = self.enable_store_packet_unknown
            elif self.type_interpret == ParserType.SUBPHONE:
                if (packet[1] & 0xF0) == 0xB0:  # 현관 도어폰 호출
                    self.handleFrontDoor(packet)
                elif (packet[1] & 0xF0) == 0x50:  # 공동 현관문 호출
                    self.handleCommunalDoor(packet)
                elif (packet[1] & 0xF0) == 0xE0:  # HEMS
                    self.handleHEMS(packet)
                else:
                    self.log(f'{self.prettifyPacket(packet)} >> ???')

            if store:
                if len(self.packet_storage) > self.max_packet_store_cnt:
                    self.packet_storage.pop(0)
                self.packet_storage.append(packet_info)
        except Exception as e:
            self.log(f'interpretPacket::Exception::{e} ({self.prettifyPacket(packet)})')
    
    def startRecv(self, count: int = 64):
        self.buffer.clear()
        self.chunk_cnt = 0
        self.enable_console_log = True
        while self.chunk_cnt < count:
            pass
        self.enable_console_log = False

    def setRS485LineBusy(self, value: bool):
        self.line_busy = value

    def isRS485LineBusy(self) -> bool:
        if self.rs485.getType() == RS485HwType.Socket:
            return False  # 무선 송신 레이턴시때문에 언제 라인이 IDLE인지 정확히 파악할 수 없다
        return self.line_busy

    def getRS485HwType(self) -> RS485HwType:
        return self.rs485.getType()

    def clearPacketStorage(self):
        self.packet_storage.clear()

    def setBufferSize(self, size: int, clear_buffer: bool = True):
        self.max_buffer_size = size
        if clear_buffer:
            self.buffer.clear()
        self.log(f'Recv Buffer Size: {self.max_buffer_size}')

    @staticmethod
    def prettifyPacket(packet: bytearray) -> str:
        return ' '.join(['%02X' % x for x in packet])
    
    @staticmethod
    def calcXORChecksum(data: Union[bytearray, bytes, List[int]]) -> int:
        return reduce(lambda x, y: x ^ y, data, 0)

    def handleLight(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:  # 일반 쿼리 (존재하는 모든 디바이스)
                light_count = len(packet) - 10
                for idx in range(light_count):
                    state = 0 if packet[8 + idx] == 0x02 else 1
                    result = {
                        'device': DeviceType.LIGHT, 
                        'index': idx,
                        'room_index': room_idx,
                        'state': state
                    }
                    self.sig_parse_result.emit(result)
            else:  # 상태 변경 명령 직후 응답
                state = 0 if packet[8] == 0x02 else 1
                result = {
                    'device': DeviceType.LIGHT, 
                    'index': dev_idx - 1,
                    'room_index': room_idx,
                    'state': state
                }
                self.sig_parse_result.emit(result)

    def handleOutlet(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:  # 일반 쿼리 (모든 디바이스)
                outlet_count = (len(packet) - 10) // 9
                for idx in range(outlet_count):
                    dev_packet = packet[8 + idx * 9: 8 + (idx + 1) * 9]
                    state = 0 if dev_packet[1] == 0x02 else 1
                    result = {
                        'device': DeviceType.OUTLET,
                        'index': idx,
                        'room_index': room_idx,
                        'state': state
                    }
                    self.sig_parse_result.emit(result)
            else:  # 상태 변경 명령 직후 응답
                state = 0 if packet[8] == 0x02 else 1
                result = {
                    'device': DeviceType.OUTLET,
                    'index': dev_idx - 1,
                    'room_index': room_idx,
                    'state': state
                }
                self.sig_parse_result.emit(result)

    def handleGasValve(self, packet: bytearray):
        if packet[4] == 0x01:  # 상태 쿼리
            pass
        elif packet[4] == 0x02:  # 상태 변경 명령
            pass
        elif packet[4] == 0x04:  # 상태 응답
            state = 0 if packet[8] == 0x03 else 1
            result = {
                'device': DeviceType.GASVALVE,
                'state': state
            }
            self.sig_parse_result.emit(result)
    
    def handleThermostat(self, packet: bytearray):
        room_idx = packet[6] & 0x0F
        if packet[4] == 0x01:  # 상태 쿼리
            pass
        elif packet[4] == 0x02:  # On/Off, 온도 변경 명령
            pass
        elif packet[4] == 0x04:  # 상태 응답
            if room_idx == 0:  # 일반 쿼리 (존재하는 모든 디바이스)
                thermostat_count = (len(packet) - 10) // 3
                for idx in range(thermostat_count):
                    dev_packet = packet[8 + idx * 3: 8 + (idx + 1) * 3]
                    if dev_packet[0] != 0x00:  # 0이면 존재하지 않는 디바이스
                        state = 0 if dev_packet[0] == 0x04 else 1                            
                        temp_current = dev_packet[1]  # 현재 온도
                        temp_config = dev_packet[2]  # 설정 온도
                        result = {
                            'device': DeviceType.THERMOSTAT,
                            'room_index': idx + 1,
                            'state': state,
                            'temp_current': temp_current,
                            'temp_config': temp_config
                        }
                        self.sig_parse_result.emit(result)
            else:  # 상태 변경 명령 직후 응답
                if packet[5] in [0x45, 0x46]:  # 0x46: On/Off 설정 변경에 대한 응답, 0x45: 온도 설정 변경에 대한 응답
                    state = 0 if packet[8] == 0x04 else 1
                    temp_current = packet[9]  # 현재 온도
                    temp_config = packet[10]  # 설정 온도
                    result = {
                        'device': DeviceType.THERMOSTAT,
                        'room_index': room_idx,
                        'state': state,
                        'temp_current': temp_current,
                        'temp_config': temp_config
                    }
                    self.sig_parse_result.emit(result)
    
    def handleVentilator(self, packet: bytearray):
        if packet[4] == 0x01:
            pass
        elif packet[4] == 0x02:
            pass
        elif packet[4] == 0x04:
            state = 0 if packet[8] == 0x02 else 1
            rotation_speed = packet[9]  # 0x01=약, 0x03=중, 0x07=강
            result = {
                'device': DeviceType.VENTILATOR,
                'state': state
            }
            if rotation_speed != 0:
                result['rotation_speed'] = rotation_speed
            self.sig_parse_result.emit(result)

    def handleAirconditioner(self, packet: bytearray):
        room_idx = packet[6] >> 4
        if packet[4] == 0x01:  # 상태 쿼리
            pass
        elif packet[4] == 0x02:  # On/Off, 온도 변경 명령
            pass
        elif packet[4] == 0x04:  # 상태 응답
            state = 0 if packet[8] == 0x02 else 1
            temp_current = packet[9]  # 현재 온도
            temp_config = packet[10]  # 설정 온도
            mode = packet[11]  # 모드 (0=자동, 1=냉방, 2=제습, 3=공기청정)
            rotation_speed = packet[12]  # 풍량 (1=자동, 2=미풍, 3=약풍, 4=강풍)
            result = {
                'device': DeviceType.AIRCONDITIONER,
                'room_index': room_idx,
                'state': state,
                'temp_current': temp_current,
                'temp_config': temp_config,
                'mode': mode,
                'rotation_speed': rotation_speed
            }
            self.sig_parse_result.emit(result)

    def handleElevator(self, packet: bytearray):
        if packet[4] == 0x01:  # 상태 쿼리 (월패드 -> 복도 미니패드)
            state_h = (packet[8] & 0xF0) >> 4  # 상위 4비트, 0x0: stopped, 0xA: moving (up), 0x0B: moving (down)
            state_l = packet[8] & 0x0F  # 하위 4비트, 0x0: idle, 0x1: arrived, 0x5: command (up), 0x6: command (down)
            floor = '{:02X}'.format(packet[9])
            ev_dev_idx = packet[10]  # 엘리베이터 n호기, 2대 이상의 정보가 교차로 들어오게 됨, idle일 경우 0

            state = 0  # idle (command done, command off)
            if state_h == 0x0:
                direction = 0
                if state_l == 0x1:
                    state = 1  # arrived
            else:
                direction = 0
                if state_h == 0xA:  # Up
                    direction = 5
                elif state_h == 0xB:  # Down
                    direction = 6
                if state_l == 0x5:  # Up
                    state = 5
                elif state_l == 0x6:  # Down
                    state = 6
                
            result = {
                'device': DeviceType.ELEVATOR,
                'data_type': 'query',
                'state': state,
                'ev_dev_idx': ev_dev_idx,
                'direction': direction,
                'floor': floor
            }
            self.sig_parse_result.emit(result)
        elif packet[4] == 0x02:
            pass
        elif packet[4] == 0x04:  # 상태 응답 (복도 미니패드 -> 월패드)
            state = packet[8] & 0x0F  # 0 = idle, 6 = command (하행) 호출
            result = {
                'device': DeviceType.ELEVATOR,
                'data_type': 'response',
                'state': state
            }
            # print(f'Response: {self.prettifyPacket(packet)}, {result}')
            self.sig_parse_result.emit(result)

    def handleEnergyMonitoring(self, packet: bytearray):
        if packet[4] == 0x01:  # 상태 쿼리
            pass
        elif packet[4] == 0x04:
            # 값들이 hexa encoding되어있다!
            if packet[5] == 0x11:  # 전기 사용량
                value = int(''.join('%02X' % x for x in packet[7:12]))
                # self.log(f'EMON - Electricity: {value}')
            elif packet[5] == 0x13:  # 가스 사용량
                value = int(''.join('%02X' % x for x in packet[7:12]))
                # self.log(f'EMON - Gas: {value}')
            elif packet[5] == 0x14:  # 수도 사용량
                value = int(''.join('%02X' % x for x in packet[7:12]))
                # self.log(f'EMON - Water: {value}')
            elif packet[5] == 0x15:  # 온수 사용량
                value = int(''.join('%02X' % x for x in packet[7:12]))
                # self.log(f'EMON - Hot Water: {value}')
            elif packet[5] == 0x16:  # 난방 사용량
                value = int(''.join('%02X' % x for x in packet[7:12]))
                # self.log(f'EMON - Heating: {value}')
            else:
                self.log(f'> {self.prettifyPacket(packet)}')

    def handleBatchOffSwitch(self, packet: bytearray):
        if packet[4] == 0x01:  # 상태 쿼리
            pass
        elif packet[4] == 0x04:
            state = 0 if packet[9] == 0x02 else 1
            result = {
                'device': DeviceType.BATCHOFFSWITCH,
                'state': state
            }
            self.sig_parse_result.emit(result)
    
    def handleFrontDoor(self, packet: bytearray):
        result = {'device': DeviceType.SUBPHONE}
        notify: bool = True
        if packet[1] == 0xB5:
            # 현관 도어폰 초인종 호출 (월패드 -> 서브폰)
            result['ringing_front'] = 1
            self.log(f'{self.prettifyPacket(packet)} >> Front door ringing started')
        elif packet[1] == 0xB6:
            # 현관 도어폰 초인종 호출 종료 (월패드 -> 서브폰)
            result['ringing_front'] = 0
            self.log(f'{self.prettifyPacket(packet)} >> Front door ringing terminated')
        elif packet[1] == 0xB9:
            # 서브폰에서 현관 통화 시작 (서브폰 -> 월패드)
            result['streaming'] = 1
            self.log(f'{self.prettifyPacket(packet)} >> Streaming (front door) started from Subphone')
        elif packet[1] == 0xBA:
            # 서브폰에서 현관 통화 종료 (서브폰 -> 월패드)
            result['streaming'] = 0
            self.log(f'{self.prettifyPacket(packet)} >> Streaming (front door) terminated from Subphone')
        elif packet[1] == 0xB4:
            # 서브폰에서 현관문 열림 명령 (서브폰 -> 월패드)
            result['doorlock'] = 0  # Unsecured
            self.log(f'{self.prettifyPacket(packet)} >> Open front door from Subphone')
        elif packet[1] in [0xBB, 0xB8]:
            # 현관 도어폰 통화 종료
            result['ringing_front'] = 0
            result['streaming'] = 0
            result['doorlock'] = 1  # Secured
            self.log(f'{self.prettifyPacket(packet)} >> Streaming finished')
        else:
            notify = False
            self.log(f'{self.prettifyPacket(packet)} >> ???')
        if notify:
            self.sig_parse_result.emit(result)

    def handleCommunalDoor(self, packet: bytearray):
        result = {'device': DeviceType.SUBPHONE}
        notify: bool = True
        if packet[1] == 0x5A:
            # 공동현관문 호출 (월패드 -> 서브폰)
            result['ringing_communal'] = 1
            self.log(f'{self.prettifyPacket(packet)} >> Communal door ringing started')
        elif packet[1] == 0x5C:
            # 공동현관문 호출 종료 (월패드 -> 서브폰)
            result['ringing_communal'] = 0
            self.log(f'{self.prettifyPacket(packet)} >> Communal door ringing terminated')
        elif packet[1] == 0x5E:
            # 공동현관문 통화 종료
            result['ringing_communal'] = 0
            result['streaming'] = 0
            result['doorlock'] = 1  # Secured
            self.log(f'{self.prettifyPacket(packet)} >> Streaming finished')
        else:
            notify = False
            self.log(f'{self.prettifyPacket(packet)} >> ???')
        if notify:
            self.sig_parse_result.emit(result)

    def handleHEMS(self, packet: bytearray):
        if packet[1] == 0xE0:
            # 쿼리 패킷 (서브폰 -> 월패드)
            pass
        elif packet[1] == 0xE1:
            result = {'device': DeviceType.HEMS, 'packet': packet}
            notify: bool = True
            # 응답 패킷 (월패드 -> 서브폰)
            devtype = HEMSDevType((packet[2] & 0xF0) >> 4)
            category = HEMSCategory(packet[2] & 0x0F)
            if category.value in [1, 2, 3, 4]:
                # 7F E1 XY 09 P1 P2 P3 Q1 Q2 Q3 R1 R2 R3 ZZ EE
                # X: 디바이스 타입
                # Y: 쿼리 타입
                # P1 P2 P3 : 당월 이력 값
                # Q1 Q2 Q3 : 전월 이력 값
                # R1 R2 R3 : 전전월 이력 값
                # ZZ : XOR Checksum
                v1 = int.from_bytes(packet[4:7], byteorder='big', signed=False)
                v2 = int.from_bytes(packet[7:10], byteorder='big', signed=False)
                v3 = int.from_bytes(packet[10:13], byteorder='big', signed=False)
                result[f'{devtype.name.lower()}_{category.name.lower()}_cur_month'] = v1
                result[f'{devtype.name.lower()}_{category.name.lower()}_1m_ago'] = v2
                result[f'{devtype.name.lower()}_{category.name.lower()}_2m_ago'] = v3
            elif category.value in [5, 7]:
                # 7F E1 XY 03 P1 P2 P3 ZZ EE
                # X: 디바이스 타입
                # Y: 쿼리 타입
                # P1~P3: 값
                # ZZ : XOR Checksum
                v = int.from_bytes(packet[4:7], byteorder='big', signed=False)
                result[f'{devtype.name.lower()}_{category.name.lower()}'] = v
            else:
                notify = False
                self.log(f'{self.prettifyPacket(packet)} >> ???')
            if notify:
                self.sig_parse_result.emit(result)
        elif packet[1] == 0xE2:
            if self.enable_trace_timestamp_packet:
                year, month, day = int('%02X' % packet[2]), int('%02X' % packet[3]), int('%02X' % packet[4])
                hour, minute, second = int('%02X' % packet[5]), int('%02X' % packet[6]), int('%02X' % packet[7])
                dt = datetime.datetime(year, month, day, hour, minute, second)
                self.log(f'Timestamp Packet: {self.prettifyPacket(packet)}')
                self.log(f'>> {dt.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]}')
        else:
            self.log(f'{self.prettifyPacket(packet)} >> ???')

2.2. Room 클래스 제거 및 Device 클래스 수정

수정 사항이 너무 많아 깃허브 커밋으로 대체 (ㅠ)

https://github.com/YOGYUI/HomeNetwork/commit/4cba376258465608b1dda0ed650d536920f6f95b

 

- log unregistered device when parsing packet · YOGYUI/HomeNetwork@4cba376

Show file tree Showing 4 changed files with 11 additions and 3 deletions.

github.com

메일을 받자마자 코드 설계 및 구현 작업을 해서 3일 정도 걸려서 마무리했다 

생각보다 Home 클래스 쪽에 개선해야 할 작업이 많아서 코드가 상당 부분 바뀌었다

처음에는 리팩터링 정도로 생각하고 접근했지만, 내부 구조가 완전히 달라져서 이전 버전의 config와는 호환이 불가능하게 되었다는... ㅠ_ㅠ

 

그래도 Readme 마크다운으로 꽤나 자세하게 설명해두긴 했다..

4. 마무리

일단 우리집인 힐스테이트 광교산에서는 모든 디바이스 동작이 원활하게 잘 되는 걸 확인했다

(mqtt topic 구조도 바꾸는 바람에 homebridge, home assistant config도 대폭 수정해야 했지만...)

(1) Home 초기화 시 config.xml 로드 후 Device 객체 생
(2) Device 객체 34개 생성 완료
(3) Home 초기화 완료 및 RS-485 컨버터 3개 연결 완료

 

메일 주신 분의 테스트 결과를 보고 잘 돌아간다 싶으면 바꾼 코드를 토대로 숙원사업(?)이던 'automatic device discovery' 기능까지 마무리해봐야겠다 

코드 수정 후 원격 지원 완료

그동안 손놓고 있었는데, 코드 구조를 확 개선하고나니 discovery 기능도 더 쉽게 구현할 수 있을 것 같다..

진작에 Room 객체를 좀 지웠어야했는데 ㅋㅋ

반응형
Comments