YOGYUI

힐스테이트 광교산::아울렛(콘센트) - 애플 홈킷 + 구글 어시스턴트 연동 본문

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

힐스테이트 광교산::아울렛(콘센트) - 애플 홈킷 + 구글 어시스턴트 연동

요겨 2022. 6. 14. 00:21
반응형

1. 패킷 분석

지난번에 월패드 분해 후 중앙제어社의 릴레이 모듈과 연결된 RS-485 통신선에 USB to RS485 컨버터 선을 연결해서 월패드와 각 방의 조명 패드들간에 오고가는 시리얼 패킷을 후킹했었다 (링크)

하나의 패킷이 0xF7 바이트로 시작하고, 0xEE 바이트로 끝나는 규칙을 갖는 것으로 판단하여 바이트스트림을 잘라냈을 때, 평상시에는 다음과 같은 패킷이 반복적으로 송수신되는 것을 확인할 수 있었다


F7 0B 01 19 01 40 10 00 00 B5 EE
F7 0D 01 19 04 40 10 00 02 02 02 B4 EE
F7 0B 01 19 01 40 20 00 00 85 EE
F7 0C 01 19 04 40 20 00 02 01 84 EE
F7 0B 01 19 01 40 30 00 00 95 EE
F7 0B 01 19 04 40 30 00 02 92 EE
F7 0B 01 19 01 40 40 00 00 E5 EE
F7 0B 01 19 04 40 40 00 02 E2 EE
F7 0B 01 19 01 40 60 00 00 C5 EE
F7 0C 01 19 04 40 60 00 02 02 C7 EE
F7 0B 01 1F 01 40 10 00 00 B3 EE
F7 1C 01 1F 04 40 10 00 11 01 00 06 00 00 00 00 02 12 01 00 00 00 00 00 00 02 A4 EE
F7 0B 01 1F 01 40 20 00 00 83 EE
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
F7 0B 01 1F 01 40 30 00 00 93 EE
F7 1C 01 1F 04 40 30 00 31 01 00 00 00 00 00 00 02 32 01 00 00 00 00 00 00 02 82 EE
F7 0B 01 1F 01 40 40 00 00 E3 EE
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
F7 0B 01 1F 01 40 60 00 00 C3 EE
F7 1C 01 1F 04 40 60 00 61 01 00 00 00 00 00 00 02 62 01 00 00 00 00 00 00 02 D2 EE


이 때, 3~4번째 바이트가 [0x01, 0x19]이면 조명과 관련된 패킷인 것을 알아냈고 이를 토대로 조명을 homebridge 및 home assistant와 연동하여 아이폰 등 애플 기기의 홈킷 및 안드로이드 기기의 구글 어시스턴트에서 제어할 수 있게 되었다

 

3~4번째 바이트가 [0x01, 0x1F]인 패킷은 어떤 디바이스를 제어하는 것인지 확인하기 위해 월패드를 이리저리 만지다가 결국 아울렛(콘센트)의 On/Off 상태인 것을 알게 되었다!

(아무래도 콘센트에 이것저것 꽂혀있는 전자기기들이 많다보니 실험에 제약이 있었다 ㅠ)


<주방 (공간 인덱스 = 6) 패킷>

F7 1C 01 1F 04 40 60 00 61 01 00 00 00 00 00 00 02 62 01 00 00 00 00 00 00 02 D2 EE : 평상시

F7 0B 01 1F 02 40 62 02 00 C0 EE : 주방 콘센트 2 OFF 명령 

F7 0B 01 1F 04 40 62 02 02 C4 EE : 명령 직후 응답

F7 1C 01 1F 04 40 60 00 61 01 00 00 00 00 00 00 02 62 02 00 00 00 00 00 00 02 D1 EE : OFF 후 응답

F7 0B 01 1F 02 40 62 01 00 C3 EE : 주방 콘센트 2 ON 명령

F7 0B 01 1F 04 40 62 01 01 C4 EE : 명령 직후 응답

F7 1C 01 1F 04 40 60 00 61 01 00 00 00 00 00 00 02 62 01 00 00 00 00 00 00 02 D2 EE : ON 후 응답

 

<컴퓨터방 (공간 인덱스 = 4) 패킷>

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 : 평상시

F7 0B 01 1F 02 40 41 02 00 E3 EE : 컴퓨터방 콘센트 1 OFF 명령 

F7 0B 01 1F 04 40 41 02 02 E7 EE

F7 1C 01 1F 04 40 40 00 41 02 00 00 00 00 00 00 02 42 01 00 00 00 00 00 00 02 F1 EE : OFF 후 응답

F7 0B 01 1F 02 40 41 01 00 E0 EE : 컴퓨터방 콘센트 1 ON 명령

