YOGYUI

[PROJ] Matter::OnOff 클러스터 개발 예제 (ESP32) 본문

PROJECT

[PROJ] Matter::OnOff 클러스터 개발 예제 (ESP32)

요겨 2023. 3. 25. 01:16
반응형

Matter - OnOff Cluster Developing Example using ESP32 SoC

가장 간단한 Matter 클러스터(cluster)인 OnOff 부터 시작해보자

OnOff 타겟은 조명(light)으로 결정했는데, 조명은 일전에 개발해둔 색상 및 밝기 변경이 가능한 WS2812 16개가 장착된 자체 개발 모듈을 그대로 사용하기로 한다

https://yogyui.tistory.com/entry/PROJ-Dimmable-WS2812S-RGB-LED-%EB%AA%A8%EB%93%88-%EC%A0%9C%EC%9E%91-2

 

[PROJ] Dimmable WS2812S RGB LED 모듈 제작 - (2)

5. MCU 선정 및 HW 연결 2023년 1월부터 시작한 Matter 프로젝트는 EspressIf사의 ESP32 SoC를 메인 타겟으로 개발해왔기에, 앞으로 Matter 관련 포스팅도 (일단은) ESP32 위주로 작성해보려 한다 프로토타이핑

yogyui.tistory.com

소스코드는 깃허브 저장소에서 확인할 수 있다 (matter-esp32-ws2812 저장소의 cluster-onoff 태그)

https://github.com/YOGYUI/matter-esp32-ws2812/tree/cluster-onoff

 

GitHub - YOGYUI/matter-esp32-ws2812

Contribute to YOGYUI/matter-esp32-ws2812 development by creating an account on GitHub.

github.com

1. 디바이스 클래스 구현

Matter Endpoint의 Cluster, Attribute 초기화 및 내부 상태에 따른 값 변경, Matter 콜백에 의한 상태 변경 등 기능은 모두 부모 클래스 CDevice를 상속하는 구조로 설계 (앞으로 다룰 모든 예제의 Endpoint는 동일 방식으로 구현)

1.1. device.h

class CDevice
{
public:
    CDevice();
    virtual ~CDevice();

protected:
    esp_matter::endpoint_t *m_endpoint;
    uint16_t m_endpoint_id;

public:
    virtual bool matter_add_endpoint();
    virtual bool matter_init_endpoint();
    bool matter_destroy_endpoint();
    esp_matter::endpoint_t* matter_get_endpoint();
    uint16_t matter_get_endpoint_id();
    virtual void matter_on_change_attribute_value(esp_matter::attribute::callback_type_t type, uint32_t cluster_id, uint32_t attribute_id, esp_matter_attr_val_t *value);
    virtual void matter_update_all_attribute_values();
};

1.2. device.cpp

CDevice::CDevice()
{
    m_endpoint = nullptr;
    m_endpoint_id = 0;
}

CDevice::~CDevice() { }

bool CDevice::matter_add_endpoint()
{
    esp_err_t ret;
    
    if (m_endpoint != nullptr) {
        matter_init_endpoint();

        // get endpoint id
        m_endpoint_id = esp_matter::endpoint::get_id(m_endpoint);

        ret = esp_matter::endpoint::enable(m_endpoint);  // should be called after esp_matter::start()
        if (ret != ESP_OK) {
            matter_destroy_endpoint();
            return false;
        }
    } else {
        return false;
    }

    return true;
}

bool CDevice::matter_init_endpoint()
{
    return true;
}

bool CDevice::matter_destroy_endpoint()
{
    esp_err_t ret;

    esp_matter::node_t *root = GetSystem()->get_root_node();
    ret = esp_matter::endpoint::destroy(root, m_endpoint);
    if (ret != ESP_OK) {
        return false;
    }

    return true;
}

esp_matter::endpoint_t* CDevice::matter_get_endpoint() 
{ 
    return m_endpoint; 
}

uint16_t CDevice::matter_get_endpoint_id()
{ 
    return m_endpoint_id; 
}

CDevice를 상속받는 자식 클래스는 matter_add_endpoint 메서드 호출 시 부모 클래스(CDevice)의 matter_add_endpoint 메서드를 반드시 호출해야 하도록 설계 (번거롭긴 하지만, esp_matter - endpoint - enable 함수 호출은 공통 구문이므로 자식 클래스 구현 시 번거로움을 제거하고자 하기 위함)

