YOGYUI

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

PROJECT

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

요겨 2023. 6. 13. 01:03
반응형

Matter - ColorControl Cluster Developing Example using ESP32 SoC

Matter의 조명과 관련된 클러스터 중 가장 내용이 방대한 ColorControl 클러스터(cluster)를 지난번 LevelControl 클러스터 개발 예제때와 마찬가지로 WS2812 테스트보드에 구현 후 테스트해보자

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

 

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

Matter - LevelControl Cluster Developing Example using ESP32 SoC 다음으로, 밝기 조절이 가능한 조명을 제어할 수 있는 LevelControl 클러스터(cluster)를 구현해보자 마찬가지로, 일전에 개발한 색상 및 밝기 변경이

yogyui.tistory.com

Color Control 클러스터의 Matter Spec은 아래 링크에서 참고

Matter Specification - Color Control Cluster

 

Matter Specification - Color Control Cluster

Mater :: Color Control Cluster This cluster provides an interface for changing the color of a light. Color is specified according to the Commission Internationale de l’Éclairage (CIE) specification CIE 1931 Color Space. Color control is carried out in t

yogyui.tistory.com

1. 소스코드 커밋

  • 저장소명: matter-esp32-ws2812
  • commit id: 56df82be9a5cebb9412d925e405f0e9ad02b13dc
    ※ 별도로 태그를 달지 않고 main 브랜치에 통합
  • 2023년 5월 18일에 Matter 1.1이 공식 release되었는데, 이에 맞춰서 SDK 버전도 함께 업데이트

https://github.com/YOGYUI/matter-esp32-ws2812

 

GitHub - YOGYUI/matter-esp32-ws2812

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

github.com

2. ColorControl Light 클래스 구현

밝기에 해당하는 'level', 색상에 해당하는 'hue(색상)', 'saturation(채도)'의 속성값 변경 명령에 대한 응답 코드를 작성해야 한다

2.1. device_colorcontrol_light.h

class CDeviceColorControlLight : public CDevice
{
public:
    CDeviceColorControlLight();

    bool matter_add_endpoint() override;
    bool matter_init_endpoint() override;
    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
    ) override;
    void matter_update_all_attribute_values() override;

public:
    void toggle_state_action() override;

private:
    bool m_matter_update_by_client_clus_onoff_attr_onoff;
    bool m_matter_update_by_client_clus_levelcontrol_attr_currentlevel;
    bool m_matter_update_by_client_clus_colorcontrol_attr_currenthue;
    bool m_matter_update_by_client_clus_colorcontrol_attr_currentsaturation;

    void matter_update_clus_onoff_attr_onoff();
    void matter_update_clus_levelcontrol_attr_currentlevel();
    void matter_update_clus_colorcontrol_attr_currenthue();
    void matter_update_clus_colorcontrol_attr_currentsaturation();
};

2.2. device_colorcontrol_light.cpp

#include "device_colorcontrol_light.h"
#include "system.h"
#include "logger.h"
#include "ws2812.h"
#include <esp_matter_endpoint.h>
#include <esp_matter_attribute_utils.h>

CDeviceColorControlLight::CDeviceColorControlLight()
{
    m_matter_update_by_client_clus_onoff_attr_onoff = false;
    m_matter_update_by_client_clus_levelcontrol_attr_currentlevel = false;
    m_matter_update_by_client_clus_colorcontrol_attr_currenthue = false;
    m_matter_update_by_client_clus_colorcontrol_attr_currentsaturation = false;
    m_state_brightness = MAX(1, GetWS2812Ctrl()->get_brightness());
    m_state_onoff = m_state_brightness ? true : false;
}

