자바스크립트를 활성화 해주세요

마이크로소프트 서피스 다이얼 클론 제작 - 2

 ·   ·  ☕ 8 min read  ·  ✍️ Yogo

Wanna be surface dial

펌웨어 개발

nrf52 SDK 폴더내에 샘플이 포함되어 있습니다. examples 폴더 안에 관련 샘플들을 찾아볼 수 있는데, 서피스 다이얼은 HID 디바이스 이므로 ble_app_hids_keyboard나 ble_app_hids_mouse를 베이스로 작성하면 어렵지 않게 시작할 수 있습니다.

다만 BLE 스펙이나 nrf52 개발환경에 익숙하지 않은 분들에게는 좀 복잡하고 어려울 수 있습니다. 그래서 적어도 BLE 스펙상에서 통신 커넥션이나 시퀀스에 대해서는 어느정도 숙지를 해야 어려움이 없습니다.

HID 관련 부분은 USB의 디스크립터와 유사하니 이전 포스트나 USB 스펙을 참고하시면 됩니다.

여기서는 BLE 기초에 대하여 설명하지 않으므로 기본적인 정보가 필요하시면 The Basics of Bluetooth Low Energy1의 내용이 도움이 될 수 있습니다. 그리고 BLE 버젼별 차이가 궁금하신 경우 The differences between BLE 4.0, BLE 4.1, BLE 4.2, and BLE 52을 참고하세요.

프로젝트 준비 및 BSP(Board Support Package)

선수 지식이 이미 준비가 되었다면 ble_app_hids_keyboard 예제를 기반으로 개발을 시작합니다.

예제를 먼저 복사 한 후 프로젝트를 불러오면 main.c 파일에 peripheral 부터 BLE 스택 설정, HID 관련 코드가 전부 한 파일내에 들어있어 복잡한 상태입니다. 여기서 BLE 설정 및 동작과 연관된 코드를 분리하고 빌드하여 기존 예제 동작에 문제가 없는 지 확인을 합니다.

별다른 오류가 없다면 구성한 하드웨어에 맞게 코드 수정을 합니다.

예제 프로젝트들은 다양한 개발 보드 지원을 위해서 BSP 설정을 보드별로 선택하도록 되어 있습니다. 프로젝트 내의 bsp.c 와 board.c 파일을 참고하면 버튼과 LED 제어를 위한 인터페이스가 정의 되어 있습니다. 여기서는 특정 보드를 사용하지 않기 때문에 보드 관련 설정은 무시할 것 입니다.

프로젝트 preprocess definitions에 BOARD_CUSTOM을 추가해 줍니다.

“definitions”

그리고 custom_board.h를 추가하여 포트 핀 번호 정의를 해주도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#define PRIMARY_BUTTON        15 

#define LEDS_NUMBER           1
#define LED_START             28
#define LED_1                 28
#define LED_STOP              28
#define LEDS_ACTIVE_STATE     0
#define LEDS_INV_MASK         LEDS_MASK
#define LEDS_LIST             { LED_1 }
#define BSP_LED_0             LED_1

#define BUTTONS_NUMBER        2
#define BUTTON_START          0
#define BUTTON_1              0
#define BUTTON_2              PRIMARY_BUTTON
#define BUTTON_STOP           PRIMARY_BUTTON
#define BUTTON_PULL           NRF_GPIO_PIN_PULLUP
#define BUTTONS_ACTIVE_STATE  0
#define BUTTONS_LIST          { BUTTON_1, BUTTON_2 }
#define BSP_BUTTON_0          BUTTON_1
#define BSP_BUTTON_1          BUTTON_2

bsp 모듈 사용을 위해서는 PRIMARY_BUTTON 부분을 제외하고 LED, BUTTON에 대한 정의가 필요합니다.

먼저 LED는 BLE 상태나 기타 상태 들을 표시(Indication) 목적으로 사용됩니다. 여기서는 Disconnected 및 Advertising 상태인지 Connected 상태인지 표시 정도만 있어도 되므로 1개의 LED만 고려하여 P0.28번핀을 해당 핀으로 설정 합니다.

버튼은 adverting용 버튼과 primary (엔코더) 버튼 2개만 할당하였습니다. 버튼의 세부설정은 bsp_btn.c에서 확인 할 수 있습니다. 이 파일은 sdk에 포함된 파일로 이 파일을 프로젝트로 복사하여 필요에 맞게 수정을 했습니다.

엔코더 디코더

엔코더에 대한 설명은 이미 유튜브 컨트롤러 제작3에서 포스팅 하였었습니다. 지난 번에는 2개의 채널을 polling 하는 방법으로 엔코더의 회전 방향과 증감량을 측정하였습니다. 그런데 이번에 사용한 NRF52832의 경우 Quadrature Encoder를 디코딩 할 수 있는 Quadrature Decoder(이하 QDEC)4를 포함하고 있습니다.