2. OnOff Light 클래스 구현

2.1. device_onoff_light.cpp

CDeviceOnOffLight::CDeviceOnOffLight()
{
    m_matter_update_by_client_clus_onoff_attr_onoff = false;
    GetWS2812Ctrl()->set_common_color(255, 255, 255);
    m_state_onoff = GetWS2812Ctrl()->get_brightness() ? true : false;
}

bool CDeviceOnOffLight::matter_add_endpoint()
{
    esp_matter::node_t *root = GetSystem()->get_root_node();
    esp_matter::endpoint::on_off_light::config_t config_endpoint;
    config_endpoint.on_off.on_off = false;
    config_endpoint.on_off.lighting.start_up_on_off = nullptr;
    uint8_t flags = esp_matter::ENDPOINT_FLAG_DESTROYABLE;
    m_endpoint = esp_matter::endpoint::on_off_light::create(root, &config_endpoint, flags, nullptr);
    if (!m_endpoint) {
        return false;
    }

    return CDevice::matter_add_endpoint();;
}

bool CDeviceOnOffLight::matter_init_endpoint()
{
    matter_update_all_attribute_values();
    
    return true;
}

void CDeviceOnOffLight::matter_on_change_attribute_value(esp_matter::attribute::callback_type_t type, uint32_t cluster_id, uint32_t attribute_id, esp_matter_attr_val_t *value)
{
    if (cluster_id == chip::app::Clusters::OnOff::Id) {
        if (attribute_id == chip::app::Clusters::OnOff::Attributes::OnOff::Id) {
            if (type == esp_matter::attribute::callback_type_t::PRE_UPDATE) {
                if (!m_matter_update_by_client_clus_onoff_attr_onoff) {
                    if (value->val.b) {
                        GetWS2812Ctrl()->set_brightness(100);
                    } else {
                        GetWS2812Ctrl()->set_brightness(0);
                    }
                } else {
                    m_matter_update_by_client_clus_onoff_attr_onoff = false;
                }
            }
        }
    }
}

void CDeviceOnOffLight::matter_update_all_attribute_values()
{
    matter_update_clus_onoff_attr_onoff();
}

void CDeviceOnOffLight::matter_update_clus_onoff_attr_onoff()
{
    esp_err_t ret;
    uint32_t cluster_id, attribute_id;
    esp_matter_attr_val_t val;

    m_matter_update_by_client_clus_onoff_attr_onoff = true;
    cluster_id = chip::app::Clusters::OnOff::Id;
    attribute_id = chip::app::Clusters::OnOff::Attributes::OnOff::Id;
    val = esp_matter_bool((bool)m_state_onoff);
    ret = esp_matter::attribute::update(m_endpoint_id, cluster_id, attribute_id, &val);
}

CDeviceOnOffLight 클래스는 CDevice를 상속받은 클래스로, WS2812 모듈의 OnOff Attribute 값이 0(false)이면 brightness를 0으로, Attribute가 1(true)이면 brightness를 100으로 설정하게 구현 (WS2812 색상은 White로 고정)

matter_on_change_attribute_value 메서드를 통해 Matter로부터 받은 속성값 변경 명령에 대한 처리를 수행하는데, 일단은 OnOff Cluster의 OnOff Attribute에 대한 처리만 구현 (사실 다른건 구현할 필요가 없다)

 

위 코드에서 가장 핵심 구문은 esp_matter::endpoint::on_off_light::create 함수인데, esp_matter의 소스코드를 보면 (esp_matter_endpoint.cpp)

namespace on_off_light {
endpoint_t *add(endpoint_t *endpoint, config_t *config)
{
    if (!endpoint) {
        ESP_LOGE(TAG, "Endpoint cannot be NULL");
        return NULL;
    }
    add_device_type(endpoint, get_device_type_id(), get_device_type_version());

    descriptor::create(endpoint, CLUSTER_FLAG_SERVER);
    cluster_t *identify_cluster = identify::create(endpoint, &(config->identify), CLUSTER_FLAG_SERVER);
    identify::command::create_trigger_effect(identify_cluster);
    groups::create(endpoint, &(config->groups), CLUSTER_FLAG_SERVER);
    scenes::create(endpoint, &(config->scenes), CLUSTER_FLAG_SERVER);
    on_off::create(endpoint, &(config->on_off), CLUSTER_FLAG_SERVER, on_off::feature::lighting::get_id());

    return endpoint;
}
} /* on_off_light */