bool CDeviceColorControlLight::matter_add_endpoint()
{
    esp_matter::node_t *root = GetSystem()->get_root_node();

    esp_matter::endpoint::extended_color_light::config_t config_endpoint;
    config_endpoint.on_off.on_off = false;
    config_endpoint.on_off.lighting.start_up_on_off = nullptr;
    config_endpoint.level_control.current_level = m_state_brightness;
    config_endpoint.level_control.lighting.min_level = 1;
    config_endpoint.level_control.lighting.max_level = 254;
    config_endpoint.level_control.lighting.start_up_current_level = m_state_brightness;

    uint8_t flags = esp_matter::ENDPOINT_FLAG_DESTROYABLE;
    m_endpoint = esp_matter::endpoint::extended_color_light::create(root, &config_endpoint, flags, nullptr);
    if (!m_endpoint) {
        GetLogger(eLogType::Error)->Log("Failed to create endpoint");
        return false;
    }

    return CDevice::matter_add_endpoint();
}

bool CDeviceColorControlLight::matter_init_endpoint()
{
    esp_err_t ret;

    esp_matter::cluster::color_control::feature::hue_saturation::config_t cfg;
    cfg.current_hue = 0;
    cfg.current_saturation = 0;
    esp_matter::cluster_t *cluster = esp_matter::cluster::get(m_endpoint, chip::app::Clusters::ColorControl::Id);
    ret = esp_matter::cluster::color_control::feature::hue_saturation::add(cluster, &cfg);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Warning)->Log("Failed to add hue_saturation feature (ret: %d)", ret);
    }

    /** 
    * feature map & color capabilities 속성을 바꿔준다 (HS만 활성화)
    * 3.2.5. Features
    * | Bit | Code |     Feature       |
    * |  0  | HS   | Hue/Saturation    |
    * |  1  | EHUE | Enhanced Hue      |
    * |  2  | CL   | Color Loop        |
    * |  3  | XY   | XY                |
    * |  4  | CT   | Color Temperature |
    */
    esp_matter::attribute_t *attribute = esp_matter::attribute::get(cluster, chip::app::Clusters::Globals::Attributes::FeatureMap::Id);
    esp_matter_attr_val_t val = esp_matter_invalid(NULL);
    esp_matter::attribute::get_val(attribute, &val);
    val.val.u32 = 0x13;
    ret = esp_matter::attribute::set_val(attribute, &val);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Warning)->Log("Failed to change feature map value (ret: %d)", ret);
    }
    attribute = esp_matter::attribute::get(cluster, chip::app::Clusters::ColorControl::Attributes::ColorCapabilities::Id);
    esp_matter::attribute::get_val(attribute, &val);
    val.val.u16 = 0x13;
    ret = esp_matter::attribute::set_val(attribute, &val);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Warning)->Log("Failed to change color capabilities value (ret: %d)", ret);
    }

    return true;
}

