홈 IoT 제작 – 대중교통편 (3) 구현

글쓴이 Engineer Myoa 날짜

(이전글 홈 IoT 제작 – 대중교통편 (2) 설계)

휴가를 다녀오느라 구현과 이후 편들에 대해 작성을 하지 못했다..

가야할 길이 멀고도 멀어 얼른얼른 진행해야 했다.

 

구현

버튼 하나로 버스, 지하철 모드를 바꿀 수 있게 설계하였고 각 모드에서 페이지네이션이 필요할 경우 타이머를 통해 페이지를 나타낼 수 있도록 구현하였다.

어느정도 코드가 완성되고나서 타임 드라이븐으로 리팩토링하였는데 문제는 이전 코드를 보관해두지 않아서 초기 코드가 없다는 것이다..

물론 가독성도 좋지 않겠으나 집에서 시작해보고자 하는 사람에게는 삽질의 흔적과 최적화 되지않은 무식한 코드가 공부하기에있어 최고의 코드인데..

 

아무튼 파트는 2파트로 나뉜다.

1) 데이터를 가공해서 보내줄 시리얼 통신 서버,

2) 데이터를 받아서 사용할 시리얼 통신 클라이언트(아두이노같은 MCU보드)

 

1) 시리얼 통신 서버

서버에서는 설계에서 진행했던 전철데이터베이스 레코드 계산과 API로부터 버스도착정보를 게더링하여 가공후 클라이언트로 전송한다.

필자는 파이썬으로 구현하였으며 최소한 pySerial이라는 라이브러리가 필요하다.

class SerialManager:
    def __init__(self):
        self.s = None
        # self.serialOpen()

        pass

    def serialOpen(self, port=DEFAULT_PORT, baudRate=DEFAULT_BAUD_RATE):
        self.s = serial.Serial(port, baudRate)
        # return s

    def sendData(self, data):
        if (type(data) == str):
            self.s.write(data.encode())
        elif (type(data) == int):
            self.s.write(bytes(bytearray([data])))

    def serialClose(self):
        self.s.close()


s = SerialManager()

위 처럼 클래스화를 하지 않아도 된다. 필자는 테스트를 위해 자주 시리얼을 열었다 닫았다하니 너무 불편하였기에 따로 클래스로 만들어 사용하였다.

중간에 sendData가 핵심인데, 시리얼통신으로 데이터를 전송하려면 byte로 타입캐스팅을 해야한다.  그래야 수신측에서 serial로 데이터를 읽을 수 있다.

또한  문자열과 정수일때의 핸들링을 달리하여 처리할 수 있도록 하자. 문자열일 경우 encode만 하면 편하게 바이트로 변하지만 정수(들)의 경우 bytearray 객체로 바꾸고, bytes로 변환해서 전송하면 된다.

굳이 이렇게 한 이유는 000000을 str로 형변환하면 “0”이 되기 때문이다.

 

 

이 파트를 제외하고 나머지 데이터 수집은 파이썬 requests, sqlalchemy, sqlite등을 이용해 수집 및 가공할 수 있도록 하자.

첨언으로 나의 경우는 6자리의 문자열을 정하여서 [ 모드 2자리 ] + [ 인덱스 2자리 ] + [ 값 2자리 ] 의 형식을 사용하였다. 인덱스는 MCU보드의 배열 인덱스와 일치해야하기 때문에 똑같은 순서로 정의된 구조체배열이 필요하다. (파이썬에서는 아래처럼 dict로 비슷하게 구현하고 순서정보를 따로 추가하자)

busIndex = {
    "300": {"idx": "00", "needAdj": False},
    "301": {"idx": "01", "needAdj": False},
    "2": {"idx": "02", "needAdj": False},
    "2-2": {"idx": "03", "needAdj": False},
    "116-1": {"idx": "04", "needAdj": False},
    "116-2": {"idx": "05", "needAdj": False},
    "5300": {"idx": "06", "needAdj": True},
    "M5532": {"idx": "07", "needAdj": True}
}

수신측에서도 데이터에 대한 고도의 파싱작업이 필요없어서 매우 편리했다.

 

2) 시리얼 통신 클라이언트

사실 MCU보드에 대한 특성 이해도 필요하고 나는 최소한의 지식으로 넘길 수 있도록 서버에서 최대한 가공하고 클라이언트에서 편하게 파싱할 수 있도록 하였다.

나는 타이버 인터럽트를 활용한 타임드라이븐 loop문을 사용하였다.

먼저 반복문과 핸들러를 구현하기 전에 필요한 상수부터 정의한다.

ㅇ#include <Event.h>
#include <Timer.h> // 타임 드라이븐을 위한 타이머

#include <Wire.h>
#include <LiquidCrystal_I2C.h> // LCD1604 I2C 라이브러리

#define MAX_STREAM_BUFFER_SIZE 6
LiquidCrystal_I2C lcd(0x3F, 2, 1, 0, 4, 5, 6, 7, 3, POSITIVE);