descriptor, identify, groups, scenes와 같이 Matter endpoint가 가져야하는 코어 클러스터들과 on/off 클러스터를 자동으로 추가해주는 것을 알 수 있다 (wrapper sdk의 장점)

물론 필요한 클러스터나 어트리뷰트는 임의로 추가할 수도 있다

 

On/Off 클러스터에 대한 스펙은 다음 글을 참고하면 된다

https://yogyui.tistory.com/entry/Matter-OnOff-Cluster

 

Matter Specification - On/Off Cluster

Matter :: On/Off Cluster Attributes and commands for turning devices on and off. 조명, 콘센트(outlet), 팬 등 다양한 종류의 디바이스의 전원 혹은 동작 상태를 켜고 끄는 데 사용되는 클러스터 거의 모든 종류의 엔

yogyui.tistory.com

3. Matter 콜백함수 구현

Matter 초기화는 모두 CSystem 클래스에서 관리하도록 구현 (한 번 구현해두면 나중에 대폭 수정할 일 없도록)

bool CSystem::initialize()
{
    // create matter root node
    esp_matter::node::config_t node_config;
    m_root_node = esp_matter::node::create(&node_config, matter_attribute_update_callback, matter_identification_callback);
    if (!m_root_node) {
        return false;
    }
    
    // start matter
    ret = esp_matter::start(matter_event_callback);
    if (ret != ESP_OK) {
        return false;
    }

    GetWS2812Ctrl()->initialize(GPIO_PIN_WS2812_DATA, WS2812_ARRAY_COUNT);
    CDevice *dev = new CDeviceOnOffLight();
    if (dev && dev->matter_add_endpoint()) {
        m_device_list.push_back(dev);
    } else {
        return false;
    }

    return true;
}

OnOff Light 객체 (CDeviceOnOffLight) 인스턴스 생성 후, CSystem 객체의 멤버 변수 벡터에 인스턴스를 푸시해두고 두고두고 쓸 수 있다

중요한 건 root node 생성 시 attribute update callback 함수를 등록해줘야 한다

esp_err_t CSystem::matter_attribute_update_callback(esp_matter::attribute::callback_type_t type, uint16_t endpoint_id, uint32_t cluster_id, uint32_t attribute_id, esp_matter_attr_val_t *val, void *priv_data)
{
    CDevice *device = GetSystem()->find_device_by_endpoint_id(endpoint_id);
    if (device){
        device->matter_on_change_attribute_value(type, cluster_id, attribute_id, val);
    }
    
    return ESP_OK;
}

콜백함수가 호출될 때, CSystem의 CDevice 인스턴스를 담고있는 멤버변수 벡터에서 endpoint id가 일치하는 개체를 찾은 뒤, 해당 인스턴스의 matter_on_change_attribute_value 메서드를 호출하는 방식으로 공통화해두었으므로, 앞으로 Matter 콜백 처리와 관련해서 CSystem 코드를 건드릴 필요가 없다

(각 CDevice 객체의 메서드를 구현하면 된다)

4. Main 함수

#include "logger.h"
#include "system.h"
#include "esp_system.h"

extern "C" void app_main() {
    if (!GetSystem()->initialize()) {
        GetLogger(eLogType::Error)->Log("Failed to initialize system");
        esp_restart();
    }
}

main.cpp 코드의 app_main 함수에서는 CSystem 인스턴스 생성 및 초기화말고는 구현할 것이 아무것도 없다

앞으로 모든 예제의 메인 함수는 변동이 없을 예정

5. Commisioning & Performance Test

테스트 환경은 Apple 생태계에서 진행했다

※ iOS 16부터 Apple Home은 Matter를 공식적으로 지원한다

  • 홈허브: 홈팟 미니 1세대 (Model: MY5G2J/A, OS 버전: 16.3.2)
  • 아이폰 XR (Model: MRYD2KH/A, iOS 16.3) - 서랍에 박혀있던 구형 아이폰으로 테스트 진행
  • Apple Home 앱에서 테스트용 홈(Home) 추가 후 진행
  • Commisioning 시 아이폰이 2.4GHz 대역 Wi-Fi에 접속되어 있어야 함

5.1. QR Code

ESP32에 Matter Vendor ID 0xFFF2, Product ID 0x8001 기반으로 DAC Provider 바이너리 파일을 NVS에 플래시해서 사용