void CDeviceColorControlLight::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 (type == esp_matter::attribute::callback_type_t::PRE_UPDATE) {
        if (cluster_id == chip::app::Clusters::OnOff::Id) {
            if (attribute_id == chip::app::Clusters::OnOff::Attributes::OnOff::Id) {
                GetLogger(eLogType::Info)->Log("MATTER::PRE_UPDATE >> cluster: OnOff(0x%04X), attribute: OnOff(0x%04X), value: %d", cluster_id, attribute_id, value->val.b);
                if (!m_matter_update_by_client_clus_onoff_attr_onoff) {
                    m_state_onoff = value->val.b;
                    if (m_state_onoff) {
                        GetWS2812Ctrl()->set_brightness(m_state_brightness);
                    } else {
                        GetWS2812Ctrl()->set_brightness(0);
                    }
                } else {
                    m_matter_update_by_client_clus_onoff_attr_onoff = false;
                }
            }
        } else if (cluster_id == chip::app::Clusters::LevelControl::Id) {
            if (attribute_id == chip::app::Clusters::LevelControl::Attributes::CurrentLevel::Id) {
                GetLogger(eLogType::Info)->Log("MATTER::PRE_UPDATE >> cluster: LevelControl(0x%04X), attribute: CurrentLevel(0x%04X), value: %d", cluster_id, attribute_id, value->val.u8);
                if (!m_matter_update_by_client_clus_levelcontrol_attr_currentlevel) {
                    m_state_brightness = value->val.u8;
                    GetWS2812Ctrl()->set_brightness(value->val.u8);
                } else {
                    m_matter_update_by_client_clus_levelcontrol_attr_currentlevel = false;
                }
            }
        } else if (cluster_id == chip::app::Clusters::ColorControl::Id) {
            if (attribute_id == chip::app::Clusters::ColorControl::Attributes::CurrentHue::Id) {
                GetLogger(eLogType::Info)->Log("MATTER::PRE_UPDATE >> cluster: ColorControl(0x%04X), attribute: CurrentHue(0x%04X), value: %d", cluster_id, attribute_id, value->val.u8);
                if (!m_matter_update_by_client_clus_colorcontrol_attr_currenthue) {
                    m_state_hue = value->val.u8;
                    int temp = REMAP_TO_RANGE(value->val.u8, 254, 360);
                    GetWS2812Ctrl()->set_hue(temp);
                } else {
                    m_matter_update_by_client_clus_colorcontrol_attr_currenthue = false;
                }
            } else if (attribute_id == chip::app::Clusters::ColorControl::Attributes::CurrentSaturation::Id) {
                GetLogger(eLogType::Info)->Log("MATTER::PRE_UPDATE >> cluster: ColorControl(0x%04X), attribute: CurrentSaturation(0x%04X), value: %d", cluster_id, attribute_id, value->val.u8);
                if (!m_matter_update_by_client_clus_colorcontrol_attr_currentsaturation) {
                    m_state_saturation = value->val.u8;
                    int temp = REMAP_TO_RANGE(value->val.u8, 254, 100);
                    GetWS2812Ctrl()->set_saturation(temp);
                } else {
                    m_matter_update_by_client_clus_colorcontrol_attr_currentsaturation = false;
                }
            }
            /* 
            else if (attribute_id == chip::app::Clusters::ColorControl::Attributes::ColorTemperatureMireds::Id) {
                GetLogger(eLogType::Info)->Log("MATTER::PRE_UPDATE >> cluster: ColorControl(0x%04X), attribute: ColorTemperatureMireds(0x%04X), value: %d", cluster_id, attribute_id, value->val.u8);
                uint32_t temp = REMAP_TO_RANGE_INVERSE(value->val.u16, 1000000);
                GetWS2812Ctrl()->set_temperature(temp);
            }
            */
        }
    }
}

void CDeviceColorControlLight::matter_update_all_attribute_values()
{
    matter_update_clus_onoff_attr_onoff();
    matter_update_clus_levelcontrol_attr_currentlevel();
    matter_update_clus_colorcontrol_attr_currenthue();
    matter_update_clus_colorcontrol_attr_currentsaturation();
}

void CDeviceColorControlLight::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);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to update attribute (%d)", ret);
    }
}

void CDeviceColorControlLight::matter_update_clus_levelcontrol_attr_currentlevel()
{
    esp_err_t ret;
    uint32_t cluster_id, attribute_id;
    esp_matter_attr_val_t val;

    m_matter_update_by_client_clus_levelcontrol_attr_currentlevel = true;
    cluster_id = chip::app::Clusters::LevelControl::Id;
    attribute_id = chip::app::Clusters::LevelControl::Attributes::CurrentLevel::Id;
    val = esp_matter_uint8(m_state_brightness);
    ret = esp_matter::attribute::update(m_endpoint_id, cluster_id, attribute_id, &val);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to update attribute (%d)", ret);
    }
}

