YOGYUI

현대통신 월패드 RS-485 디바이스 자동 탐지 및 HA MQTT Discovery 지원 기능 추가 본문

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

현대통신 월패드 RS-485 디바이스 자동 탐지 및 HA MQTT Discovery 지원 기능 추가

요겨 2023. 6. 24. 17:43
반응형

Hyundai HT Wallpad - Automatic discover RS-485 devices and support Home Assistant MQTT discovery

지난주, 현대통신 월패드의 RS-485 연동 소스코드의 패킷 파서 클래스를 일원화하는 작업을 진행했다

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

 

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

Hyundai Wallpad RS-485 Python Source Code Enhancement 지난주 목요일 (6월 15일) 힐스테이트 소스코드 관련 지원 요청 이메일을 받았다 소스코드가 워낙에 조악하게 기능 구현에만 충실하게 짜놨다보니 디버

yogyui.tistory.com

지원 요청하신 유저분께서 감사하게도 소정의 보답을 해주신다고 하셔서 염치불구하고 치킨 쿠폰을 부탁드려 선물받은 뒤 배부르게 먹었다

(교촌치킨 오랜만에 먹었는데 맛있드라...)

이미 사용한 쿠폰임 ㅎㅎ
역시 치킨은 진리

 

그런데, 블로그 댓글에 아래와 같이 추가 요구사항이 접수(?)되었다

https://yogyui.tistory.com/entry/%ED%98%84%EB%8C%80%ED%86%B5%EC%8B%A0-%EC%9B%94%ED%8C%A8%EB%93%9C-RS-485-%EC%97%B0%EB%8F%99-%EC%86%8C%EC%8A%A4%EC%BD%94%EB%93%9C-%EB%A6%AC%ED%8C%A9%ED%84%B0%EB%A7%81

 

정리하자면 Home Assistant의 MQTT 플러그인은 디바이스(액세서리)를 설정 파일(yaml)에 사용자가 수동으로 입력하지 않고 MQTT 메시지를 통해 자동으로 등록하고 사용할 수 있다는 것! (이런 편리한 기능이 있었다니...)

 

이왕 홈네트워크 RS-485 디바이스 자동 탐지 기능을 개발 중이었으니, 개발하는 김에 HA용 MQTT Discovery 기능까지 한꺼번에 구현 완료했다! ㅋㅋ

1. RS-485 디바이스 자동 탐지 기능 개발

1.1. 컨셉

현대통신 월패드는 디바이스(조명, 콘센트, 난방, 에어컨 등)들과 평소에 수시로 RS-485 패킷을 주고받고 있으며, 패킷 내부에 디바이스 타입, 디바이스 인덱스, 디바이스가 포함된 방 번호 등 필요한 정보들이 다 포함되어 있기 때문에 평소에 RS-485 통신선에 흘러다니는 패킷을 취합하면 어떤 디바이스들이 홈네트워크 상에 존재하는지 파악할 수 있다

1.2. 설계 

'device discovery'라는 플래그를 설정하고, 만약 이 플래그가 활성화되어 있으면 'discovered device' 리스트에 발견된 디바이스들을 담아둔 뒤, discover가 완료되면 해당 디바이스 정보들을 config.xml에 기입하여 어플리케이션 재부팅 후 정상적으로 제어할 수 있도록 한다

※ 엄밀히 말하면 discover timeout check는 별도의 thread로 구현했기 때문에 위 순서도는 컨셉만 잡기 위해 설계한 결과물

1.3. 코드 구현

Home 클래스 내부에 discover_device 멤버변수를 둔 뒤, xml 파일에서 부팅 시 값을 바꿀 수 있게 해줬다

(discover_timeout은 디바이스 감지 시간, device_reload는 감지 후 앱 자동 재부팅 여부, discovered_dev_list는 감지된 객체 정보들을 담을 리스트)