드라이버 코드는 다음과 같이 작성하였습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
uint8_t accumulation_value = 0;

static void qdec_event_handler(nrf_drv_qdec_event_t event)
{    
    dial_report_t dial_report = {0};
    int16_t acc;
    uint16_t accdbl;
    uint8_t value;

    if (event.type == NRF_QDEC_EVENT_REPORTRDY) {
        accdbl        = event.data.report.accdbl;
        acc           = event.data.report.acc;
        value         = event.data.sample.value;

        accumulation_value += event.data.report.acc;

        if (accumulation_value > 3) {
          dial_report.rotation = -100;
          ble_send_input_report((uint8_t *)(&dial_report));
          accumulation_value = 0;
        } else if (accumulation_value < -3) {
          dial_report.rotation = 100;
          ble_send_input_report((uint8_t *)(&dial_report));
          accumulation_value = 0;
        }
    }
}

void qdec_init(void) {
  ret_code_t err_code;
  nrf_drv_qdec_config_t config = {
  .reportper          = (nrf_qdec_reportper_t)NRF_QDEC_REPORTPER_10,
  .sampleper          = (nrf_qdec_sampleper_t)NRF_QDEC_SAMPLEPER_1024us,
  .psela              = ENCODER_CH_A,                           
  .pselb              = ENCODER_CH_B,                           
  //.pselled            = NRFX_QDEC_CONFIG_PIO_LED,                         
  //.ledpre             = NRFX_QDEC_CONFIG_LEDPRE,                          
  //.ledpol             = (nrf_qdec_ledpol_t)NRFX_QDEC_CONFIG_LEDPOL,
  .dbfen              = true,                           
  .sample_inten       = true,                    
  .interrupt_priority = NRFX_QDEC_CONFIG_IRQ_PRIORITY,      
  };

  err_code = nrf_drv_qdec_init(&config, qdec_event_handler);
  APP_ERROR_CHECK(err_code);
}

지난 프로젝트에서는 폴링(polling) 방식으로 엔코더 시그널 변화를 인식했다면 이번 프로젝트에서는 qdec 장치를 이용하여 엔코더의 회전이 발생 시 인터럽트와 콜백 이벤트로 엔코더의 회전 여부와 방향을 체크하도록 하였습니다.

엔코더는 지난번과 동일한 벤더 제품으로 1 클릭 회전당 4회 채널 상태변화가 발생합니다. qdec은 리포트 타이밍에 따라 내부적으로 회전 변화량을 누적하여 반환을 하는데 1클릭 당 1 ~ 3 사이의 누적된 변화량이 연속적으로 리포팅 됩니다. 이 누적 변화량이 한쪽 방향으로 4회가 되는지 여부 확인을 위해서 임시 변수에 누적하여 저장 및 확인을 하고 재 설정하는 방식으로 1클릭을 인식하도록 하였습니다.

참고로 dial_report는 BLE를 통해 전송할 데이터를 담는 구조체를 별도로 만들고 해당 구조체에 값을 저장한 것로 나중에 따로 설명하겠습니다.

HID (Human Interface Device) Descriptor

BLE HID 디스크립터는 USB와 동일한 디스크립터 형식으로 선언합니다. keyboard 샘플을 기반으로 하였으므로 기존 디스크립터는 키보드 장치를 위한 디스크립터 입니다. 이 부분을 Radial Controller 디스크립터로 변경해야합니다.

Windows radial controller sample report descriptors5를 참고하면 샘플 디스크립터가 선언되어 있습니다. Haptick Feedback은 optional이고 현재 하드웨어에는 적용되어 있지 않으니 일단 제외하고 rotation과 primary button만 지정된 디스크립터를 적용합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// Integrated Radial Controller TLC
0x05, 0x01,         // USAGE_PAGE (Generic Desktop)          
0x09, 0x0e,         // USAGE (System Multi-Axis Controller)                      
0xa1, 0x01,         // COLLECTION (Application)         
0x85, 0x01,         //   REPORT_ID (Radial Controller)                
0x05, 0x0d,         //   USAGE_PAGE (Digitizers)
0x09, 0x21,         //   USAGE (Puck)                 
0xa1, 0x00,         //   COLLECTION (Physical)  
0x05, 0x09,         //     USAGE_PAGE (Buttons)           
0x09, 0x01,         //     USAGE (Button 1)
0x95, 0x01,         //     REPORT_COUNT (1)
0x75, 0x01,         //     REPORT_SIZE (1)   
0x15, 0x00,         //     LOGICAL_MINIMUM (0)      
0x25, 0x01,         //     LOGICAL_MAXIMUM (1)         
0x81, 0x02,         //     INPUT (Data,Var,Abs)
0x05, 0x01,         //     USAGE_PAGE (Generic Desktop)          
0x09, 0x37,         //     USAGE (Dial)
0x95, 0x01,         //     REPORT_COUNT (1)
0x75, 0x0f,         //     REPORT_SIZE (15)  
0x55, 0x0f,         //     UNIT_EXPONENT (-1)           
0x65, 0x14,         //     UNIT (Degrees, English Rotation)        
0x36, 0xf0, 0xf1,   //     PHYSICAL_MINIMUM (-3600)         
0x46, 0x10, 0x0e,   //     PHYSICAL_MAXIMUM (3600)      
0x16, 0xf0, 0xf1,   //     LOGICAL_MINIMUM (-3600)      
0x26, 0x10, 0x0e,   //     LOGICAL_MAXIMUM (3600)        
0x81, 0x06,         //     INPUT (Data,Var,Rel)