void CDeviceColorControlLight::matter_update_clus_colorcontrol_attr_currenthue()
{
    esp_err_t ret;
    uint32_t cluster_id, attribute_id;
    esp_matter_attr_val_t val;

    m_matter_update_by_client_clus_colorcontrol_attr_currenthue = true;
    cluster_id = chip::app::Clusters::ColorControl::Id;
    attribute_id = chip::app::Clusters::ColorControl::Attributes::CurrentHue::Id;
    val = esp_matter_uint8(m_state_hue);
    ret = esp_matter::attribute::update(m_endpoint_id, cluster_id, attribute_id, &val);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to update attribute (%d)", ret);
    }
}

void CDeviceColorControlLight::matter_update_clus_colorcontrol_attr_currentsaturation()
{
    esp_err_t ret;
    uint32_t cluster_id, attribute_id;
    esp_matter_attr_val_t val;

    m_matter_update_by_client_clus_colorcontrol_attr_currentsaturation = true;
    cluster_id = chip::app::Clusters::ColorControl::Id;
    attribute_id = chip::app::Clusters::ColorControl::Attributes::CurrentSaturation::Id;
    val = esp_matter_uint8(m_state_saturation);
    ret = esp_matter::attribute::update(m_endpoint_id, cluster_id, attribute_id, &val);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to update attribute (%d)", ret);
    }
}

void CDeviceColorControlLight::toggle_state_action()
{
    if (m_state_onoff) {
        GetWS2812Ctrl()->set_brightness(0);
        m_state_onoff = false;
    } else {
        GetWS2812Ctrl()->set_brightness(m_state_brightness);
        m_state_onoff = true;
    }
    matter_update_all_attribute_values();
}

Color Control 클러스터는 Hue/Saturation 기반, Enhanced Hue, Color Loop, XY, Color Temperature 등 총 5종류의 색상 제어 알고리즘에 대응할 수 있다 (자세한 내용은 matter spec 확인)

본 예제에서는 Color Temperature 및 Enhanced Hue, Hue/Saturation 3개 제어 방식을 활성화하도록 한다

※ 색상쪽은 완전 문외한이라...

2.3. system.cpp

bool CSystem::initialize()
{
    GetLogger(eLogType::Info)->Log("Start Initializing System");
    
    esp_err_t ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to initialize nsv flash (%d)", ret);
        return false;
    }

    if (!init_default_button()) {
        GetLogger(eLogType::Warning)->Log("Failed to init default on-board button");
    }

    // create matter root node
    esp_matter::node::config_t node_config;
    snprintf(node_config.root_node.basic_information.node_label, sizeof(node_config.root_node.basic_information.node_label), PRODUCT_NAME);
    m_root_node = esp_matter::node::create(&node_config, matter_attribute_update_callback, matter_identification_callback);
    if (!m_root_node) {
        GetLogger(eLogType::Error)->Log("Failed to create root node");
        return false;
    }
    GetLogger(eLogType::Info)->Log("Root node (endpoint 0) added");

    // prevent endpoint id increment when board reset
    matter_set_min_endpoint_id(1);
    
    // start matter
    ret = esp_matter::start(matter_event_callback);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to start matter (ret: %d)", ret);
        return false;
    }
    GetLogger(eLogType::Info)->Log("Matter started");

    // enable chip shell
    // esp_matter::console::diagnostics_register_commands();
    // esp_matter::console::init();

    GetWS2812Ctrl()->initialize();
    // set matter endpoints
    CDevice *dev = nullptr;
#if LIGHT_TYPE == 0
    dev = new CDeviceOnOffLight();
#elif LIGHT_TYPE == 1
    dev = new CDeviceLevelControlLight();
#elif LIGHT_TYPE == 2
    dev = new CDeviceColorControlLight();
#endif
    if (dev && dev->matter_add_endpoint()) {
        m_device_list.push_back(dev);
    } else {
        return false;
    }

    GetLogger(eLogType::Info)->Log("System Initialized");
    print_system_info();
    // print_matter_endpoints_info();

    return true;
}

