[스마트 굴비] nRF Connect SDK Matter 조명 구현

스마트 굴비
굴비가 아니고 명태 #
![[LEXAN MOD] 버섯 무드등을 IoT 조명으로 개조하기](https://varofla.com/blog/laxon-mod-3/3.webp)
[LEXAN MOD] 버섯 무드등을 IoT 조명으로 개조하기
수년간 코드 꽂아두고 사용하던 조명이 드디어 고장이 났습니다. 하지만 껍데기는 이쁘니 내장을 뽑아내고 IoT 조명으로 개조해 더 굴려 먹기로 했습니다.
varofla.com몇 년 전, 고장이 난 무드등을 Home Assistant(이하 HA)에 연결할 수 있는 IoT 조명으로 개조한 적이 있습니다. ESP32를 사용하는 PCB를 설계해 집어넣고 Wi-Fi에 연결해 MQTT Discovery를 통해 HA에서 인식되도록 한 프로젝트였습니다.
시간이 조금 지난 뒤에는 서버랙에 공기청정기를 달아주는 Rack Out Of Dust 프로젝트에서 nRF52840의 ZigBee를 통해 HA에 연결해 보기도 했습니다.
두 프로젝트 모두 HA에 연결하는 점은 같았지만, HA에 연결하기 위해 전자는 MQTT를 거쳐야 하기 때문에 MQTT Discovery 구문을 만들어야 했고, 후자는 Z2M을 거치기 때문에 Z2M 컨버터를 따로 만들어야 했습니다. 조금 더 깔끔한 방법이 없을까 하다가 HA에서 Matter의 인증을 받은 이후로 Matter에 관심을 가지게 되었습니다...만, 별다른 계기가 없어서 흐지부지하고 있었습니다.

그러던 중 귀여운 조명을 발견하게 되었습니다.
굴비..아니 명태 조명? 뭔가 현관에 두면 이쁘지 않을까 하는 생각이 들더라고요.

유일한 단점이라면 배터리.. 24시간 켜둘 게 뻔한 조명에 배터리는 그다지 마음에 들지 않았습니다.
마침, Matter의 맛도 궁금했던터라 이참에 Matter 조명을 만들어보기로 했습니다.

nRF54L15 DK 핸즈온
Nordic의 최신 Matter SoC인 nRF54L15를 맛보기 위해 개발보드를 구매했습니다.
varofla.com꽤 오랜만에 Nordic의 SoC를 다루다 보니 어차피 하나도 기억나지 않을 거.. nRF52840을 대체하는 nRF54L15 SoC로 시작해 보기 위해 nRF54L5-DK를 구매해 간단히 워밍업을 해보았습니다.
Matter 살펴보기 #

Technical Documentation - Matter
docs.nordicsemi.com코드를 들여다보기에 앞서서 공식 Docs를 읽어봤습니다. 몇몇 내용을 살펴보면..
The Matter SDK is the software library that constitutes the reference implementation of the Matter protocol specification. There is no one unified, documented Matter SDK API that can be referenced when developing a custom application. Instead, to learn how to interact with the Matter library, a Matter firmware developer must peruse the source code of any of the existing Matter sample applications. To aid developers, Nordic Semiconductor provides a unified API that wraps initialization of Matter-specific components into more user-friendly high level code.
Nordic Docs - Matter APIs 중 일부 [링크]
Matter SDK의 Docs 그런 거 없으니 바라지 말라..? 예제를 참고해서 느낌적으로 코딩해라....? 하지만 우리가 warp한 API 제공할 테니 고마워하라고 합니다. 아, 사실 고마워하란 이야기는 없습니다.
The Matter application code in the nRF Connect SDK can be divided into the following steps:
Initialization of application-specific components. This includes initialization of hardware modules and registration of proprietary Bluetooth® LE services.
Initialization of the Matter stack.
Starting the application main event loop.
Interaction between the application and the Matter Data Model. This is based on the Zigbee Cluster Library (ZCL) callbacks.
Steps 1 to 3 can be implemented with the use of the nRF Connect Matter API and the utilities provided as a part of nRF Connect SDK Matter samples’
commonmodules (ncs/nrf/samples/matter/common). Step 4 requires ZCL callback functions that must be provided to interact with the Matter Data Model. These callbacks are specific to the particular configuration of the Data Model (in other words, the supported clusters) and therefore cannot be generalized or abstracted in a more user-friendly form.Nordic Docs - Matter APIs 중 일부 [링크]
nRF Connect SDK의 common 모듈 형태로 Matter을 wrap한 편의성 API를 제공한다는것 같습니다.

예제로 느낌을 잡아라 하니,, 우선 공식 예제를 통해 느낌을 보기로 했습니다. "matter light bulb" 예제입니다.
varofla@varoflas-MacBook-Pro light_bulb % tree src -L 1
src
├── app_task.cpp
├── app_task.h
├── aws_iot_integration
├── chip_project_config.h
├── default_zap
├── main.cpp
└── zcl_callbacks.cppsrc의 구조는 이렇게.. 그나저나 cpp..? 원판인 CHIP Matter SDK 자체가 C++ 기반이라 강제되는것 같습니다.
app_task.cpp- Matter SDK init, 기능 구현main.cpp- app_task 호출zcl_callbacks.cpp- callback 구현
각각의 파일은 이런 역할을 가지고 있었습니다. 코드를 하나씩 살펴보면,
app_task.cpp #
Matter의 init, DevKit의 상식적인(?)기능 구현을 담고 있습니다. 코드에서 쓸모없는 내용은 쳐내고 한번 살펴보겠습니다.
CHIP_ERROR AppTask::Init() {
/* Initialize Matter stack */
ReturnErrorOnFailure(
Nrf::Matter::PrepareServer(
Nrf::Matter::InitData{
.mPostServerInitClbk = []() {
app::SetAttributePersistenceProvider(&gDeferredAttributePersister);
gSimpleAttributePersistence.Init(Nrf::Matter::GetPersistentStorageDelegate());
return CHIP_NO_ERROR;
}}));
if (!Nrf::GetBoard().Init(ButtonEventHandler)) {
LOG_ERR("User interface initialization failed.");
return CHIP_ERROR_INCORRECT_STATE;
}
/* Register Matter event handler that controls the connectivity status LED based on the captured Matter network
* state. */
ReturnErrorOnFailure(Nrf::Matter::RegisterEventHandler(Nrf::Board::DefaultMatterEventHandler, 0));
ReturnErrorOnFailure(sIdentifyCluster.Init());
return Nrf::Matter::StartServer();
}main.cpp에서 호출하는 Init 메소드입니다.
Nrf::Matter::PrepareServer처럼 Nrf::Matter namespace의 함수들이 nRF Connect SDK가 wrapping한 API인것 같습니다.
그밖에 Nrf::Board는 DevKit의 LED나 스위치를 사용하기 위한 레이어입니다. DevKit의 BSP 위에 Matter 코드를 올려둔 구조로, 온보드 LED와 버튼, 디커미셔닝 기능 등이 구현되어 있습니다.
void AppTask::UpdateClusterState() {
SystemLayer().ScheduleLambda([this] {
/* write the new on/off value */
Protocols::InteractionModel::Status status =
Clusters::OnOff::Attributes::OnOff::Set(kLightEndpointId, mPWMDevice.IsTurnedOn());
if (status != Protocols::InteractionModel::Status::Success) {
LOG_ERR("Updating on/off cluster failed: %x", to_underlying(status));
}
/* write the current level */
status = Clusters::LevelControl::Attributes::CurrentLevel::Set(kLightEndpointId, mPWMDevice.GetLevel());
if (status != Protocols::InteractionModel::Status::Success) {
LOG_ERR("Updating level cluster failed: %x", to_underlying(status));
}
});
}온보드 버튼 클릭으로 LED의 상태가 바뀌었을 때 이를 업데이트하는 코드입니다.
- OnOff Cluster, OnOff Attribute:
Clusters::OnOff::Attributes::OnOff::Set() - LevelControl Cluster, CurrentLevel Attribute:
Clusters::LevelControl::Attributes::CurrentLevel::Set()
Clusters::{Cluster 이름}::Attributes::{Attribute 이름}::Set 이런 식으로 특정 Cluster의 원하는 Attribute를 업데이트하는 모양입니다.
물론 생긴 것부터 CHIP의 코드입니다.
그나저나 예전에 ZigBee를 써볼 때에는 ZCL에서 Cluster, Attirbute를 찾아 함수에 넣었던 기억이 있는데요. 여긴 namespace로 떡칠하고 ID는 내부에서 쓱싹하는 모양입니다. 이거 참 C++ 1승이네요. 코드 짤 때마다 Cluster Spec을 뒤적거리지않아도 될 것 같습니다. 그나저나 둘 다 같은 ZCL 쓰지 않나...?
파일의 나머지 내용들은 "버튼을 누르면 LED를 켠다" 같이 Matter와 별 관련이 없어서 생략합니다.
zcl_callbacks.cpp #
Matter SDK의 모든 Attribute 변경은 callback으로 처리됩니다. 미리 weak로 만들어져있고, 이걸 사용자가 정의해서 사용하는 방식입니다.
void MatterPostAttributeChangeCallback(const chip::app::ConcreteAttributePath &attributePath, uint8_t type,
uint16_t size, uint8_t *value) {
ClusterId clusterId = attributePath.mClusterId;
AttributeId attributeId = attributePath.mAttributeId;
if (clusterId == OnOff::Id && attributeId == OnOff::Attributes::OnOff::Id) {
ChipLogProgress(Zcl, "Cluster OnOff: attribute OnOff set to %" PRIu8 "", *value);
AppTask::Instance().GetPWMDevice().InitiateAction(*value ? Nrf::PWMDevice::ON_ACTION : Nrf::PWMDevice::OFF_ACTION,
static_cast<int32_t>(LightingActor::Remote), value);
} else if (clusterId == LevelControl::Id && attributeId == LevelControl::Attributes::CurrentLevel::Id) {
ChipLogProgress(Zcl, "Cluster LevelControl: attribute CurrentLevel set to %" PRIu8 "", *value);
if (AppTask::Instance().GetPWMDevice().IsTurnedOn()) {
AppTask::Instance().GetPWMDevice().InitiateAction(
Nrf::PWMDevice::LEVEL_ACTION, static_cast<int32_t>(LightingActor::Remote), value);
} else {
ChipLogDetail(Zcl, "LED is off. Try to use move-to-level-with-on-off instead of move-to-level");
}
}
}모든 Attribute의 변경을 핸들링하는 MatterPostAttributeChangeCallback 함수입니다.
Cluster와 Attribute ID를 비교해 if문으로 빠져나가고 있습니다. 마찬가지로 여기도 ID는 하드코딩 하지 않는 모습입니다. (OnOff::Id 등)
void emberAfOnOffClusterInitCallback(EndpointId endpoint) {
Protocols::InteractionModel::Status status;
bool storedValue;
/* Read storedValue on/off value */
status = Attributes::OnOff::Get(endpoint, &storedValue);
if (status == Protocols::InteractionModel::Status::Success) {
/* Set actual state to the cluster state that was last persisted */
AppTask::Instance().InitPWMDDevice();
AppTask::Instance().GetPWMDevice().InitiateAction(
storedValue ? Nrf::PWMDevice::ON_ACTION : Nrf::PWMDevice::OFF_ACTION,
static_cast<int32_t>(LightingActor::Remote), reinterpret_cast<uint8_t *>(&storedValue));
}
AppTask::Instance().UpdateClusterState();
}emberAf prefix가 붙은 함수들은 라이프사이클과 관련이 있습니다. 마찬가지로 SDK에서 weak으로 선언되어 있습니다.
여기서 정의한 emberAfOnOffClusterInitCallback는 SDK의 init 직후 NVM에서 LED의 초기 상태를 읽어오는 함수입니다.
zap-gui와 zap-generate #
Matter는 Cluster 설정에 무려 GUI 툴을 사용합니다. zap-gui명령을 입력해 GUI에서 설정하고 zap-generate로 소스코드를 generate합니다. nRF Connect SDK에서는 west에 두 커맨드가 통합되어 있습니다.
~ % west zap-gui

~ % west zap-generate
Matter 조명 바닥부터 시작하기 #
이제 코드를 짜봅시다. nRF54L15DK에서 Matter 조명을 테스트하는것이 목표입니다.

템플릿을 복사해 오니.. 아이고 막막하네요 ㅋㅋ..
varofla@varoflas-MacBook-Pro template % tree src -L 1
src
├── app
├── bsp
├── mw
└── main.c일단 항상 하던 대로 레이어링을 합니다.
app: taskbsp: 보드 기능 구현 (온보드 스위치, 온보드 LED 등)mw: task에 두기 애매한 미들웨어(matter 등)
이런 식으로 구조를 잡고, main.c에서는 bsp init, app init, app start 과정을 거칩니다.
#include "app/app.h"
#include "bsp/bsp.h"
int main() {
bsp_init();
app_init();
app_start();
}일단 main이 깔끔해야 심신이 안정됩니다 ㅋㅋ..
MW Matter Wrapper 구현 #
varofla@varoflas-MacBook-Pro template % tree src/mw -L 2
src/mw
├── CMakeLists.txt
└── matter
├── CMakeLists.txt
├── chip_project_config.h
├── default_zap
├── matter.cpp
├── matter.h
├── matter_internal.hpp
├── matter_spec.h
└── zcl_callbacks.cpp일단 가장 중요한 Matter부터 구출했습니다.

zap-tool에서 Endpoint를 추가하고,


눈치 싹싹 보면서 Attribute들을 추가했습니다.
이제 코드 생성하고 MW를 만들면 되는데.. 그..이슈가.. Matter SDK는 C++인데 저는 C++를 모른단 말이죠..? 아니 분명 배우긴 했는데..
// src/mw/matter/matter.h
#ifndef SRC_MW_MATTER_MATTER_C_BRIDGE
#define SRC_MW_MATTER_MATTER_C_BRIDGE
#include <stdbool.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
// (typedef 생략)
/* lifecycle */
matter_c_err_t matter_c_init(const matter_init_params_t *params, matter_ctx_t **out_ctx);
matter_c_err_t matter_c_start(matter_ctx_t *ctx);
void matter_c_deinit(matter_ctx_t *ctx);
/* API */
matter_c_err_t matter_c_set_onoff(matter_ctx_t *ctx, uint16_t endpoint, bool on);
matter_c_err_t matter_c_set_level(matter_ctx_t *ctx, uint16_t endpoint, uint8_t level);
matter_c_err_t matter_c_set_color(matter_ctx_t *ctx, uint16_t endpoint, uint16_t color);
/* Attribute 콜백 등록/해제 */
matter_c_err_t matter_c_register_event_cb(matter_ctx_t *ctx, matter_event_cb_t cb, void *user);
matter_c_err_t matter_c_unregister_event_cb(matter_ctx_t *ctx);
#ifdef __cplusplus
}
#endif
#endif /* SRC_MW_MATTER_MATTER_C_BRIDGE */
wrapper를 만들었습니다. rclcpp도 그렇고 점점 C++ 압박이 오고 있는것 같습니다. 일단 오늘은 아닌걸로...
// src/mw/matter/zcl_callbacks.cpp
void MatterPostAttributeChangeCallback(const chip::app::ConcreteAttributePath &path,
uint8_t type, uint16_t size, uint8_t *value) {
(void)type;
matter_event_t evt{};
evt.type = MATTER_EVT_ATTRIBUTE_CHANGED;
evt.endpoint = path.mEndpointId;
evt.cluster_id = path.mClusterId;
evt.attribute_id = path.mAttributeId;
if (value && size <= sizeof(evt.value)) {
memcpy(evt.value, value, size);
evt.value_len = size;
}
matter_emit_event(evt);
}이벤트는 이런 식으로 넘기고,
// src/mw/matter/matter.cpp
static void emit_event(matter_ctx *ctx, const matter_event_t &evt) {
if (!ctx || !ctx->cb) {
return;
}
ctx->cb(&evt, ctx->cb_user);
}
void matter_emit_event(const matter_event_t &evt) {
emit_event(s_ctx, evt);
}콜백을 통해 APP 레이어로 발사합니다.
BSP PWM LED 구현 #
varofla@varoflas-MacBook-Pro template % tree src/bsp -L 2
src/bsp
├── CMakeLists.txt
├── bsp.c
├── bsp.h
├── bulb
│ ├── CMakeLists.txt
│ ├── bulb.c
│ └── bulb.h
└── switchs
├── CMakeLists.txt
├── switchs.c
└── switchs.h한편, 조명의 LED와 스위치는 BSP에서 제어합니다. 일단 Matter를 테스트하는 게 목표라, 조명 모듈만 구현했습니다.
/ {
// (생략)
pwmleds {
/delete-node/ pwm_led_1;
pwm_led_cool: pwm_led_cool {
pwms = <&pwm20 0 PWM_USEC(1000) PWM_POLARITY_NORMAL>;
};
pwm_led_warm: pwm_led_warm {
pwms = <&pwm20 1 PWM_USEC(1000) PWM_POLARITY_NORMAL>;
};
};
};
&pinctrl {
/delete-node/ pwm20_default;
/delete-node/ pwm20_sleep;
/omit-if-no-ref/ pwm20_default: pwm20_default {
group1 {
psels = <NRF_PSEL(PWM_OUT0, 1, 10)>,
<NRF_PSEL(PWM_OUT1, 1, 14)>;
};
};
/omit-if-no-ref/ pwm20_sleep: pwm20_sleep {
group1 {
psels = <NRF_PSEL(PWM_OUT0, 1, 10)>,
<NRF_PSEL(PWM_OUT1, 1, 14)>;
low-power-enable;
};
};
};아직 회로는 설계하기 전이라, 일단은 DevKit에 맞게 오버레이를 만들었습니다.
// src/bsp/bulb/bulb.h
#ifndef SRC_BSP_BULB_BULB
#define SRC_BSP_BULB_BULB
#include <stdbool.h>
#include <stdint.h>
bool bulb_init(void);
void bulb_set_power(bool value);
void bulb_set_brightness(uint8_t value);
void bulb_set_color_temperature(uint32_t value);
void bulb_update(void);
uint8_t bulb_get_brightness(void);
#endif /* SRC_BSP_BULB_BULB */matter의 attribute 업데이트는 onoff, 밝기, 색온도 조절 전부 따로 노는 것 같더라고요. 하나하나 API를 만들었습니다.
APP task 구현 #
varofla@varoflas-MacBook-Pro template % tree src/app -L 2
src/app
├── CMakeLists.txt
├── app.c
├── app.h
├── board_tasks
├── main_task
└── matter_task
├── matter_task.c
└── matter_task.h일단 task 틀만 잡아두고 Matter MW에 필수적인 task만 구현했습니다.
// src/app/matter_task/matter_task.c
static void s_task(void *, void *, void *) {
s_matter_start();
matter_stack_event_queue_data_t data;
bool is_provisioned = false; // temp
for (;;) {
if (k_msgq_get(&matter_stack_event_queue, &data, K_FOREVER) != 0) {
continue;
}
switch (data.event_data.type) {
case MATTER_EVT_STACK_READY:
LOG_DBG("matter stack ready!");
bulb_task_set_effect(BULB_EFFECT_NONE);
break;
// (생략)
case MATTER_EVT_ATTRIBUTE_CHANGED:
LOG_DBG("endpoint: %d \t cluster: %d \t attribute: %d",
(int)data.event_data.endpoint, (int)data.event_data.cluster_id, (int)data.event_data.attribute_id);
s_update_attribute(&data.event_data);
break;
case MATTER_EVT_NONE:
default:
break;
}
}
}Matter를 init하고 무한 switch 처리.. 스니펫에는 나오지 않지만, callback은 msgq로 던집니다.
// src/app/matter_task/matter_task.c
static void s_update_attribute(const matter_event_t *evt) {
uint16_t endpoint = evt->endpoint;
uint32_t cluster_id = evt->cluster_id;
uint32_t attribute_id = evt->attribute_id;
const uint8_t *data = evt->value;
uint16_t data_len = evt->value_len;
switch (cluster_id) {
case MATTER_CLUSTER_ID_ONOFF:
s_update_onoff(endpoint, attribute_id, data, data_len);
break;
case MATTER_CLUSTER_ID_LEVEL_CONTROL:
s_update_level(endpoint, attribute_id, data, data_len);
break;
case MATTER_CLUSTER_ID_COLOR_CONTROL:
s_update_color(endpoint, attribute_id, data, data_len);
break;
default:
break;
}
}Attribute 변경 처리는 이렇게 했습니다.
테스트 #
Home Assistant에서 Thread Matter 장치를 연결할 때는 크게 두 가지 방법이 있습니다.
- SkyConnect(ZBT-1) 등을 HA에 연결하고 OpenThrad 보더라우터를 세팅해 사용
- 스마트폰 제조사의 보더라우터를 구매해 Home Assistant에 등록해 사용

저의 경우 후자로, Apple 스마트폰을 사용 중이기 때문에 HomePod mini를 보더라우터로 사용합니다.




QR 찍고, 추가.. 당연히 Matter 인증 따위 없기 때문에 인증 경고가 발생합니다. 어차피 혼자 사용하는데 뭐..

Matter 로고!! 깔끔하게 연결된 모습입니다.
마무리 #
해야지 해야지 하던 Matter에 드디어 손을 대보았습니다.
C++이기도 하고 워낙 커다란 표준이다 보니 겁부터 먹었는데, 막상 코드를 읽어보고 하니 쉽다는 느낌이 많이 들었습니다. 진작에 해볼걸 그랬습니다.
다음 포스팅에서는 조명을 분해하고 회로를 설계하는 과정이 될 것 같네요.