class Home:
    discover_device: bool = False
    discover_timeout: int = 60
    discover_reload: bool = False
    discovered_dev_list: List[dict]
    
    def loadConfig(self):
        xml_path = os.path.join(PROJPATH, 'config.xml')
        if not os.path.isfile(xml_path):
            self.config_tree = None
            return
        self.config_tree = ET.parse(xml_path)
        root = self.config_tree.getroot()
        
        # 중략
        discovery_node = node.find('discovery')
        enable_discovery = False
        if discovery_node is not None:
            enable_node = discovery_node.find('enable')
            if enable_node is not None:
                enable_discovery = bool(int(enable_node.text))
            timeout_node = discovery_node.find('timeout')
            if timeout_node is not None:
                self.discover_timeout = int(timeout_node.text)
            reload_node = discovery_node.find('reload')
            if reload_node is not None:
                self.discover_reload = bool(int(reload_node.text))
        if enable_discovery:
            self.startDiscoverDevice()
    
    def startDiscoverDevice(self):
        self.discover_device = True
        if self.thread_discovery is None:
            self.thread_discovery = ThreadDiscovery(self.discover_timeout)
            self.thread_discovery.sig_terminated.connect(self.onThreadDiscoveryTerminated)
            self.thread_discovery.setDaemon(True)
            self.thread_discovery.start()

그리고 discover_device 멤버변수가 활성화된 상태에서는 패킷 파싱 결과를 discovered_dev_list 멤버변수 리스트에 담아둔 뒤 discover가 끝나면 config.xml 파일에 해당 정보를 자동으로 기입할 수 있게 만들었다

(xml 파일 작성 코드는 생략... 깃허브 소스코드 참고)

class Home:
    def handlePacketParseResult(self, result: dict):
        if self.discover_device:
            self.updateDiscoverDeviceList(result)
        else:
            self.updateDeviceState(result)
    
    def updateDiscoverDeviceList(self, result: dict):
        dev_type: DeviceType = result.get('device')
        dev_idx: int = result.get('index')
        if dev_idx is None:
            dev_idx = 0
        room_index: int = result.get('room_index')
        if room_index is None:
            room_index = 0
        parser_index: int = result.get('parser_index')
        if parser_index is None:
            parser_index = 0

        if self.findDevice(dev_type, dev_idx, room_index):
            return
        if self.isDeviceDiscovered(dev_type, dev_idx, room_index):
            return
        if dev_type is DeviceType.UNKNOWN:
            return

        self.discovered_dev_list.append({
            'type': dev_type,
            'index': dev_idx,
            'room_index': room_index,
            'parser_index': parser_index
        })
        writeLog(f"discovered {dev_type.name} (index: {dev_idx}, room: {room_index}, parser index: {parser_index})", self)

    def onThreadDiscoveryTerminated(self):
        del self.thread_discovery
        self.thread_discovery = None

        self.saveDiscoverdDevicesToConfigFile()
        if self.discover_reload:
            self.restart()
        else:
            self.discover_device = False

1.4. 테스트 (사용법)

어플리케이션 실행 전 config.xml 파일에서 다음 항목들을 설정해줘야 한다

(rs485 컨버터 관련 설정은 이미 다 되어있다고 가정)

<config>
    <parser_mapping>
        <light>0</light>
        <outlet>0</outlet>
        <gasvalve>0</gasvalve>
        <thermostat>0</thermostat>
        <ventilator>0</ventilator>
        <airconditioner>0</airconditioner>
        <elevator>0</elevator>
        <subphone>0</subphone>
        <batchoffsw>0</batchoffsw>
        <hems>0</hems>
    </parser_mapping>
    <device>
        <discovery>
            <enable>1</enable>
            <timeout>60</timeout>
            <reload>1</reload>
        </discovery>
        <entry>
        </entry>
    </device>
</config>
  • enable - RS485 디바이스 자동 감지 여부 (1 = enable, 0 = disable)
  • timeout - 단위=초
  • reload - discover timeout 후 어플리케이션 자동 재시작 여부 (1 = enable, 0 = disable), enable 권장

<entry> 태그 내에는 어플리케이션에서 사용할 디바이스들의 설정이 들어가야 하는데, 자동 감지를 사용할 것이므로 비워두면 된다 

 

어플리케이션을 실행하면 다음과 같이 발견된 디바이스들이 로그에 기입된다

타임아웃 10초가 남은 순간부터 매초 남은 시간이 로깅되며, reload 옵션이 활성화되어 있으면 Home 객체가 자동으로 재시작된다