definition.h에서 LIGHT_TYPE 값을 2로 설정하면 ColorControl 객체가 생성된다

2.4. WS2812.h

Hue, Saturation에 대응하여 RGB 값으로 바꾸기 위해 Espressif사의 예제코드를 그대로 복사-붙여넣기해서 사용했다

struct rgb_t
{
    uint8_t r, g, b;
    rgb_t(uint8_t red = 0, uint8_t green = 0, uint8_t blue = 0) {
        r = red;
        g = green;
        b = blue;
    }
};

struct hsv_t
{
    /**
     * @brief 
     * hue range: [0, 360] degree
     * saturation range: [0, 100]
     * value range: [0, 100]
     */
    uint32_t hue;           // 색상
    uint32_t saturation;    // 채도
    uint32_t value;         // 명도
    hsv_t(uint32_t h = 0, uint32_t s = 0, uint32_t v = 100) {
        hue = MIN(360, h);
        saturation = MIN(100, s);
        value = MIN(100, v);
    }

    rgb_t conv2rgb() {
        /**
         * @brief HSV to RGB conversion formula
         * @ref https://en.wikipedia.org/wiki/HSL_and_HSV
         */
        rgb_t rgb;
        
        uint32_t h = hue % 360;
        uint32_t rgb_max = value * 2.55f;
        uint32_t rgb_min = rgb_max * (100 - saturation) / 100.0f;

        uint32_t i = h / 60;
        uint32_t diff = h % 60;

        // RGB adjustment amount by hue
        uint32_t rgb_adj = (rgb_max - rgb_min) * diff / 60;

        switch (i) {
        case 0:
            rgb.r = rgb_max;
            rgb.g = rgb_min + rgb_adj;
            rgb.b = rgb_min;
            break;
        case 1:
            rgb.r = rgb_max - rgb_adj;
            rgb.g = rgb_max;
            rgb.b = rgb_min;
            break;
        case 2:
            rgb.r = rgb_min;
            rgb.g = rgb_max;
            rgb.b = rgb_min + rgb_adj;
            break;
        case 3:
            rgb.r = rgb_min;
            rgb.g = rgb_max - rgb_adj;
            rgb.b = rgb_max;
            break;
        case 4:
            rgb.r = rgb_min + rgb_adj;
            rgb.g = rgb_min;
            rgb.b = rgb_max;
            break;
        default:
            rgb.r = rgb_max;
            rgb.g = rgb_min;
            rgb.b = rgb_max - rgb_adj;
            break;
        }

        return rgb;
    }
};

또한, WS2812 제어 시 일반 GPIO Toggle 방식으로는 한계가 있어서 esp-idf의 RMT (Remote Control Transceiver) 모듈을 활용했다 (nearly 100us 단위로 GPIO 신호 제어가 가능하다)