F7 0B 01 1F 04 40 41 01 01 E7 EE : 명령 직후 응답

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 : ON 후 응답


 패킷 구조가 조명과 상당히 유사한 것을 알 수 있다

  • 5번째 바이트가 0x01이면 조회(쿼리) 패킷, 0x02이면 명령 패킷, 0x04이면 상태 정보를 담은 응답 패킷
  • 7번째 바이트의 상위 4비트는 공간 인덱스, 하위 4비트는 디바이스 인덱스
  • 응답 패킷의 7번째 바이트의 하위 4비트가 0일 경우, 해당 공간에 존재하는 모든 콘센트의 정보가 담겨있다
    9번째 바이트부터 9바이트 단위로 하나의 콘센트에 대한 정보가 담겨있으며, 해당 정보 스트림 중 첫번째 바이트의 상위 4비트는 공간 인덱스, 하위 4비트는 디바이스 인덱스(1-based)가 되며, 두번째 바이트가 0x02일 경우 ON, 0x01일 경우 ON 상태이다
  • 응답 패킷의 7번째 바이트의 하위 4비트가 0이 아닐 경우, 하나의 디바이스에 대한 ON/OFF 상태 정보만 담겨있으며, 마찬가지로 8번째 혹은 9번째 바이트가 0x02일 경우 ON, 0x01일 경우 ON 상태이다
  • 명령 패킷은 조명과 동일

 

따라서, 아울렛 패킷 파싱 구문은 조명과 거의 유사하게 다음과 같이 작성할 수 있다

(동일한 시리얼 포트로 동작하기 때문에 하나의 파서 객체로 구현)

class ParserLight(SerialParser):
    def handlePacket(self):
        idx = self.buffer.find(0xF7)
        if idx > 0:
            self.buffer = self.buffer[idx:]
        if len(self.buffer) >= 3:
            packet_length = self.buffer[1]
            if len(self.buffer) >= packet_length:
                if self.buffer[packet_length - 1] == 0xEE:
                    packet = self.buffer[:packet_length]
                    self.interpretPacket(packet)
                    self.buffer = self.buffer[packet_length:]
    
    def interpretPacket(self, packet: bytearray):
        try:
            if packet[2:4] == bytearray([0x01, 0x19]):  # 조명
                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
                            self.sig_parse_result.emit({
                                'device': 'light', 
                                'index': idx,
                                'room_index': room_idx,
                                'state': state
                            })
                    else:  # 상태 변경 명령 직후 응답
                        state = 0 if packet[8] == 0x02 else 1
                        self.sig_parse_result.emit({
                            'device': 'light', 
                            'index': dev_idx - 1,
                            'room_index': room_idx,
                            'state': state
                        })
            elif packet[2:4] == bytearray([0x01, 0x1F]):  # 아울렛 (콘센트)
                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):
                            # XX YY -- -- -- -- -- -- ZZ
                            # XX: 상위 4비트 = 공간 인덱스, 하위 4비트는 디바이스 인덱스
                            # YY: 02 = OFF, 01 = ON
                            # ZZ: 02 = 대기전력 차단 수동, 01 = 대기전력 차단 자동
                            # 중간에 있는 패킷들은 전력량계 데이터같은데, 파싱 위한 레퍼런스가 없음
                            dev_packet = packet[8 + idx * 9: 8 + (idx + 1) * 9]
                            state = 0 if dev_packet[1] == 0x02 else 1
                            self.sig_parse_result.emit({
                                'device': 'outlet',
                                'index': idx,
                                'room_index': room_idx,
                                'state': state
                            })
                    else:  # 상태 변경 명령 직후 응답
                        state = 0 if packet[8] == 0x02 else 1
                        self.sig_parse_result.emit({
                            'device': 'outlet',
                            'index': dev_idx - 1,
                            'room_index': room_idx,
                            'state': state
                        })
        except Exception as e:
            writeLog('interpretPacket::Exception::{} ({})'.format(e, packet), self)

2. 특이사항

우선, 평상 시 응답 패킷은 하나의 아울렛에 대해 9바이트가 하나의 정보를 담고 있는데, 대부분의 경우 6개 바이트가 0으로 채워져있다

하지만 거실의 첫번째 콘센트의 경우 다음과 같이 

F7 1C 01 1F 04 40 10 00 11 01 00 07 00 00 00 00 02 12 01 00 00 00 00 00 00 02 A5 EE

4번째 바이트가 0x07로 채워져있어 그 이유가 뭔지 상당히 궁금했는데, 월패드를 제어하다가 정체를 알아냈다

월패드 거실의 첫번째 콘센트의 현재 사용전력량이 7W로 표기되고 있는데, 이게 바로 패킷에 들어가있는 값의 의미였다

안타깝게도 거실의 1번 콘센트를 제외하고는 실시간 사용 전력량이 월패드에 나타나지 않는데, 이는 패킷 분석 시 모두 0으로 채워져있던 것과 일맥상통하는 것 같다 (아이파크는 모든 콘센트가 다 전력량을 제공했었는데 ㅠ_ㅠ)

 

중앙제어 회사의 카탈로그를 보니 힐스테이트 광교산에 설치된 각 방 제어 패드는 전력량에 대한 정보를 표시하지 않는 제품군인듯 (아쉽 ㅠㅠ)