디바이스 감지가 완료된 시점에서 다음과 같이 config.xml 파일에 발견된 디바이스들이 자동으로 등록되어 있는 것을 확인할 수 있다

discovery 완료 후 어플리케이션 재시작 시 discovery 기능은 자동으로 OFF되며, <entry> 노드에 자동으로 등록된 디바이스들을 제어할 수 있게 된다 (parser index mapping도 자동으로 설정된다)

디바이스의 이름(name)은 다음 템플릿을 기반으로 자동으로 설정된다

- "ROOM{room_index} {device_type}{device_index}"

- 만약에 room_index가 0이라면 "{device_type}{device_index}"

- 만약 device_index도 0이라면 "{device_type}"

2. Home Assistant MQTT Discovery 기능 지원 개발

2.1. HA MQTT Discovery

https://www.home-assistant.io/integrations/mqtt/

홈어시스턴트 MQTT 플러그인의 MQTT Discovery 기능을 사용하는 방법은 생각보다 간단하다

토픽(topic)이 

"<discovery_prefix>/<component>/<object_id>/config"

인 메시지를 발행하기만 하면 된다

- discovery_prefix는 HA의 MQTT 설정에서 변경할 수 있으며, 기본값은 'homeassistant'다

- component는 'light', 'climate', 'switch', 'sensor' 등 기존에 사용하고 있던 mqtt 컴포넌트명을 넣으면 된다

- object_id는 디바이스별로 고유하게 부여된 문자열을 사용하면 된다 (HA에서 디바이스간 구분을 위해 필요)

그리고 topic 마지막에 'config'를 붙여서 메시지를 발행하면 HA에서 자동으로 MQTT 액세서리를 등록/수정/삭제해주는 아주 편리한 기능!

 

발행시 사용할 페이로드(payload)는 기존에 yaml 파일 설정 시 활용한 구성을 json 형태로 변환해서 전달하면 된다


예를 들어보자기존에 나는 거실 천장을 다음과 같이 구성해뒀었다

light
  - platform: mqtt
    name: "거실 천장 1"
    unique_id: "livingroomlight1"
    schema: template
    state_topic: "home/state/light/1/0"
    command_topic: "home/command/light/1/0"
    state_template: "{% if value_json.state %} on {% else %} off {% endif %}"
    command_on_template: '{"state": 1}'
    command_off_template: '{"state": 0}'

이걸 HA에서 자동으로 등록하게 해주려면

Topic: homeassistant/light/livingroomlight1/config
Payload: {
    "name": "거실 천장 1",
    "object_id": "livingroomlight1",
    "unique_id": "livingroomlight1",
    "state_topic": "home/state/light/1/0",
    "command_topic": "home/command/light/1/0",
    "state_template": "{% if value_json.state %} on {% else %} off {% endif %}",
    "command_on_template": '{"state": 1}',
    "command_off_template": '{"state": 0 }'
}

이런 느낌으로 발행해주면 된다

즉, MQTT Discovery 도입 전에 충분히 액세서리 설정에 대한 구현 및 검증을 마쳤다면, 이 후에는 굳이 yaml 파일에 설정을 두지 않고 HA에서 알아서 내가 전달하는 대로 액세서리를 구현해주게 하는게 핵심!

본인의 코드를 다른 사람이 편하게 사용하게 하는 '상품화' 단계에서는 반드시 도입을 고려해야하는 옵션이라 할 수 있겠다

뒤늦게라도 이런 옵션이 있는 걸 알게되서 다행! ㅋㅋ

2.2. 코드 구현

각 디바이스들의 부모 클래스인 Devicemqtt_config_topic 멤버변수를 추가한 뒤, 어플리케이션 초기화 시 HA MQTT Discovery용 메시지를 발행할 수 있게 configMQTT 메서드를 추가했다

class Device:
    mqtt_config_topic: str = ''
    ha_discovery_prefix: str = 'homeassistant'
    
    @abstractmethod
    def publishMQTT(self):
        pass
    
    @abstractmethod
    def setHomeAssistantConfigTopic(self):
        pass

Device 클래스를 상속한 Light 클래스에 HA MQTT Discovery 관련 기능을 어떻게 구현했는지 예를 살펴보자