esp-matter SDK의 mfg_tool로 생성했는데, 이에 대해서는 따로 포스팅하도록 한다

5.2. Apple Home에서 기기 추가

애플 홈 - 추가(+) - 액세서리 추가 - QR코드 스캔

잠시 기다리면 커미셔닝 완료 후 액세서리가 추가된다

(애플 홈의 Matter는 아직 불안정한 부분이 있어서 한번에 성공하지 못하는 경우도 있는데, 2~3번 ESP32 Factory Reset 후 반복하다보면 어느 순간 정상적으로 완료된다)

5.3. 기기 제어 데모


[전등 ON 명령 시 콘솔 로그]

I (1301005) esp_matter_command: Received command 0x00000001 for endpoint 0x0001's cluster 0x00000006
I (1301015) chip[ZCL]: On/Off set value: 1 1
I (1301015) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x0000 is 0 **********
I (1301035) chip[ZCL]: Toggle on/off from 0 to 1
I (1301045) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0xFFFC is 1 **********
I (1301045) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4001 is 0 **********
I (1301055) chip[ZCL]: On Command - OffWaitTime :  0
I (1301065) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4002 is 0 **********
I (1301085) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x4002 [system.cpp:417]
I (1301095) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x4002 [system.cpp:417]
I (1301105) chip[ZCL]: On/Toggle Command - Stop Timer
I (1301115) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4000 is 1 **********
I (1301125) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x4000 [system.cpp:417]
I (1301135) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x4000 [system.cpp:417]
I (1301165) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x0000 is 1 **********
I (1301165) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x0000 [system.cpp:417]
I (1301185) logger: [CDeviceOnOffLight::matter_on_change_attribute_value] MATTER::PRE_UPDATE >> cluster: OnOff(0x0006), attribute: OnOff(0x0000), value: 1 [device_onoff_light.cpp:41]
I (1301215) logger: [CMemory::save_ws2812_brightness] save <ws2812 brightness> to memory: 100 [memory.cpp:103]
I (1301225) logger: [CWS2812Ctrl::set_pwm_duty] set pwm duty: 400 [ws2812.cpp:111]
I (1301245) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x0000 [system.cpp:417]
I (1301265) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0005's Attribute 0x0003 is 0 **********
I (1301275) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0005, attribute_id: 0x0003 [system.cpp:417]
I (1301295) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0005, attribute_id: 0x0003 [system.cpp:417]
I (1301305) chip[EM]: <<< [E:30869r M:216490933 (Ack:80594563)] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0001:09 (IM:InvokeCommandResponse)
I (1301325) chip[IN]: (S) Sending msg 216490933 on secure session with LSID: 30652
E (1301325) chip[DL]: Long dispatch time: 328 ms, for event type 3
I (1301335) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0005's Attribute 0x0003 is 0 **********
I (1301345) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x0000 is 1 **********
I (1301355) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4000 is 1 **********
I (1301365) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4002 is 0 **********
I (1301385) chip[EM]: <<< [E:43411i M:216490934] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0001:05 (IM:ReportData)
I (1301385) chip[IN]: (S) Sending msg 216490934 on secure session with LSID: 30652
I (1301395) chip[DMG]: Refresh Subscribe Sync Timer with min 0 seconds and max 4 seconds
I (1301405) chip[EM]: >>> [E:30869r M:80594563] (S) Msg RX from 1:73D697EBCDD128E2 [1927] --- Type 0001:08 (IM:InvokeCommandRequest)
I (1301415) chip[EM]: <<< [E:30869r M:216490935 (Ack:80594563)] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0000:10 (SecureChannel:StandaloneAck)
I (1301435) chip[IN]: (S) Sending msg 216490935 on secure session with LSID: 30652
I (1301445) chip[EM]: >>> [E:30869r M:80594564 (Ack:216490933)] (S) Msg RX from 1:73D697EBCDD128E2 [1927] --- Type 0000:10 (SecureChannel:StandaloneAck)
I (1301465) chip[EM]: >>> [E:43411i M:80594565 (Ack:216490934)] (S) Msg RX from 1:73D697EBCDD128E2 [1927] --- Type 0001:01 (IM:StatusResponse)
I (1301465) chip[IM]: Received status response, status is 0x00
I (1301475) chip[EM]: <<< [E:43411i M:216490936 (Ack:80594565)] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0000:10 (SecureChannel:StandaloneAck)
I (1301495) chip[IN]: (S) Sending msg 216490936 on secure session with LSID: 30652

 