#define TIME_DRIVEN_INTERVAL 500 // 타임 드라이븐 인터럽트는 500ms에 한번씩 발동
const int BUS_PAGE_HOLD_COUNT = 5000 / TIME_DRIVEN_INTERVAL; // 버스화면에서 각 페이지를 홀딩할때 필요한 루프 수. 500ms에 한번 인터럽트가 걸리니 5초동안 유지하기 위해서는 5000/500인 10회.

#define MODE_DISP_BUS 0 // 각 모드별 상수 정의
#define MODE_DISP_SUBWAY 1
#define MODE_DISP_DEBUG 2
#define MODE_SYSTEM 99
#define MODE_IDX_DISP_BACKLIGHT 10

const int btnMode = 5;
const int btnPrev = 6;
const int btnNext = 7; // 모드 변경을 위한 푸쉬버튼. ()

int stateMode = 1; // 모드버튼 digitalRead값
int currentMode = MODE_DISP_BUS; // 현재 모드

int statePrev = 1; // deprecated. 자동으로 페이지네이션
int stateNext = 1;
int currentPage = 0;
int currentBusPage = 0;
int currentBusPageCounter = 0;

char readByte = 0;  // 시리얼 통신으로부터 받을 데이터
char byteStream[MAX_STREAM_BUFFER_SIZE]; // 시리얼 통신으로 받은 데이터를 순차적으로 기록

Timer t; // 타임 드라이븐을 위한 변수

struct Bus{ // 각각 버스도착정보 지하철도착정보 구조체
  String label;
  int remain;
};
struct Subway{
  String label;
  int prev;
  int remain;
};

// 실질적으로 필요한 버스노선을 변수로 선언 및 정의
Bus busData[] = {{"300" , 0}, {"301" , 0}, {"2" , 0}, {"2-2" , 0},
                  {"116-1" , 0}, {"116-2" , 0}, {"5300" , 0}, {"M5532" , 0}};
// UN -> Upside Normal, DR -> Downside Rapid
Subway subwayData[] = { {"UN", 0, 0}, {"UR", 0,0}, {"DN", 0,0}, {"DR", 0,0} };

 

버스도착정보와 지하철정보를 받아와야하므로 최소 2개의 모드가 필요했다. 또한 각 모드에서 표현해야 할 데이터 양이 늘어난다면 페이지네이션이 필요하다. 위에 있는 mode 상수와 변수, Timer객체들은 위의 작업에서 필요한 정보들이다.

 

void setup() {
  Serial.begin(9600);
  lcd.begin(16, 4);
  pinMode(btnMode, INPUT_PULLUP); // 모드변경을 신호를 감지할 버튼
  pinMode(btnPrev, INPUT_PULLUP);
  pinMode(btnNext, INPUT_PULLUP);

  t.every(TIME_DRIVEN_INTERVAL, mainUIUpdate); // TIME_DRIVEN_INTERVAL마다 인터럽트. 콜백함수는 mainUIUpdate
  //prevMillis = millis(); // deprecated. timer interrupt 방식이므로 미사용

  /* customChar */
  byte charArrowLeft[] = {
    B00011,
    B00111,
    B01111,
    B11111,
    B11111,
    B01111,
    B00111,
    B00011
  };
  byte charSeo[] = {
    B01001,
    B01001,
    B10111,
    B10101,
    B10101,
    B00001,
    B00001,
    B00000
  };
  byte charUl[] = {
    B01110,
    B10001,
    B01110,
    B11111,
    B00100,
    B01110,
    B00100,
    B01111
  };

  byte charCheon[] = {
    B01001,
    B11101,
    B01011,
    B10101,
    B10011,
    B00001,
    B01000,
    B01111
  };
    byte charAn[] = {
    B01010,
    B10110,
    B10111,
    B10110,
    B01010,
    B00000,
    B01000,
    B01111
  };
  byte charRapid[] = {
    B11110,
    B00010,
    B11111,
    B00000,
    B01001,
    B01111,
    B01001,
    B01111
  };

  lcd.createChar(0, charArrowLeft);
  lcd.createChar(1, charSeo);
  lcd.createChar(2, charUl);
  lcd.createChar(3, charCheon);
  lcd.createChar(4, charAn);
  lcd.createChar(5, charRapid);
}

t.every(ms초, 콜백함수) 를 사용하면 ms초마다 콜백함수를 호출할 수 있다.

이 타이머를 이용하면 편리하게 타임드라이븐 인터럽트를 구현할 수 있는데, 아두이노 보드형태에 따라 마이크로는 2개, 우노는3개, 메가는 5개 등 반드시 보드 스펙문서를 확인하여 가용 타이머가 몇 개인지 숙지하자.

lcd 객체의 createChar(idx, byteArray) 함수는 커스텀 문자를 입력할 수 있다.

https://maxpromer.github.io/LCD-Character-Creator/

위와 같은 사이트들을 이용하면 도트찍듯 편리하게 커스텀 문자를 만들어 낼 수 있다.

createChar로 입력된 문자는 추후 lcd.write((byte)0) 의 형태로 호출 할 수 있다.

단, createChar로 생성할 수 있는 커스텀 문자는 최대 8개이니, 이 점 역시 숙지하자.

 

