티스토리 뷰

반응형

집앞에 어슬렁거리는 빈집 털이범이나, 여자들만 사는 곳을 노리는 범죄를 예방하기위해 각 아파트마다 CCTV가 설치되어 있다. 다만 그런 CCTV의 경우 자신의 집앞을 정확하게 비쳐줄지모르고 규모가 작은 빌라의 경우 없는 경우도 많다. 집안의 인터폰이 있어서 초인종을 누르면 카메라를 통해서 문앞에 영사을 확인할 수 있지만 초인종을 누르지 않을경우 누가 앞에서 어슬렁 거리는지 알수가 없게된다. Raspberry를 이용해서 이러한 범죄를 예방할 수 있는 가정용 CCTV를 제작하기로 하고 아래와 같은 기능을 갖은 프로젝트를 기획하였다.

<시나리오>

문 앞의 누군가가 감지되었을때, 카메라를 통해서 촬영하고 낯선 사람일 경우 클라우드로 전송한다.

세부적으로 나뉘어 보면, 

1) 인체 감지센서를 통해 문 앞의 사람 인식

2) 인식되었을때 사진 촬영 및 저장

3) 낯선 사람일 경우 클라우드로 전송

현재 가지고 있는 부품으로는 인체감지 센서는 있으나 Windows 10 IoT Core에서 지원하는 카메라를 보유하고 있지 않기 때문에, 테스트 목적상 카메라를 구입하기 전까지, 인체감지 센서로 감지되었을 때, 16X2 LCD 에 Display 해주는 것을 1차적 목표로 삼았다.

#인체 감지 센서, PIR(Passive Infrared Sensor)