[전등 OFF 명령 시 콘솔 로그]

I (1585675) chip[EM]: >>> [E:30870r M:80594649] (S) Msg RX from 1:73D697EBCDD128E2 [1927] --- Type 0001:08 (IM:InvokeCommandRequest)
I (1585685) esp_matter_command: Received command 0x00000000 for endpoint 0x0001's cluster 0x00000006
I (1585695) chip[ZCL]: On/Off set value: 1 0
I (1585695) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x0000 is 1 **********
I (1585715) chip[ZCL]: Toggle on/off from 1 to 0
I (1585725) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0xFFFC is 1 **********
I (1585725) chip[ZCL]: Off Command - OnTime :  0
I (1585735) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4001 is 0 **********
I (1585745) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x4001 [system.cpp:417]
I (1585765) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x4001 [system.cpp:417]
I (1585775) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x0000 is 0 **********
I (1585795) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x0000 [system.cpp:417]
I (1585805) logger: [CDeviceOnOffLight::matter_on_change_attribute_value] MATTER::PRE_UPDATE >> cluster: OnOff(0x0006), attribute: OnOff(0x0000), value: 0 [device_onoff_light.cpp:41]
I (1585825) logger: [CMemory::save_ws2812_brightness] save <ws2812 brightness> to memory: 0 [memory.cpp:103]
I (1585835) logger: [CWS2812Ctrl::set_pwm_duty] set pwm duty: 0 [ws2812.cpp:111]
I (1585855) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0006, attribute_id: 0x0000 [system.cpp:417]
I (1585875) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0005's Attribute 0x0003 is 0 **********
I (1585885) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 0, endpoint_id: 1, cluster_id: 0x0005, attribute_id: 0x0003 [system.cpp:417]
I (1585895) logger: [CSystem::matter_attribute_update_callback] attribute update callback > type: 1, endpoint_id: 1, cluster_id: 0x0005, attribute_id: 0x0003 [system.cpp:417]
I (1585915) chip[EM]: <<< [E:30870r M:216491099 (Ack:80594649)] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0001:09 (IM:InvokeCommandResponse)
I (1585925) chip[IN]: (S) Sending msg 216491099 on secure session with LSID: 30652
E (1585935) chip[DL]: Long dispatch time: 257 ms, for event type 3
I (1585935) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0005's Attribute 0x0003 is 0 **********
I (1585955) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x0000 is 0 **********
I (1585965) esp_matter_attribute: ********** Endpoint 0x0001's Cluster 0x0006's Attribute 0x4001 is 0 **********
I (1585975) chip[EM]: <<< [E:43492i M:216491100] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0001:05 (IM:ReportData)
I (1585985) chip[IN]: (S) Sending msg 216491100 on secure session with LSID: 30652
I (1585995) chip[DMG]: Refresh Subscribe Sync Timer with min 0 seconds and max 4 seconds
I (1586005) chip[EM]: >>> [E:30870r M:80594650 (Ack:216491099)] (S) Msg RX from 1:73D697EBCDD128E2 [1927] --- Type 0000:10 (SecureChannel:StandaloneAck)
I (1586085) chip[EM]: >>> [E:43492i M:80594651 (Ack:216491100)] (S) Msg RX from 1:73D697EBCDD128E2 [1927] --- Type 0001:01 (IM:StatusResponse)
I (1586095) chip[IM]: Received status response, status is 0x00
I (1586105) chip[EM]: <<< [E:43492i M:216491101 (Ack:80594651)] (S) Msg TX to 1:73D697EBCDD128E2 [1927] --- Type 0000:10 (SecureChannel:StandaloneAck)
I (1586115) chip[IN]: (S) Sending msg 216491101 on secure session with LSID: 30652


애플 홈에서 제어되는 기능이 On/Off 클러스터의 On/Off 어트리뷰트가 전부이기 때문에 무난하게 잘 동작하는 걸 확인할 수 있다 (네트워크 레이턴시때문에 반응 속도가 시원찮은 경우가 간혹 발생한다)

이제 밝기 제어를 위한 LevelControl 클러스터, 색상 제어를 위한 ColorControl 클러스터 구현 예제까지 쭉쭉 달려나가보자!

반응형
Comments