class Light(Device):
    def __init__(self, name: str = 'Light', index: int = 0, room_index: int = 0):
        super().__init__(name, index, room_index)
        self.mqtt_subscribe_topic = f'home/command/light/{self.room_index}/{self.index}'
        self.setHomeAssistantConfigTopic()
    
    def setHomeAssistantConfigTopic(self):
        self.mqtt_config_topic = f'{self.ha_discovery_prefix}/light/{self.unique_id}/config'

    def configMQTT(self):
        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 }'
        }
        if self.mqtt_client is not None:
            self.mqtt_client.publish(self.mqtt_config_topic, json.dumps(obj), 1, True)

Home에서는 초기화(initialize) 시 initDevices 호출 시 각 device별로 configMQTT 메서드를 호출하게만 하면 끝~

class Home:
    def initialize(self, init_service: bool, connect_rs485: bool):
        self.initMQTT()
        self.loadConfig()
        self.initDevices()
        
    def initDevices(self):
        for dev in self.device_list:
            dev.setMqttClient(self.mqtt_client)
            dev.sig_set_state.connect(partial(self.onDeviceSetState, dev))
            if self.ha_mqtt_discover_enable:
                dev.setHomeAssistantDiscoveryPrefix(self.ha_mqtt_discover_prefix)
                dev.configMQTT()

2.3. 테스트 (사용법)

config.xml 파일의 다음 부분을 설정해주면 된다

<mqtt> - <homeassistant> - <discovery>

<config>
    <mqtt>
        <homeassistant>
            <discovery>
                <enable>1</enable>
                <prefix>homeassistant</prefix>
            </discovery>
        </homeassistant>
    </mqtt>
</config>
  • enable: 1 = HA MQTT Discovery를 위한 메시지 발행, 0 = disable
  • prefix: HA MQTT의 discovery를 위한 prefix. 본인의 HA 설정에 따라 변경하면 됨 (default=homeassistant)

홈어시스턴트 웹페이지의 '통합 구성요소'에서 Mosquitto MQTT를 보면 어플리케이션에서 /config 토픽 메시지를 통해 등록된 디바이스들이 구성되어 있는 것을 확인할 수 있다

각 요소의 구성요소 ID (unique_id)는 다음과 같은 포맷을 따른다

"{component}.{device_type}_{room_index}{device_index}"

예를 들면

  • 방 1번의 첫번째 조명: light.light_1_0
  • 방 2번의 두번째 콘센트: switch.outlet_2_1
  • 방 3번의 난방: climate.thermostat_3_0
  • 방 4번의 에어컨: climate.airconditioner_4_0
  • 전열교환기: fan.ventilator_0_0

unique_id들을 통해 본인의 대시보드를 입맛대로 꾸며주면 된다

※ config.xml의 각 디바이스별 <name> 태그에 지정한 이름이 HA의 display name으로 등록되니, xml 파일을 입맛대로 수정하는 것도 하나의 방법

이렇게나 편리한 기능이 있다는 걸 댓글로 알려주신 Jakwor 님께 감사의 인사를 올립니다!

※ 흠... Homebridge MQTT 플러그인은 이런 기능 없을래나... 없으면 내가 깃허브 contribute 한번 해볼까나?!

3. Github Commit

main 브랜치 최신 커밋(id: 69ad1ca1a7f597b4f4a6130ef43a33d9390b867b)에 README 마크다운 파일까지 수정하고 몇몇 버그 수정 후 커밋 완료!

https://github.com/YOGYUI/HomeNetwork/tree/main/Hillstate-Gwanggyosan

 

GitHub - YOGYUI/HomeNetwork: HomeNetwork(Homebridge) Repo

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

github.com

이제 내 코드를 사용하고자 한다면 저장소를 클론한 뒤, 본인 환경에 맞게 RS-485 컨버터 구성만 config.xml에 제대로 넣어주면 디바이스 탐색 및 HA 액세서리 등록까지 한큐에 완료된다!

※ 아직 내가 발견하지 못한 레인지 후드, 쿡탑 등은 당연하게도 감지 및 등록되지 않는다 ㅠ

수정된 코드 사용 중 발생하는 버그 및 애로사항은 블로그 댓글 혹은 lee2002w@gmail.com 으로 메일 부탁드립니다~

 

반응형