인체는 36.5도의 열을 가지고 있으므로 적외선이 나온다. 이것을 감지하여 인체의 움직임이 감지되면 신호를 보내주는 센서인데 품번은 HC-SR501을 구매하였다.(https://www.eleparts.co.kr/EPXF9YKT), 주로 아파트 복도에서 자동으로 불이 켜지도록 하는게 이와같은 센서를 이용해서 가능하다. 

핀연결은 위의 사진과 같이 GND, Signal, VCC(5V) 이며 Raspberry의 GPIO 핀에 Output 을 연결하면 된다. 또한 가변저항이 있어서 감도조절과 딜레이 시간을 조절할 수 있는데, 우선 초기설정값대로 두고 진행한다. 센서의 전면부는 축구공 모양은 편광필터고 이 안에 적외선 감지 센서가 들어있다. 편광필터는 센서가 감지하는 주파수 범위만 통과시키도록 하여 센서의 성능을 높이는 역할을 한다.

RaspberryPI의 GPIO 5번에 연결하고 GND와 VCC(5V)를 연결하여 회로 구성을 마치고 코드 구성을 해본다. 코드의 경우 GPIO pin을 사용하므로 windows.devices.Gpio 를 using 하며 폴링구조가 아닌 Interrupt 구조로 인체 감지되는 event를 받도록 구성한다. Interrupt에 의한 GPIO 제어하기는 여기를 참조하자.(http://hero-space.tistory.com/28)

본격적으로 코드를 작성해본다.

private GpioPin pirSensorPin;
private GpioPinEdge pirSensorEdge;
public event EventHandler< gpiopinvaluechangedeventargs > MotionDetected;
private const int PIR_PIN = 5;

여기서 EventHandler를 하나 생성해서 등록해 놓는데, 최초 App이 실행될때, Event가 발생할때 호출되도록 등록해 놓아야 한다.

private void PirSensor(int sensorPin)
{
     Debug.WriteLine("Init PirSensor");
     var gpioController = GpioController.GetDefault();
     if (gpioController != null)
     {
          pirSensorEdge = GpioPinEdge.RisingEdge;
          pirSensorPin = gpioController.OpenPin(sensorPin);
          pirSensorPin.SetDriveMode(GpioPinDriveMode.Input);
          pirSensorPin.ValueChanged += PirSensorPin_ValueChanged;
          MotionDetected += MainPage_MotionDetected;
     }
     else
     {
          Debug.WriteLine("Error: GPIO controller not found.");
     }
}

GpioPin 객체를 하나 생성하고 Edge에 따라 변경될때 발생되도록 지원하는 ValueChanged에 PirSensorPin_ValueChanged를 등록해 놓는다. 또한 선언해놓은 Event Handler에도 호출시 연동할 Function을 등록해 놓는다.

private void PirSensorPin_ValueChanged(GpioPin sender, GpioPinValueChangedEventArgs args)
{
      if (MotionDetected != null && args.Edge == pirSensorEdge)
      {
          Debug.WriteLine("Call PinSensorPin_ValueChanged");
          MotionDetected(this, args);
      }
}

private void MainPage_MotionDetected(object sender, GpioPinValueChangedEventArgs e)
{
    var currentPinValue = pirSensorPin.Read();
    Debug.WriteLine(currentPinValue);
}

자 이제 기본적인 코드 구성은 끝났다. MainPage에서 PirSensor(PIR_PIN); 를 호출하여 PIR 센서를 init 한다. 해당 모델은 인체 감지되는 경우 High 값이 됨을 알 수 있다. 동영상으로 확인해보자.

Application을 실행하고 나서, 센서앞을 손으로 왔다갔다 하는 경우 Detected 라고 출력되는 걸 알 수 있다. 약간의 딜레이가 필요한것 같긴하나, 우선은 현재 동작상태를 유지해서 감지됬을 경우 !6X2 LCD에 'Detected' 라고 하고, 다시 감지되지 않으면 'Nobody' 라고 LCD에 출력하는 것을 해볼 것이다.

#16X2 LCD

LCD는 활용할 부분이 많을 것 같아서구매해두었는데 구매는 이곳에서 하였다.(https://www.eleparts.co.kr/EPX4P4YG).

이 LCD의 경우 16X2 사이즈고 Data 라인은 8bit로 되어있는데 8bit 모드로도 할 수 있고 4bit 모드로도 설정할 수 있다. 우선 MS iot page에 16X2 LCD의 예제를 보면 4bit 모드로 사용하도록 회로구성 및 코드구성이 되어있어서 동일하게 구성해보았다. 전원이 제대로 인가됬다면 LCD의 backlight가 나옴을 알 수 있을 것이다. 회로도 구성은 MS에서 제공하는 이 곳의 예제를 확인해본다.(https://developer.microsoft.com/en-us/windows/iot/samples/arduino-wiring/lcdtextdisplay). 예제의 경우 아두이노에서 제공하는 라이브러리를 활용할 수 있도록 wiring 프로젝트로 생성하고 있는데 uwp 프로제트로 만들되 LCD 제어에 필요한 라이브러리가 있다면 복사해 오거나 참조 추가하면된다. 이와 같은 LCD의 경우 손으로 직접 찾아서 넣어야하니 복잡한 감이 없지 않다. 하드웨어 연결만 참조하고 코드는 따로 이어서 설명하겠다. 샘플문서에서는 가변저항을 연결하는 부분이 있는데 가변저항의 역할은 LCD Back light의 contrast 에 해당하므로 가변저항은 없을 경우 220옴 정도의 저항을 연결하면 글자가 LCD위에 쓰여짐을 알 수 있다. 

위 연결그림을 보면 중간에 4가지 선은 연결하지 않았다. 4bit 모드로 동작하는 것이며 코드를 구성해보자. 우선 LCD.cs를 생성해서 LCD Class를 구성하는데 아래 소스를 사용해서 MainPage가 이용하도록 구현한다.

using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Windows.Devices.Gpio;

namespace LCD_Display
{
    class LCD
    {
        private String cleanline = "";
        // commands
        private const int LCD_CLEARDISPLAY = 0x01;
        private const int LCD_RETURNHOME = 0x02;
        private const int LCD_ENTRYMODESET = 0x04;
        private const int LCDDisplayControl = 0x08;
        private const int LCD_CURSORSHIFT = 0x10;
        private const int LCD_FUNCTIONSET = 0x20;
        private const int LCD_SETCGRAMADDR = 0x40;
        private const int LCD_SETDDRAMADDR = 0x80;

        // flags for display entry mode
        private const int LCD_ENTRYRIGHT = 0x00;
        private const int LCD_ENTRYLEFT = 0x02;
        private const int LCD_ENTRYSHIFTINCREMENT = 0x01;
        private const int LCD_ENTRYSHIFTDECREMENT = 0x00;

        // flags for display on/off control
        private const int LCD_DISPLAYON = 0x04;
        private const int LCD_DISPLAYOFF = 0x00;
        private const int LCD_CURSORON = 0x02;
        private const int LCD_CURSOROFF = 0x00;
        private const int LCD_BLINKON = 0x01;
        private const int LCD_BLINKOFF = 0x00;

        // flags for display/cursor shift
        private const int LCD_DISPLAYMOVE = 0x08;
        private const int LCD_CURSORMOVE = 0x00;
        private const int LCD_MOVERIGHT = 0x04;
        private const int LCD_MOVELEFT = 0x00;

        // flags for function set
        private const int LCD_8BITMODE = 0x10;
        private const int LCD_4BITMODE = 0x00;
        private const int LCD_2LINE = 0x08;
        private const int LCD_1LINE = 0x00;
        public const int LCD_5x10DOTS = 0x04;
        public const int LCD_5x8DOTS = 0x00;

        private GpioController controller = GpioController.GetDefault();
        private GpioPin[] DPin = new GpioPin[8];
        private GpioPin RsPin = null;
        private GpioPin EnPin = null;

        private int DisplayFunction = 0;
        private int DisplayControl = 0;
        private int DisplayMode = 0;

        private int _cols = 0;
        private int _rows = 0;
        private int _currentrow = 0;

        private String[] buffer = null;

        public bool AutoScroll = false;


       // [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public void delayMicroseconds(int uS)
        {
            if (uS > 2000)
                throw new Exception("Invalid param, use Task.Delay for 2ms and more");

            if (uS < 100) //call takes more time than 100uS 
                return;

            var tick_to_reach = System.DateTime.UtcNow.Ticks + uS * 1000; //1GHz Raspi2 Clock
            while (System.DateTime.UtcNow.Ticks < tick_to_reach)
            {
            }

        }

        public LCD(int cols, int rows, int charsize = LCD_5x8DOTS)
        {
            _cols = cols;
            _rows = rows;

            buffer = new String[rows];

            for (int i = 0; i < cols; i++)
            {
                cleanline = cleanline + " ";
            }

            DisplayFunction = charsize;
            if (_rows > 1)
                DisplayFunction = DisplayFunction | LCD_2LINE;
            else
                DisplayFunction = DisplayFunction | LCD_1LINE;
        }

        private async Task begin()
        {
            await Task.Delay(50);
            // Now we pull both RS and R/W low to begin commands
            RsPin.Write(GpioPinValue.Low);
            EnPin.Write(GpioPinValue.Low);

            //put the LCD into 4 bit or 8 bit mode
            if ((DisplayFunction & LCD_8BITMODE) != LCD_8BITMODE)
            {
                // we start in 8bit mode, try to set 4 bit mode
                write4bits(0x03);
                await Task.Delay(5); // wait min 4.1ms

                // second try
                write4bits(0x03);
                await Task.Delay(5); // wait min 4.1ms

                // third go!
                write4bits(0x03);
                delayMicroseconds(150);

                // finally, set to 4-bit interface
                write4bits(0x02);
            }
            else
            {
                // Send function set command sequence
                command((byte)(LCD_FUNCTIONSET | DisplayFunction));
                await Task.Delay(5); // wait min 4.1ms

                // second try
                command((byte)(LCD_FUNCTIONSET | DisplayFunction));
                delayMicroseconds(150);

                // third go
                command((byte)(LCD_FUNCTIONSET | DisplayFunction));
            }

            command((byte)(LCD_FUNCTIONSET | DisplayFunction));
            DisplayControl = LCD_DISPLAYON | LCD_CURSOROFF | LCD_BLINKOFF;
            DisplayOn();

            await clearAsync();

            DisplayMode = LCD_ENTRYLEFT | LCD_ENTRYSHIFTDECREMENT;
            command((byte)(LCD_ENTRYMODESET | DisplayMode));

        }

        public async Task< bool > InitAsync(int rs, int enable, int d4, int d5, int d6, int d7)
        {
            try
            {
                DisplayFunction = DisplayFunction | LCD_4BITMODE;
                
                RsPin = controller.OpenPin(rs);
                RsPin.SetDriveMode(GpioPinDriveMode.Output);

                EnPin = controller.OpenPin(enable);
                EnPin.SetDriveMode(GpioPinDriveMode.Output);

                DPin[0] = controller.OpenPin(d4);
                DPin[0].SetDriveMode(GpioPinDriveMode.Output);

                DPin[1] = controller.OpenPin(d5);
                DPin[1].SetDriveMode(GpioPinDriveMode.Output);

                DPin[2] = controller.OpenPin(d6);
                DPin[2].SetDriveMode(GpioPinDriveMode.Output);

                DPin[3] = controller.OpenPin(d7);
                DPin[3].SetDriveMode(GpioPinDriveMode.Output);

                await begin();

                return true;

            }
            catch (Exception)
            {
                return false;
            }

        }

        private void pulseEnable()
        {
            EnPin.Write(GpioPinValue.Low);
            // delayMicroseconds(1);
            EnPin.Write(GpioPinValue.High);
            //delayMicroseconds(1);
            EnPin.Write(GpioPinValue.Low);
            //delayMicroseconds(50);

        }

        private void write4bits(byte value)
        {
            //String s = "Value :"+value.ToString();
            for (int i = 0; i < 4; i++)
            {
                var val = (GpioPinValue)((value >> i) & 0x01);
                DPin[i].Write(val);
                //  s = DPin[i].PinNumber.ToString()+" /"+ i.ToString() + "=" + val.ToString() + "  " + s;
            }
            //Debug.WriteLine(s);

            pulseEnable();
        }


        private void write8bits(byte value)
        {
            for (int i = 0; i < 8; i++)
            {
                var val = (GpioPinValue)((value >> 1) & 0x01);
                DPin[i].Write(val);
            }

            pulseEnable();
        }

        private void send(byte value, GpioPinValue bit8mode)
        {
            Debug.WriteLine("send :"+value.ToString());

            RsPin.Write(bit8mode);

            if ((DisplayFunction & LCD_8BITMODE) == LCD_8BITMODE)
            {
                write8bits(value);
            }
            else
            {
                byte B = (byte)((value >> 4) & 0x0F);
                write4bits(B);
                B = (byte)(value & 0x0F);
                write4bits(B);
            }
        }

        private void write(byte value)
        {
            send(value, GpioPinValue.High);
        }

        private void command(byte value)
        {
            send(value, GpioPinValue.Low);
        }

        public async Task clearAsync()
        {
            command(LCD_CLEARDISPLAY);
            await Task.Delay(2);

            for (int i = 0; i < _rows; i++)
            {
                buffer[i] = "";
            }

            _currentrow = 0;

            await homeAsync();
        }

        public async Task homeAsync()
        {
            command(LCD_RETURNHOME);
            await Task.Delay(2);
        }

        public void write(String text)
        {
            var data = Encoding.UTF8.GetBytes(text);

            foreach (byte Ch in data)
            {
                write(Ch);
            }
        }

        public void setCursor(byte col, byte row)
        {
            var row_offsets = new int[] { 0x00, 0x40, 0x14, 0x54 };

            /*if (row >= _numlines)
            {
                row = _numlines - 1;    // we count rows starting w/0
            }
            */

            command((byte)(LCD_SETDDRAMADDR | (col + row_offsets[row])));
        }

        // Turn the display on/off (quickly)
        public void DisplayOff()
        {
            DisplayControl &= ~LCD_DISPLAYON;
            command((byte)(LCDDisplayControl | DisplayControl));
        }
        public void DisplayOn()
        {
            DisplayControl |= LCD_DISPLAYON;
            command((byte)(LCDDisplayControl | DisplayControl));
        }

        // Turns the underline cursor on/off
        public void noCursor()
        {
            DisplayControl &= ~LCD_CURSORON;
            command((byte)(LCDDisplayControl | DisplayControl));
        }
        public void cursor()
        {
            DisplayControl |= LCD_CURSORON;
            command((byte)(LCDDisplayControl | DisplayControl));
        }

        // Turn on and off the blinking cursor
        public void noBlink()
        {
            DisplayControl &= ~LCD_BLINKON;
            command((byte)(LCDDisplayControl | DisplayControl));
        }
        public void blink()
        {
            DisplayControl |= LCD_BLINKON;
            command((byte)(LCDDisplayControl | DisplayControl));
        }

        // These commands scroll the display without changing the RAM
        public void scrollDisplayLeft()
        {
            command(LCD_CURSORSHIFT | LCD_DISPLAYMOVE | LCD_MOVELEFT);
        }
        public void scrollDisplayRight()
        {
            command(LCD_CURSORSHIFT | LCD_DISPLAYMOVE | LCD_MOVERIGHT);
        }

        // This is for text that flows Left to Right
        public void leftToRight()
        {
            DisplayMode |= LCD_ENTRYLEFT;
            command((byte)(LCD_ENTRYMODESET | DisplayMode));
        }

        // This is for text that flows Right to Left
        public void rightToLeft()
        {
            DisplayMode &= ~LCD_ENTRYLEFT;
            command((byte)(LCD_ENTRYMODESET | DisplayMode));
        }

        // This will 'right justify' text from the cursor
        public void autoscroll()
        {
            DisplayMode |= LCD_ENTRYSHIFTINCREMENT;
            command((byte)(LCD_ENTRYMODESET | DisplayMode));
        }

        // This will 'left justify' text from the cursor
        public void noAutoscroll()
        {
            DisplayMode &= ~LCD_ENTRYSHIFTINCREMENT;
            command((byte)(LCD_ENTRYMODESET | DisplayMode));
        }

        // Allows us to fill the first 8 CGRAM locations
        // with custom characters
        public void createChar(byte location, byte[] charmap)
        {
            location &= 0x7; // we only have 8 locations 0-7
            command((byte)(LCD_SETCGRAMADDR | (location << 3)));
            for (int i = 0; i < 8; i++)
            {
                write(charmap[i]);
            }
        }

        public void WriteLine(string Text)
        {
            if (_currentrow >= _rows)
            {
                //let's do shift
                for (int i = 1; i < _rows; i++)
                {
                    buffer[i - 1] = buffer[i];
                    setCursor(0, (byte)(i - 1));
                    write(buffer[i - 1].Substring(0, _cols));
                }
                _currentrow = _rows - 1;
            }
            buffer[_currentrow] = Text + cleanline;
            setCursor(0, (byte)_currentrow);
            var cuts = buffer[_currentrow].Substring(0, _cols);
            write(cuts);
            _currentrow++;
        }
    }
}
    public sealed partial class MainPage : Page
    {
        LCD lcd;

        public MainPage()
        {
            this.InitializeComponent();
            initLCD();
        }

        public string CurrentComputerName()
        {
            var hostNames = NetworkInformation.GetHostNames();
            var localName = hostNames.FirstOrDefault(name => name.DisplayName.Contains(".local"));
            return localName.DisplayName.Replace(".local", "");
        }

        public string CurrentIPAddress()
        {
            var icp = NetworkInformation.GetInternetConnectionProfile();

            if (icp != null && icp.NetworkAdapter != null)
            {
                var hostname =
                    NetworkInformation.GetHostNames()
                        .SingleOrDefault(
                            hn =>
                            hn.IPInformation != null && hn.IPInformation.NetworkAdapter != null
                            && hn.IPInformation.NetworkAdapter.NetworkAdapterId
                            == icp.NetworkAdapter.NetworkAdapterId);

                if (hostname != null)
                {
                    // the ip address
                    return hostname.CanonicalName;
                }
            }

            return string.Empty;
        }

        private async void initLCD()
        {
            lcd = new LCD(16, 2);
            await lcd.InitAsync(20, 16, 2, 3, 4, 17);
            await lcd.clearAsync();
        }

        private void button_Click(object sender, RoutedEventArgs e)
        {
            lcd.WriteLine(textBox.Text);
        }

        private async void button1_Click(object sender, RoutedEventArgs e)
        {
            await lcd.clearAsync();
            lcd.write("Windows 10 IoT Core ");
            //lcd.setCursor(0, 1);
            //lcd.write("IP:" + CurrentIPAddress());
            //lcd.setCursor(0, 2);
            //lcd.write("Name:" + CurrentComputerName());
            while (true)
            {
                lcd.setCursor(0, 1);
                lcd.write("Time :" + DateTime.Now.ToString("HH:mm:ss.ffff"));
            }
        }
    }

그럼 실제로 동작시켜보자, button1을 눌러서 string과 시간을 출력하도록 한다.

LCD의 핀이 많다보니 연결하는게 굉장히 복잡하다.

정상적인 코드임에도 잠시 LCD에 안나왔던 적도 있는데, 가변저항 꽂는 핀을 VCC 5V로 연결해서 Max Contrast로 설정되었기 때문이었다. 220옴 저항을 하나 연결하니 출력됨을 알수있다.

#인체가 감지될때 'Detected', 감지되지 않으면 'Nobody'

카메라를 구입하게 되면 사진을 찍는 기능을 연동하겠지만 우선 분리해서 인체가 감지되는 것을 Trigger로 놓고 LCD에 정해놓은 String을 조건에 따라서 출력해준다.

using System;
using System.Diagnostics;
using Windows.UI.Xaml.Controls;
using Windows.Devices.Gpio;

namespace PIR_LCD_Application
{
    /// 
    /// 자체적으로 사용하거나 프레임 내에서 탐색할 수 있는 빈 페이지입니다.
    /// 
    public sealed partial class MainPage : Page
    {
        LCD lcd;
        private GpioPin pirSensorPin;
        private GpioPinEdge pirSensorEdge;
        public event EventHandler< gpiopinvaluechangedeventargs > MotionDetected;
        private const int PIR_PIN = 5;

        public MainPage()
        {
            this.InitializeComponent();
            initLCD();
            PirSensor(PIR_PIN);

            Unloaded += MainPage_Unloaded;
        }

        private async void initLCD()
        {
            lcd = new LCD(16, 2);
            await lcd.InitAsync(20, 16, 2, 3, 4, 17); //16X2 LCD and works on 4bits
            await lcd.clearAsync();
        }

        private void PirSensor(int sensorPin)
        {
            Debug.WriteLine("Init PirSensor");
            var gpioController = GpioController.GetDefault();
            if (gpioController != null)
            {
                pirSensorEdge = GpioPinEdge.RisingEdge;
                pirSensorPin = gpioController.OpenPin(sensorPin);
                pirSensorPin.SetDriveMode(GpioPinDriveMode.Input);
                pirSensorPin.ValueChanged += PirSensorPin_ValueChanged;
                MotionDetected += MainPage_MotionDetected;
            }
            else
            {
                Debug.WriteLine("Error: GPIO controller not found.");
            }
        }

        private void MainPage_MotionDetected(object sender, GpioPinValueChangedEventArgs e)
        {
            var currentPinValue = pirSensorPin.Read();

            Debug.WriteLine("current value : " + currentPinValue);
            Debug.WriteLine("Detected");

            var task = this.Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () =>
            {
                textBox.Text = "Detected";

            });
            lcd.WriteLine(" ");
            lcd.WriteLine("Detected");

        }

        private void PirSensorPin_ValueChanged(GpioPin sender, GpioPinValueChangedEventArgs args)
        {
            if (MotionDetected != null && args.Edge == pirSensorEdge)
            {
                Debug.WriteLine("Call PinSensorPin_ValueChanged");
                MotionDetected(this, args);
            }
            else
            {
                Debug.WriteLine("Nobody");
                var task = this.Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal, () =>
                {
                    textBox.Text = "Nobody";

                });
                lcd.WriteLine(" ");
                lcd.WriteLine("Nobody");

            }
        }

        public void Dispose()
        {
            pirSensorPin.Dispose();
        }
        private void MainPage_Unloaded(object sender, object args)
        {
            // Cleanup
            pirSensorPin.ValueChanged -= PirSensorPin_ValueChanged;
            Dispose();
        }
    }
}

한가지 포인트는 Gpio Pin의 Value가 바뀔 때마다 PirSensorPin_ValueChanged 가 호출되게 되며 호출시 argument의 edge가 high로 설정했기에 high가 됬다면 detected 됫다고 보고 LCD에 write를 해주고, low라면 nobody로 lcd에 write 해준다. 또한 UI에도 보여주기 위해서 Text box를 하나 만들어서 Text Box에도 Write해준다.

자, 이제 최종 동영상을 확인해보자.

민감도의 설정은 필요해보이지만 기본적인 시나리오를 구성하기에 충분해보인다. 이제는 카메라를 연동해야하는데, 우선 오늘은 여기까지~!

반응형
댓글