콘센트 하나만 전력량을 제공하니 굳이 구현할 필요가 없기도 하고, 나머지 0 값들에 대한 레퍼런스가 부족하기도 해서 코딩에는 적용하지 않았다

 

또한, 디바이스 정보 패킷의 마지막 바이트가 0x01 혹은 0x02였는데, 이는 월패드에서 '대기전력 차단' 기능에 대한 정보였다 (자동 차단 = 0x02, 수동 차단 = 0x01)

F7 1C 01 1F 04 40 10 00 11 01 00 0700 00 00 00 02 12 01 00 00 00 00 00 00 02 A5 EE

 

아울렛별로 16A 용량의 대기전력 자동 차단 설정 기능이 탑재되어 있는데, 굳이 이것까지 스마트폰으로 제어할 일은 없기에 이 또한 따로 구현하지는 않기로 한다 (나는 모든 콘센트의 자동 차단 기능을 해제해놨다.. 관리비가 지나치게 많이 나온다 싶으면 활용해봐야지~)

3. 코드 구현

지난번 조명 구현한 코드에 추가해서 구현했다

전체 코드는 깃헙 저장소의 hillstate-outlet 브랜치로 커밋했다

 

GitHub - YOGYUI/HomeNetwork: HomeNetwork(Homebridge) Repo

HomeNetwork(Homebridge) Repo. Contribute to YOGYUI/HomeNetwork development by creating an account on GitHub.

github.com

핵심은 역시 Outlet 객체

import json
from Device import *


class Outlet(Device):
    def __init__(self, name: str = 'Outlet', index: int = 0, **kwargs):
        self.index = index  # outlet device order index
        super().__init__(name, **kwargs)
    
    def __repr__(self):
        repr_txt = f'<{self.name}({self.__class__.__name__} at {hex(id(self))})'
        repr_txt += f' Room Idx: {self.room_index}, Dev Idx: {self.index}'
        repr_txt += '>'
        return repr_txt
    
    def publish_mqtt(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 makePacketQueryState(self) -> bytearray:
        # F7 0B 01 1F 01 40 XX 00 00 YY EE
        # XX: 상위 4비트 = Room Index, 하위 4비트 = 0
        # YY: Checksum (XOR SUM)
        packet = bytearray([0xF7, 0x0B, 0x01, 0x1F, 0x01, 0x40])
        packet.append((self.room_index << 4))
        packet.extend([0x00, 0x00])
        packet.append(self.calcXORChecksum(packet))
        packet.append(0xEE)
        return packet

    def makePacketSetState(self, state: bool):
        # F7 0B 01 1F 02 40 XX YY 00 ZZ EE
        # XX: 상위 4비트 = Room Index, 하위 4비트 = Device Index (1-based)
        # YY: 02 = OFF, 01 = ON
        # ZZ: Checksum (XOR SUM)
        packet = bytearray([0xF7, 0x0B, 0x01, 0x1F, 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

상태 조회 및 명령 패킷 생성 구문은 조명과 99% 유사해서 1분만에 짜버렸다

4. Homebridge 및 Home Assistant 연동

homebridge 액세서리 추가
Home Assistant 액세서리 추가

MQTT 기반의 액세서리이기 때문에 복사-붙여넣기 작업을 조금만 해주면 액세서리는 금방 추가할 수 있다

5. 테스트

이번에도 인증용(?) 동영상을 찍어봤다

아이폰 홈 앱에서 액세서리 On/Off 누르면 1초 내로 아울렛 전원이 On/Off 된다

 

거실 월패드에서 상태를 변경했을 때도 1초 내로 변경된 상태가 홈 앱에 적용된다

6. 정리

방 곳곳에 개인 서버용 NAS, 홈네트워크용 게이트웨이(필립스 휴, 이케아, 샤오미 등)랑 시그널 리피터, 와이파이 익스텐더, 공기청정기 등이 배치되어 아울렛에 꽂혀있기 때문에 함부로 전원을 내리지는 못하는 상황이라 활용할 일이 그닥 많은 기능은 아니다 (명절에 귀향했을 때 전기비 아끼기 위한 용도 수준? ㅋㅋ)

하지만 손쉽게 구현할 수 있는데도 그냥 넘어가버리는 건 공돌이의 정신 자세가 아니기 때문에 나중을 위해서라도 후딱 구현해봤다

 

구현해야 할 제어 기능 리스트업하고 마무리하도록 하자

  • 조명 On/Off
  • 아울렛(전원 콘센트) On/Off - 실시간 전력량 조회는 불가능
  • 도시가스 차단
  • 난방 제어, 현재 방 온도 가져오기
  • 시스템 에어컨 (냉방 및 공기청정)
  • 환기 (전열교환기)
  • 엘리베이터 호출
  • 도어락 해제
  • Optional: 현관 비디오폰 영상, 거실 천장 모션 센서
반응형