프로젝트

나도 메이커! 메이커스 여러분들의 작품/프로젝트를 공유하는 공간입니다.

무선으로 LED를 제어하는 Cube만들기

2016-02-10 10:54:14

개요

 

시중 제품 중에 Cube Timer라는 제품이 있습니다. 아래와 같은 사진 모양인데요.

 

 

 

모양은 그냥 각 면에 숫자가 적혀있는 주사위 같이 생겼지만 이 제품은 타이머 알람의 기능을 수행합니다.

별도의 조작이 없이 그냥 윗면의 적혀진 숫자대로 타이머 기능을 수행합니다.

예를 들어 Cube의 윗면이 15min이 적혀있는 면일 경우에는 15분 뒤에 알람이 울리게 됩니다.

 

인터페이스가 상당히 단순하기 때문에 조작하기 쉽고 Fun한 요소까지 추가되어 있기 때문에 상당히 매력적인 제품입니다.

(국내에서는 Funshop에서 볼 수 있습니다. 링크 : http://www.funshop.co.kr/goods/detail/7014?t=s​)

 

이 제품을 보고 어쩌면 저런 큐브를 통해 LED를 제어해 볼 수 있지 않을까 라는 생각에 이번에는 큐브 모양의 컨트롤러를 만들어 보았습니다.

(사실 만든건 작년 2월에 만들었는데 이제서야 뭐 올릴거 없나 생각하다가 이것이 생각나서 올려봅니다!)
 

 

 

 

 

기능은 위에서 보여드린 CubeTimer와 동일합니다. 큐브의 윗면의 색깔대로 LED는 바뀌게 됩니다.

또한 큐브를 제자리에서 돌리게되면 밝기와 LED 픽셀이 점점 켜졌다가 꺼지는 효과도 있습니다.(무지개의 경우에는 LED가 무지개색깔대로 자연스럽게 변화하게 됩니다.)

 

 

동영상

 

 

 

 

 

필요한 사전 지식

 

 

1. 3축 가속도 센서

2. NeoPixel

3. Zigbee 사용하기

 

 

부품 목록

 

NO 부품명 수량 상세설명
1 오렌지 보드 1 UNO 호환보드
2 Zigbee쉴드 2  
3 Zigbee모듈 2 Series1
4 3축 가속도 센서 1 MPU-6050
5 LED Strip 1 Adafruit Noepixel
6 1.5V 배터리 4  
7 5V 어댑터 2 LEDStrip용, 오렌지 보드용
8 브레드 보드 1  
9 점퍼 케이블 약 10개  

 

부품명 오렌지 보드 Zigbee쉴드 Zigbee모듈 3축 가속도 센서  LEDStrip
파트
부품명 1.5V 배터리 5V어댑터 브레드 보드 점퍼 케이블  
파트  

 

 

하드웨어 making

 

 

LED큐브 내부 회로도

 

 

 

내부는 상당히 단순한 편입니다. 오렌지 보드와 1.5v 4개 배터리 홀더, 브레드 보드, 가속도 센서를 사용하여 구성하였습니다.

겉 표면은 아크릴로 주문 제작하여 CUBE를 제작하였습니다. 

 

 

LED Strip부 회로도

 

 

 

소프트웨어 coding

 

 

CUBE 소스

#include <math.h> // (no semicolon)
#include <Wire.h>

/* MPU-6050 sensor */
#define MPU6050_ACCEL_XOUT_H 0x3B // R
#define MPU6050_PWR_MGMT_1 0x6B // R/W
#define MPU6050_PWR_MGMT_2 0x6C // R/W
#define MPU6050_WHO_AM_I 0x75 // R
#define MPU6050_I2C_ADDRESS 0x68

/* Kalman filter */
struct GyroKalman{
	/* These variables represent our state matrix x */
	float x_angle, x_bias;
	
	/* Our error covariance matrix */
	float P_00, P_01, P_10, P_11;
	
	float Q_angle, Q_gyro;
	
	float R_angle;
};

struct GyroKalman angX;
struct GyroKalman angY;
struct GyroKalman angZ;

static const float R_angle = 0.3; 		//.3 default

static const float Q_angle = 0.01;	//0.01 (Kalman)
static const float Q_gyro = 0.04;	//0.04 (Kalman)

//These are the limits of the values I got out of the Nunchuk accelerometers (yours may vary).
const int lowX = -2150;
const int highX = 2210;
const int lowY = -2150;
const int highY = 2210;
const int lowZ = -2150;
const int highZ = 2550;

/* time */
unsigned long prevSensoredTime = 0;
unsigned long curSensoredTime = 0;

typedef union accel_t_gyro_union
{
	struct
	{
		uint8_t x_accel_h;
		uint8_t x_accel_l;
		uint8_t y_accel_h;
		uint8_t y_accel_l;
		uint8_t z_accel_h;
		uint8_t z_accel_l;
		uint8_t t_h;
		uint8_t t_l;
		uint8_t x_gyro_h;
		uint8_t x_gyro_l;
		uint8_t y_gyro_h;
		uint8_t y_gyro_l;
		uint8_t z_gyro_h;
		uint8_t z_gyro_l;
	} 
	reg;
	
	struct
	{
		int x_accel;
		int y_accel;
		int z_accel;
		int temperature;
		int x_gyro;
		int y_gyro;
		int z_gyro;
	} 
	value;
};

int xInit[5] = {
  0,0,0,0,0};
int yInit[5] = {
  0,0,0,0,0};
int zInit[5] = {
  0,0,0,0,0};
int initIndex = 0;
int initSize = 5;
int xCal = 0;
int yCal = 0;
int zCal = 1800;

int cnt = 0;
int valGx, valGy, valGz;
char transChar;
char volume;

void setup()
{
	int error;
	uint8_t c;
	
	initGyroKalman(&angX, Q_angle, Q_gyro, R_angle);
	initGyroKalman(&angY, Q_angle, Q_gyro, R_angle);
	initGyroKalman(&angZ, Q_angle, Q_gyro, R_angle);
	
	Serial.begin(9600);
	Wire.begin();
	
	// default at power-up:
	// Gyro at 250 degrees second
	// Acceleration at 2g
	// Clock source at internal 8MHz
	// The device is in sleep mode.
	//
	error = MPU6050_read (MPU6050_WHO_AM_I, &c, 1);
	Serial.print(F("WHO_AM_I : "));
	Serial.print(c,HEX);
	Serial.print(F(", error = "));
	Serial.println(error,DEC);
	
	// According to the datasheet, the 'sleep' bit
	// should read a '1'. But I read a '0'.
	// That bit has to be cleared, since the sensor
	// is in sleep mode at power-up. Even if the
	// bit reads '0'.
	error = MPU6050_read (MPU6050_PWR_MGMT_2, &c, 1);
	Serial.print(F("PWR_MGMT_2 : "));
	Serial.print(c,HEX);
	Serial.print(F(", error = "));
	Serial.println(error,DEC);
	
	// Clear the 'sleep' bit to start the sensor.
	MPU6050_write_reg (MPU6050_PWR_MGMT_1, 0);
}


void loop()
{
	int error;
	double dT;
	accel_t_gyro_union accel_t_gyro;
	
	curSensoredTime = millis();
	
	error = MPU6050_read (MPU6050_ACCEL_XOUT_H, (uint8_t *) &accel_t_gyro, sizeof(accel_t_gyro));
	if(error != 0) {
		Serial.print(F("Read accel, temp and gyro, error = "));
		Serial.println(error,DEC);
	}
	// Swap all high and low bytes.
	// After this, the registers values are swapped,
	// so the structure name like x_accel_l does no
	// longer contain the lower byte.
	uint8_t swap;
	#define SWAP(x,y) swap = x; x = y; y = swap
	SWAP (accel_t_gyro.reg.x_accel_h, accel_t_gyro.reg.x_accel_l);
	SWAP (accel_t_gyro.reg.y_accel_h, accel_t_gyro.reg.y_accel_l);
	SWAP (accel_t_gyro.reg.z_accel_h, accel_t_gyro.reg.z_accel_l);
	SWAP (accel_t_gyro.reg.t_h, accel_t_gyro.reg.t_l);
	SWAP (accel_t_gyro.reg.x_gyro_h, accel_t_gyro.reg.x_gyro_l);
	SWAP (accel_t_gyro.reg.y_gyro_h, accel_t_gyro.reg.y_gyro_l);
	SWAP (accel_t_gyro.reg.z_gyro_h, accel_t_gyro.reg.z_gyro_l);
	
	Serial.print(F("hh : "));
	Serial.print(accel_t_gyro.value.x_gyro, DEC);
	Serial.print(F(", "));
	Serial.print(accel_t_gyro.value.y_gyro, DEC);
	Serial.print(F(", "));
	Serial.print(accel_t_gyro.value.z_gyro, DEC);
	Serial.println(F(""));
	
	
	if(prevSensoredTime > 0) {
		int gx1=0, gy1=0, gz1 = 0;
		float gx2=0, gy2=0, gz2 = 0;
		
		int loopTime = curSensoredTime - prevSensoredTime;
		
		gx2 = angleInDegrees(lowX, highX, accel_t_gyro.value.x_gyro);
		gy2 = angleInDegrees(lowY, highY, accel_t_gyro.value.y_gyro);
		gz2 = angleInDegrees(lowZ, highZ, accel_t_gyro.value.z_gyro);
		
		predict(&angX, gx2, loopTime);
		predict(&angY, gy2, loopTime);
		predict(&angZ, gz2, loopTime);
		
		gx1 = update(&angX, accel_t_gyro.value.x_accel) / 10;
		gy1 = update(&angY, accel_t_gyro.value.y_accel) / 10;
		gz1 = update(&angZ, accel_t_gyro.value.z_accel) / 10;
		
		/////////////////////////////////////////////////////////////////////////////
		//  ���� ����� �� �����Ǵ� n���� ���� ��� => ���� �����Ǵ� ���� ����
		/////////////////////////////////////////////////////////////////////////////
		if(initIndex < initSize) {
			xInit[initIndex] = gx1;
			yInit[initIndex] = gy1;
			zInit[initIndex] = gz1;
			if(initIndex == initSize - 1) {
				int sumX = 0; 
				int sumY = 0; 
				int sumZ = 0;
				for(int k=1; k <= initSize; k++) {
					sumX += xInit[k];
					sumY += yInit[k];
					sumZ += zInit[k];
				}
				
				xCal -= sumX/(initSize -1);
				yCal -= sumY/(initSize -1);
				zCal = (sumZ/(initSize -1) - zCal);
			}
			initIndex++;
		}
		
		/////////////////////////////////////////////////////////////////////////////
		//  �������� ��� �ʿ��� �۾��� ó���ϴ� ��ƾ
		/////////////////////////////////////////////////////////////////////////////
		else {
			// ������ ����
			gx1 += xCal;
			gy1 += yCal;
		
		}
		
		if(cnt < 10) {
			cnt++;
		}
		else if(cnt == 10) {
			valGx = gx1;
			valGy = gy1;
			valGz = gz1;
			Serial.print("eeeee : ");
			Serial.print(valGx);
			Serial.print(F(", "));
			Serial.print(valGy);
			Serial.print(F(", "));
			Serial.print(valGz);
			Serial.println(F(""));
			cnt = 100;
		}
		Serial.print(gx1);
		Serial.print(F(", "));
		Serial.print(gy1);
		Serial.print(F(", "));
		Serial.print(gz1);
		Serial.println(F(""));
		if(abs(valGx-gx1) < 500 && abs(valGy-gy1) < 500 && abs(valGz-gz1) < 500) {
			transChar = 'a';
			Serial.print(transChar);
		}
		else if(abs(valGx-gx1-1500) < 500 && abs(valGy-gy1-1500) < 500 && abs(valGz-gz1) < 500) {
			transChar = 'r';
			Serial.print(transChar);
			if(accel_t_gyro.value.y_gyro > 3000) {
				volume = 'u';
				Serial.print(volume);
			}
			else if(accel_t_gyro.value.y_gyro < -3000) {
				volume = 'd';
				Serial.print(volume);
			}
			else {
				volume = 'n';
				Serial.print(volume);
			}
		}
		else if(abs(valGx-gx1-1500) < 500 && abs(valGy-gy1+1500) < 500 && abs(valGz-gz1) < 500)  {
			transChar = 'g';
			Serial.print(transChar);
			if(accel_t_gyro.value.y_gyro < -3000) {
				volume = 'u';
				Serial.print(volume);
			}
			else if(accel_t_gyro.value.y_gyro > 3000) {
				volume = 'd';
				Serial.print(volume);
			}
			else {
				volume = 'n';
				Serial.print(volume);
			}
		}
		else if(abs(valGx-gx1-3000) < 500 && abs(valGy-gy1) < 500 && abs(valGz-gz1) < 500) {
			transChar = 'b';
			Serial.print(transChar);
			if(accel_t_gyro.value.x_gyro > 3000) {
				volume = 'u';
				Serial.print(volume);
			}
			else if(accel_t_gyro.value.x_gyro < -3000) {
				volume = 'd';
				Serial.print(volume);
			}
			else {
				volume = 'n';
				Serial.print(volume);
			}
		}
		else if(abs(valGx-gx1-1500) < 500 && abs(valGy-gy1) < 500 && abs(valGz-gz1+1500) < 500) {
			transChar = 'x';
			Serial.print(transChar);
			if(accel_t_gyro.value.z_gyro < -3000) {
				volume = 'u';
				Serial.print(volume);
			}
			else if(accel_t_gyro.value.z_gyro > 3000) {
				volume = 'd';
				Serial.print(volume);
			}
			else {
				volume = 'n';
				Serial.print(volume);
			}
		}
		else if(abs(valGx-gx1-1500) < 500 && abs(valGy-gy1) < 500 && abs(valGz-gz1-1500) < 500) {
			transChar = 'y';
			Serial.print(transChar);
		}
		else if(abs(accel_t_gyro.value.x_gyro) > 5000 && abs(accel_t_gyro.value.y_gyro) > 5000 && abs(accel_t_gyro.value.z_gyro) > 5000) {
			transChar = 'z';
			Serial.print(transChar); 
		}
	}
	
	prevSensoredTime = curSensoredTime;  
	delay(300);

}	// End of loop()


/**************************************************
 * Sensor read/write
 **************************************************/
int MPU6050_read(int start, uint8_t *buffer, int size) {
	int i, n, error;
	
	Wire.beginTransmission(MPU6050_I2C_ADDRESS);
	
	n = Wire.write(start);
	if (n != 1)
	return (-10);
	
	n = Wire.endTransmission(false); // hold the I2C-bus
	if (n != 0)
	return (n);
	
	// Third parameter is true: relase I2C-bus after data is read.
	Wire.requestFrom(MPU6050_I2C_ADDRESS, size, true);
	i = 0;
	while(Wire.available() && i<size) {
		buffer[i++]=Wire.read();
	}
	if ( i != size)
	return (-11);
	return (0); // return : no error
}

int MPU6050_write(int start, const uint8_t *pData, int size) {
	int n, error;
	
	Wire.beginTransmission(MPU6050_I2C_ADDRESS);
	
	n = Wire.write(start); // write the start address
	if (n != 1)
	return (-20);
	
	n = Wire.write(pData, size); // write data bytes
	if (n != size)
	return (-21);
	
	error = Wire.endTransmission(true); // release the I2C-bus
	if (error != 0)
	return (error);
	return (0); // return : no error
}

int MPU6050_write_reg(int reg, uint8_t data) {
	int error;
	error = MPU6050_write(reg, &data, 1);
	return (error);
}

/**************************************************
 * Raw data processing
 **************************************************/
float angleInDegrees(int lo, int hi, int measured) {
	float x = (hi - lo)/180.0;
	return (float)measured/x;
}

void initGyroKalman(struct GyroKalman *kalman, const float Q_angle, const float Q_gyro, const float R_angle) {
	kalman->Q_angle = Q_angle;
	kalman->Q_gyro = Q_gyro;
	kalman->R_angle = R_angle;
	
	kalman->P_00 = 0;
	kalman->P_01 = 0;
	kalman->P_10 = 0;
	kalman->P_11 = 0;
}

/*
* The kalman predict method.
 * kalman 		the kalman data structure
 * dotAngle 		Derivitive Of The (D O T) Angle. This is the change in the angle from the gyro.
 * 					This is the value from the Wii MotionPlus, scaled to fast/slow.
 * dt 				the change in time, in seconds; in other words the amount of time it took to sweep dotAngle
 */
void predict(struct GyroKalman *kalman, float dotAngle, float dt) {
	kalman->x_angle += dt * (dotAngle - kalman->x_bias);
	kalman->P_00 += -1 * dt * (kalman->P_10 + kalman->P_01) + dt*dt * kalman->P_11 + kalman->Q_angle;
	kalman->P_01 += -1 * dt * kalman->P_11;
	kalman->P_10 += -1 * dt * kalman->P_11;
	kalman->P_11 += kalman->Q_gyro;
}

/*
* The kalman update method
 * kalman 	the kalman data structure
 * angle_m 	the angle observed from the Wii Nunchuk accelerometer, in radians
 */
float update(struct GyroKalman *kalman, float angle_m) {
	const float y = angle_m - kalman->x_angle;
	const float S = kalman->P_00 + kalman->R_angle;
	const float K_0 = kalman->P_00 / S;
	const float K_1 = kalman->P_10 / S;
	kalman->x_angle += K_0 * y;
	kalman->x_bias += K_1 * y;
	kalman->P_00 -= K_0 * kalman->P_00;
	kalman->P_01 -= K_0 * kalman->P_01;
	kalman->P_10 -= K_1 * kalman->P_00;
	kalman->P_11 -= K_1 * kalman->P_01;
	return kalman->x_angle;
}

 

 

 

 

 

LED부분 소스

#include <Adafruit_NeoPixel.h>

#define PIN 6

Adafruit_NeoPixel strip = Adafruit_NeoPixel(30, PIN, NEO_GRB + NEO_KHZ800);

char temp;
int rainbowChange = 255;
int bright = 255;
int index;
char ch;
char volumeChar;
char color; 
uint16_t numPixels = strip.numPixels();
uint16_t currentOnPixels;

void setup() {
	Serial.begin(9600);
	strip.begin();
	strip.show(); // Initialize all pixels to 'off'
}

void loop() {
	if(Serial.available()) {
		ch = Serial.read();
		Mode(ch, numPixels, strip.Color(0, 0, 0), strip.Color(255, 0, 0), strip.Color(0, 255, 0), strip.Color(0, 0, 255), strip.Color(255, 255, 0), strip.Color(127, 127, 127));
	} 
}

void volumeUpDown(char volumeChar) {
	if(volumeChar == 'u' && numPixels < strip.numPixels()) {
		numPixels += 4;
		if(temp == 'r') {
			colorWipe(strip.Color(255, 0, 0), 1, numPixels);
		}
		else if(temp =='b') {    
			colorWipe(strip.Color(0, 0, 255), 1, numPixels);
		}
	}
	else if(volumeChar == 'd' && numPixels-6 > 0) {
		numPixels -= 4;
		colorDelete(strip.Color(0, 0, 0), 1, numPixels);         
	}
}

void brightnessUpDown(char brtChar) {
	if(brtChar == 'u' && bright <= 225) {
		bright+=30;
		colorWipe(strip.Color(0, bright, 0), 1, numPixels);
	}
	else if(brtChar == 'd' && bright > 40) {
		bright -= 30;
		colorWipe(strip.Color(0, bright, 0), 1, numPixels);  
	}
}

void rainbowUpDown(char rainbowChar) {
	if(rainbowChar == 'u') {
		rainbowChange += 20;
		rainbowChange %= 255;
		rainbow(2, numPixels, rainbowChange);        
	}
	else if(rainbowChar == 'd' && rainbowChange > 25) {
		rainbowChange -= 20;
		rainbowChange %= 255;
		rainbow(2, numPixels, rainbowChange);  
	}
}

// Fill the dots one after the other with a color
void colorWipe(uint32_t c, uint8_t wait, uint16_t numPixels) {
	for(uint16_t i=0; i<=numPixels; i++) {
		strip.setPixelColor(i, c);
		strip.show();
		delay(wait);
	}
}

void colorDelete(uint32_t c, uint8_t wait, uint16_t numPixels) {
	for(uint16_t i=strip.numPixels(); i>numPixels; i--) {
		strip.setPixelColor(i, c);
		strip.show();
		delay(wait);
	}
}

void rainbow(uint8_t wait, uint16_t numPixels,uint16_t rainbowChange) {
	uint16_t i;
	for(i=0; i<=numPixels; i++) {
		strip.setPixelColor(i, Wheel((i+rainbowChange) & 255));
	}
	strip.show();
	delay(wait);
}

//Theatre-style crawling lights.
void theaterChase(uint32_t c, uint8_t wait, uint16_t numPixels) {
	for (int rainbowChange=0; rainbowChange<10; rainbowChange++) {  
		for (int q=0; q < 3; q++) {
			for (int i=0; i <= numPixels; i=i+3) {
				strip.setPixelColor(i+q, c);  
			}
			strip.show();
			
			delay(wait);
			
			for (int i=0; i <= numPixels; i=i+3) {
				strip.setPixelColor(i+q, 0); 
			}
		}
	}
}

uint32_t Wheel(byte WheelPos) {
	if(WheelPos < 85) {
		return strip.Color(WheelPos * 3, 255 - WheelPos * 3, 0);
	} 
	else if(WheelPos < 170) {
		WheelPos -= 85;
		return strip.Color(255 - WheelPos * 3, 0, WheelPos * 3);
	} 
	else {
		WheelPos -= 170;
		return strip.Color(0, WheelPos * 3, 255 - WheelPos * 3);
	}
}

void Mode(char ch, uint16_t numPixels, uint32_t a, uint32_t b, uint32_t c, uint32_t d, uint32_t e, uint32_t f) {
	if(ch == 'a') { 
		colorWipe(a, 15, numPixels); //OFF
	}
	else if(ch == 'r') { 
		colorWipe(b, 15, numPixels); // Red
		temp = 'r';
		while(Serial.available() <=0); 
		volumeChar = Serial.read(); 
		volumeUpDown(volumeChar);
	}
	else if(ch == 'g') {
		colorWipe(strip.Color(0, bright, 0), 15, numPixels); // Green
		while(Serial.available() <=0); 
		volumeChar = Serial.read();
		brightnessUpDown(volumeChar);
	}
	else if(ch == 'b') {
		colorWipe(d, 15, numPixels); // Blue
		temp = 'b';
		while(Serial.available() <=0);  
		volumeChar = Serial.read(); 
		volumeUpDown(volumeChar);
	}
	
	else if(ch == 'x') {  //rainbow
		while(Serial.available() <=0); 
		volumeChar = Serial.read(); 
		rainbowUpDown(volumeChar);
	}
	else if(ch == 'y') { 
		theaterChase(f, 15, numPixels); //Starlight
	}
}

 

 

 

 

소스의 경우 상당히 길어 보이는데 기능은 사실 간단합니다.

 

큐브의 소스에서는 가속도 센서를 이용하여 큐브의 윗면이 무엇인지 파악하게 됩니다.

다만 가속도 센서의 값을 그대로 사용할 경우 값이 불안정하기 때문에 Kalman-Filter를 이용하여 안정된 값을 얻습니다.(필터로 인해 소스가 길어지는 듯 합니다.)

X값, Y값, Z값 3개를 비교하여 6개의 면을 구분합니다.

 

LED 부분의 소스에서는 큐브에서 Zigbee를 통해 날라오는 데이터 값으로 LED를 제어하게 되는데 

'r'이라는 데이터가 날라올 경우 빨간색으로 LED를 켜고

'g'가 날라오면 녹색으로 켜고

'b'가 날라오면 파란색으로 키는 간단한 수준의 통신입니다.

 

Zigbee를 사용할 경우 Serial.print()만으로도 데이터 전송이 가능하기 때문에 아두이농 Serial통신을 하실 수 있다면 쉽게 통신을 만들 수 있습니다.

 

 

 

녹색 켜기 

 

 

 

빨강 켜기

 

 

 

무지개색 조절하기

 

수박쨈

오렌지 보드, 아두이노, LED, Zigbee, Controller
profile

수박쨈 2016-02-12 11:08:44

참고로 큐브 거리 테스트를 해봤는데 Zigbee통신을 사용해서 그런지 100m 정도의 거리에서도 무선 통신이 가능합니다.

profile

Hydragon 2016-02-12 21:41:47

좋은 정보 감사합니다 ^^

profile

김정우 2016-02-20 01:31:05

이야 멋진 프로젝트하셨네요.

profile

구본휘 2016-02-20 02:32:10

재미있는 프로젝트였어요 좋은 정보 감사합니다.

profile

박수경 2016-02-25 16:38:44

오 멋진데요~~

profile

장세현 2016-03-15 19:37:03

좋은정보감사합니다.

profile

김흥식 2016-07-20 15:06:32

최근 가장 큰 도움이 되었네요

profile

정상훈 2016-10-11 15:14:31

좋은 정보 감사합니다

profile

김성훈 2017-02-13 14:59:22

오렌지보드 대신에 아두이노 우도 보드를 사용해도 되나요?