mainUIUpdate는 LCD에 표출될 정보인데 이는 마지막으로 설명하도록 하고, 시리얼 통신 클라이언트를 구현하기 위한 코드를 먼저 보도록 한다.

void handleStream() { // data format -> 000000 = mode 00, idx 00, data 00
    if (Serial.available() ) {
      while (readIdx < MAX_STREAM_BUFFER_SIZE && Serial.available()) {
        readByte = Serial.read();
        byteStream[readIdx++] = readByte;

      }
    }
  String tmp = String(byteStream); // 읽어들인 byteStream 배열을 String 객체로
  int r_mode = tmp.substring(0,2).toInt();
  int r_idx = tmp.substring(2,4).toInt();
  int r_data = tmp.substring(4,6).toInt();

  switch(r_mode){
    case MODE_DISP_BUS: // BUS
      busData[r_idx].remain = r_data;
      break;
    case MODE_SYSTEM:
      if (r_idx == MODE_IDX_DISP_BACKLIGHT){
        if( r_data > 0){
          lcd.setBacklight(true);
        }else{
          lcd.setBacklight(false);
        }
      }
      break;
  }

}

1절에서 이야기한대로 데이터형식은 00 00 00 으로 각각 모드, 배열인덱스, 실제 데이터로 구성된다.

가장 먼저 Serial이 사용가능한 상태(데이터가 들어오는 상태)일 경우 readIdx(byteStream 배열에서 사용할 인덱스)가 byteStream최대크기보다 작으면서 Serial에 데이터가 남아있을 동안 while문을 돌린다.

Serial.read()로 readByte에 한 바이트씩 읽어오고 이를 byteStream 배열에 차곡차곡 쌓는다.

다 읽어들였으면 배열 데이터를 바탕으로 String 객체를 생성하고 2자리 씩 끊어 파싱한다.

밑에 switch는 파싱한 데이터를 바탕으로 도착정보 배열객체들의 데이터를 갱신하는 과정.

 

int counter = 0;
int readIdx = 0;

void loop() {
  t.update();
}

void mainUIUpdate() {

    readIdx = 0;

    stateMode = digitalRead(btnMode);
    statePrev = digitalRead(btnPrev);
    stateNext = digitalRead(btnNext);

    if(stateMode == HIGH){
      currentMode = (++currentMode) % 3;
    }
    switch(currentMode){
      case MODE_DISP_BUS:
        dispBus();
        break;
      case MODE_DISP_SUBWAY:
        dispSubway();
        break;
      case MODE_DISP_DEBUG:
        dispDebug();
        break;
    }
    handleStream();

}


void dispDebug(){
    lcd.clear();
    lcd.print(counter);
    lcd.setCursor(0, 1);
    lcd.print(stateMode);
    lcd.print(" ");
    lcd.print(statePrev);
    lcd.print(" ");
    lcd.print(stateNext);
}
void dispBus(){
  lcd.clear();
  int lenData = sizeof(busData) / sizeof(busData[0]);

  int cursorLine = 0;
  for(int i= 4*currentBusPage; i < 4*(currentBusPage+1); i++){
  //for(int i=0; i < lenData ; i++){
    lcd.setCursor(0,cursorLine++);
    lcd.print(busData[i].label + ": ");
    // lcd.print(busData[i].remain);
    if(busData[i].remain == 0){
      lcd.print("x");
    }else if(busData[i].remain >=8){
      lcd.print(busData[i].remain);
    }
    else{
      for(int diamond = 0; diamond < busData[i].remain; diamond++){
        lcd.write((byte)0);
      }
    }

  }
  currentBusPageCounter++;
  if(currentBusPageCounter >=BUS_PAGE_HOLD_COUNT){
    currentBusPageCounter = 0;
    currentBusPage = (++currentBusPage) %(lenData/4);
  }

}

void dispSubway(){
  lcd.clear();

  for(int i=0; i<4; i++){
    lcd.setCursor(0, i);
    switch(subwayData[i].label.charAt(0)){
      case 'U':
        lcd.write((byte)1);
        lcd.write((byte)2);
        break;
      case 'D':
        lcd.write((byte)3);
        lcd.write((byte)4);
        break;
    }
    switch(subwayData[i].label.charAt(1)){
      case 'N':
        lcd.print(" :");
        break;
      case 'R':
        lcd.write((byte)5);
        lcd.print(":");
        break;
    }
    lcd.print("NOT AVAIL");
  }

}

최종적으로 mainUIUpdate함수가 콜백될때마다 현재 상수들에 맞게 LCD에 정보를 출력하고 Serial데이터를 확인한다.(handleStream함수)

현재 mode상수값에 맞게 Bus, Subway, Debug화면이 출력된다.

최종적으로 구현된 결과물은 아래와 같다.

displaySubway 호출 시

displayBus 호출 시 (페이지네이션1)

displayBus 호출 시 (페이지네이션2)

 

이 다음으로는 기상 및 미세먼지 알리미를 제작한 일지를 작성하려고 한다.

 


5개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다