// not supported
// 0x09, 0x30,     //     USAGE (X)
// 0x75, 0x10,     //     REPORT_SIZE (16)                    
// 0x55, 0x0d,     //     UNIT_EXPONENT (-3)           
// 0x65, 0x13,     //     UNIT (Inch,EngLinear)        
// 0x35, 0x00,     //     PHYSICAL_MINIMUM (0)         
// 0x46, 0xc0, 0x5d,   //     PHYSICAL_MAXIMUM (24000)      
// 0x15, 0x00,     //     LOGICAL_MINIMUM (0)      
// 0x26, 0xff, 0x7f,   //     LOGICAL_MAXIMUM (32767)      
// 0x81, 0x02,     //     INPUT (Data,Var,Abs)         
// 0x09, 0x31,     //     USAGE (Y)                    
// 0x46, 0xb0, 0x36,   //     PHYSICAL_MAXIMUM (14000)      
// 0x81, 0x02,     //     INPUT (Data,Var,Abs)        
// 0x05, 0x0d,     //     USAGE_PAGE (Digitizers)
// 0x09, 0x48,     //     USAGE (Width)
// 0x36, 0xb8, 0x0b,   //     PHYSICAL_MINIMUM (3000)
// 0x46, 0xb8, 0x0b,   //     PHYSICAL_MAXIMUM (3000)
// 0x16, 0xb8, 0x0b,   //     LOGICAL_MINIMUM (3000)    
// 0x26, 0xb8, 0x0b,   //     LOGICAL_MAXIMUM (3000)      
// 0x81, 0x03      //     INPUT (Cnst,Var,Abs)     

0xc0,           //   END_COLLECTION
0xc0,           // END_COLLECTION

배터리 잔량 체크

샘플에는 Battery Service에 대한 부분도 포함되어 있습니다. 다만, 실제 배터리 잔량이 아닌 가상의 값으로 전송하고 있으므로 실제 배터리 용량을 체크를 할 수 있도록 고려를 해보도록 합니다.

배터리 잔량을 체크하기 위한 간단한 방법은 배터리 전압을 ADC로 읽는 것 입니다. 한가지 유의할 점은 MCU 구동 전압보다 배터리 최대 전압이 높을 경우 포트와 직접연결은 하지않고 저항으로 분압하여 MCU의 operation voltage maximum rating을 초과하지 않도록 합니다.

외부에 10k 저항 2개를 직렬로 연결하여 1/2로 분압하도록 하였습니다. Li-Po 배터리는 만충 시 최대 4.2v이므로 ADC에서 측정이 되는 최대 전압은 2.1v가 될 것이며 Vdd를 3V로 사용할 예정이므로 특별한 문제는 없을 것 입니다.

다만 배터리의 양극에 저항이 연결되어 있으므로 최대 210uA 정도 지속적인 전류 소모가 발생합니다. 280mAh 용량의 배터리이므로 대략 계산 시 빠른 시간내에 전부 소진되지는 않겠지만 불필요한 전류 소모이므로 이를 방지하기 위해서는 FET(Field Effect Transistor)를 이용하여 전압 측정 할 때에만 저항에 전압이 인가되도록 하는 방법이 있습니다.

하지만 여기서는 실제로 고려되었다가 납땜 여유 공간 부족으로 적용하지 못했습니다. 다음 개선 버전 고려 시 적용 하도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void saadc_init(void) {
  ret_code_t err_code;
  nrfx_saadc_config_t saadc_config;
  nrf_saadc_channel_config_t batt_channel_config;

  saadc_config.low_power_mode = true;
  saadc_config.resolution = NRF_SAADC_RESOLUTION_12BIT;  
  saadc_config.oversample = NRF_SAADC_OVERSAMPLE_DISABLED;
  saadc_config.interrupt_priority = APP_IRQ_PRIORITY_LOW;
  
  err_code = nrfx_saadc_init(&saadc_config, saadc_callback);
  APP_ERROR_CHECK(err_code);

  // batterty (0 ~ 2.4v)
  // Each two series resistors are 10k ohm. battery max voltage is 4.2v, so ADC input max voltage 2.1v.
  set_channel_config(&batt_channel_config, NRF_SAADC_INPUT_AIN4, NRF_SAADC_GAIN1_4);
  err_code = nrfx_saadc_channel_init(BATT_ADC_CHANNEL, &batt_channel_config);
  APP_ERROR_CHECK(err_code);
}