bool CWS2812Ctrl::init_rmt()
{
    esp_err_t ret;

    rmt_tx_channel_config_t rmt_tx_ch_cfg;
    rmt_tx_ch_cfg.gpio_num = GPIO_PIN_WS2812_DATA;
    rmt_tx_ch_cfg.clk_src = RMT_CLK_SRC_DEFAULT;
    rmt_tx_ch_cfg.resolution_hz = RMT_RESOLUTION_HZ;
    rmt_tx_ch_cfg.mem_block_symbols = 64;
    rmt_tx_ch_cfg.trans_queue_depth = 4;
    rmt_tx_ch_cfg.flags.invert_out = 0;
    rmt_tx_ch_cfg.flags.io_od_mode = 0;
    rmt_tx_ch_cfg.flags.with_dma = 0;
    ret = rmt_new_tx_channel(&rmt_tx_ch_cfg, &m_rmt_ch_handle);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to create RMT TX channel (ret %d)", ret);
        return false;
    }

    m_rmt_enc_base = new rmt_encoder_t();
    if (!m_rmt_enc_base) {
        GetLogger(eLogType::Error)->Log("Failed to create RMT base encoder (ret %d)", ret);
        return false;
    }
    m_rmt_enc_base->encode = func_rmt_encode;
    m_rmt_enc_base->reset = func_rmt_reset;
    m_rmt_enc_base->del = func_rmt_delete;

    rmt_bytes_encoder_config_t rmt_bytes_enc_cfg;
    rmt_bytes_enc_cfg.bit0.duration0 = 0.3 * RMT_RESOLUTION_HZ / 1000000;  // T0H=300ns
    rmt_bytes_enc_cfg.bit0.level0 = 1;
    rmt_bytes_enc_cfg.bit0.duration1 = 0.9 * RMT_RESOLUTION_HZ / 1000000;  // T0L=900ns
    rmt_bytes_enc_cfg.bit0.level1 = 0;
    rmt_bytes_enc_cfg.bit1.duration0 = 0.9 * RMT_RESOLUTION_HZ / 1000000;  // T1H=900ns
    rmt_bytes_enc_cfg.bit1.level0 = 1;
    rmt_bytes_enc_cfg.bit1.duration1 = 0.3 * RMT_RESOLUTION_HZ / 1000000;  // T1L=300ns
    rmt_bytes_enc_cfg.bit1.level1 = 0;
    rmt_bytes_enc_cfg.flags.msb_first = 1;
    ret = rmt_new_bytes_encoder(&rmt_bytes_enc_cfg, &m_rmt_enc_bytes);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to create RMT bytes encoder (ret %d)", ret);
        return false;
    }

    rmt_copy_encoder_config_t rmt_copy_enc_cfg;
    ret = rmt_new_copy_encoder(&rmt_copy_enc_cfg, &m_rmt_enc_copy);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to create RMT copy encoder (ret %d)", ret);
        return false;
    }

    uint32_t reset_ticks = RMT_RESOLUTION_HZ / 1000000 * 300 / 2; // reset code = 300us
    m_rmt_reset_code.duration0 = reset_ticks;
    m_rmt_reset_code.level0 = 0;
    m_rmt_reset_code.duration1 = reset_ticks;
    m_rmt_reset_code.level1 = 0;

    // set enable rmt channel
    ret = rmt_enable(m_rmt_ch_handle);
    if (ret != ESP_OK) {
        GetLogger(eLogType::Error)->Log("Failed to enable RMT (ret %d)", ret);
        return false;
    }

    return true;
}

RMT에 대한 자세한 설명과 예시는 Espressif의 공식 문서를 참고

https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/peripherals/rmt.html

 

Remote Control Transceiver (RMT) - ESP32 - — ESP-IDF Programming Guide latest documentation

The RMT transmitter can generate a carrier wave and modulate it onto the base signal. Compared to the base signal, the carrier frequency is usually high. In addition, user can only set the frequency and duty cycle for the carrier. The RMT receiver can demo

docs.espressif.com

3. DEMO

Matter commissioning 과정은 다른 글들에서 언급한 것과 차이가 없어서 이 글에서는 스킵하고 실제로 Apple Home 앱에서 제어되는 데모만 살펴보도록 한다

※ 근래 Matter 제품 출시 준비때문에 PAI, PAA, CD, DAC 작업하느라 간단한 예제 돌려볼 시간도 부족하다 ㅠㅠ

색상 제어의 세계는 나름 심오해서 약간 빡센 공부가 필요할 것 같다... (클러스터 스펙 문서 한번 훑어보는 데만도 반나절 이상이 소요됐다;;)

 

어쨌든 Matter 클러스터 구현과 홈 IoT 플랫폼과의 연동이 어느 정도 만족스러운 수준으로 구동되는 것은 확인했으니, 제품 양산 시 알게되는 새로운 내용에 대해서는 별도의 글로 포스팅하도록 한다

반응형
Comments