double saadc_read_battery_voltage(void) {
  nrf_saadc_value_t value;
  nrfx_saadc_sample_convert(BATT_ADC_CHANNEL, &value);
  return value * VALUE_PER_VOLTAGE * 2;
}

NRF는 ADC reference voltage를 0.6v 또는 Vdd 기준으로 설정 할 수 있습니다. 만약 0.6v에 gain을 1/4으로 설정하면 0.6/(1/4) = 2.4v로 측정이 가능합니다. resolution이 12bit이므로 2.4v / 4096 * adc value의 공식으로 측정한 ADC 값에 대한 전압을 계산할 수 있고 여기에 2를 곱하면 현재 배터리 전압에 대한 추정이 가능합니다.

그럼 측정된 전압을 다시 퍼센트(%)로 환산이 필요합니다. 하지만 전압에 따른 배터리 잔량은 비선형입니다. 그래서 단순히 최대에서 최소 전압을 퍼센트로 나누어서 표시하는 것은 정확성이 많이 떨어집니다.

정확도를 올리기 위해서는 제조사에서 제공하는 데이터시트 내 discharging 차트를 통해 확인 할 수 있습니다.

차트에 제공된 전압와 전류량 그래프를 확인하여 구간별 근사값으로 룩업 테이블을 만들어 사용하거나 차트 그래프에 fit되는 다차원 방정식 정의하여 계산하는 방식도 가능합니다.
실제로는 제조사 데이터시트를 구할 수가 없어서 Lipo Voltage Chart6에 있는 테이블을 참조했습니다.

정확도가 아주 높지는 않겠지만 배터리 잔량의 추세를 가늠할 수 있을것 입니다.

이후엔 ADC로 측정한 값을 전압으로 계산하고 다시 이 값을 위 테이블의 값을 비교하면서 인덱스를 찾아 인덱스를 퍼센트로 바꾸어 호스트에 전송하면 다음과 같이 배터리 잔량 정보를 표시할 수 있습니다.

Input Report 전송

Input report 전송은 디스크립터에서 선언한 버튼과 회전량(각도)를 전송하면 됩니다. 2바이크 크기로 최상위 1 비트가 버튼 눌림 여부이고 나머지 15비트가 signed 형으로 degree 값을 저장하여 전송을 해야합니다.

값 지정 편하게 할 수 있도록 비트필드 구조체를 선언하고 사용자 입력이 발생할 때 해당 값을 저장하고 ble_send_input_report 함수를 호출하여 배열로 전송을 할 수 있도록 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
typedef struct {
  unsigned button_pressed : 1;
  int16_t rotation : 15; 
} dial_report_t;

static void bsp_event_handler(bsp_event_t event) {
  switch (event) {
    ...
      case BSP_EVENT_KEY_0:
          if (m_conn_handle != BLE_CONN_HANDLE_INVALID) {
              dial_report_t dial_report = {0};
              dial_report.button_pressed = !nrf_gpio_pin_read(PRIMARY_BUTTON);                
              ble_send_input_report((uint8_t *)(&dial_report));
          }
          break;
    ...
  }
}

그 외

알 수 없는 원인으로 하드웨어가 정지되어 panic 상태 시 자동으로 리셋하기 위한 WDT(Watch Dog Timer) 설정과 배터리 전압이 너무 낮거나(0% 수치) 또는 배터리 소모를 줄이기 위해서 일정 시간동안(약 5분간) 입력이 없으면 강제 슬립 상태로 진입하는 등의 실제 사용 시 필요한 기능들이 있습니다.

이 기능들은 고려하지 않아도 구동에는 문제가 없지만 일단 문제가 발생하면 하드웨어 먹통이 되어 강제적인 리셋이 필요할 수 있습니다. 그리고 제때 충전하지 않아서 배터리가 완전 방전(3v 이하 전압)이 되면 배터리 수명이 급격히 나빠질 수 있으니 가급적 해당 기능을 고려하는 것이 좋습니다.

실제 작업에서는 이 부분을 적용하였지만 일단 여기서는 생략하고 포스팅을 마무리 합니다.

다음 포스팅에서는 하드웨어 제작과 실제 구동 관련하여 내용을 남기겠습니다.

… 다음 포스트에서 계속 …