WIFI로 제어하는 탁상시계 만들기 #5 NTP 기능을 이용한 인터넷 시계

2019. 1. 22. 17:30

Project/Turtle Clock

NTP 기능을 이용하여 시간 정보를 관리하고 출력하기

Turtle Clock BigFont에 대한 다섯 번째 연재입니다. 이제까지는 프로젝트에 사용할 부품들에 대해 소개하였는데, 나머지 두 개의 부품에 대한 연재에 앞서 우선 시계 표현을 위한 시간 정보를 구축하겠습니다. 아두이노로 시계를 만든다면 보통 외부 RTC 모듈을 사용하겠지만, 아두이노(Arduino)나 NodeMCU 보드는 부팅과 동시에 카운팅되는 자체 시계를 가지고 있습니다. 물론, 시스템이 재부팅되면 시간을 다시 세팅해야 되고 오랜 시간이 지나면 정확성이 떨어질 수 있지만, NodeMCU 보드는 와이파이를 통한 인터넷 접속이 가능하므로 네트워크 타임 프로토콜(Network Time Protocol, NTP)을 이용하여 이러한 단점을 커버할 수 있습니다. 실시간으로 가져오는 시간 정보로 정확한 시계를 구현할 수 있기 때문입니다.

NTP 프로토콜을 이용하여 시간 정보 관리하기

NodeMCU 보드로 시계를 만들기 위해 아래 두 가지 사항을 프로그램으로 구현해야 합니다.

  • NTP 프로토콜을 이용하여 현재 시간 업데이트하기
  • 다음 업데이트까지 시간 정보 유지하기

우선, 첫 번째 문제부터 해결해 보겠습니다.

1. NTPClient 라이브러리 설치

ESP8266(NodeMCU) 플랫폼을 설치하면 생성되는 예제 중에 “NTPClient”가 있습니다. NTP 프로토콜을 이용하여 현재 시간을 가져와 출력하는 예제인데 전체 소스는 아래와 같습니다.(보기만 하세요. 사용하지 않습니다.)

/*
  Udp NTP Client
  Get the time from a Network Time Protocol (NTP) time server
  Demonstrates use of UDP sendPacket and ReceivePacket
  For more on NTP time servers and the messages needed to communicate with them,
  see http://en.wikipedia.org/wiki/Network_Time_Protocol
  created 4 Sep 2010
  by Michael Margolis
  modified 9 Apr 2012
  by Tom Igoe
  updated for the ESP8266 12 Apr 2015
  by Ivan Grokhotkov
  This code is in the public domain.
*/
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#ifndef STASSID
#define STASSID "your-ssid"
#define STAPSK  "your-password"
#endif
const char * ssid = STASSID; // your network SSID (name)
const char * pass = STAPSK;  // your network password
unsigned int localPort = 2390;      // local port to listen for UDP packets
/* Don't hardwire the IP address or we won't get the benefits of the pool.
    Lookup the IP address for the host name instead */
//IPAddress timeServer(129, 6, 15, 28); // time.nist.gov NTP server
IPAddress timeServerIP; // time.nist.gov NTP server address
const char* ntpServerName = "time.nist.gov";
const int NTP_PACKET_SIZE = 48; // NTP time stamp is in the first 48 bytes of the message
byte packetBuffer[ NTP_PACKET_SIZE]; //buffer to hold incoming and outgoing packets
// A UDP instance to let us send and receive packets over UDP
WiFiUDP udp;
void setup() {
  Serial.begin(115200);
  Serial.println();
  Serial.println();
  // We start by connecting to a WiFi network
  Serial.print("Connecting to ");
  Serial.println(ssid);
  WiFi.mode(WIFI_STA);
  WiFi.begin(ssid, pass);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("");
  Serial.println("WiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
  Serial.println("Starting UDP");
  udp.begin(localPort);
  Serial.print("Local port: ");
  Serial.println(udp.localPort());
}
void loop() {
  //get a random server from the pool
  WiFi.hostByName(ntpServerName, timeServerIP);
  sendNTPpacket(timeServerIP); // send an NTP packet to a time server
  // wait to see if a reply is available
  delay(1000);
  int cb = udp.parsePacket();
  if (!cb) {
    Serial.println("no packet yet");
  } else {
    Serial.print("packet received, length=");
    Serial.println(cb);
    // We've received a packet, read the data from it
    udp.read(packetBuffer, NTP_PACKET_SIZE); // read the packet into the buffer
    //the timestamp starts at byte 40 of the received packet and is four bytes,
    // or two words, long. First, esxtract the two words:
    unsigned long highWord = word(packetBuffer[40], packetBuffer[41]);
    unsigned long lowWord = word(packetBuffer[42], packetBuffer[43]);
    // combine the four bytes (two words) into a long integer
    // this is NTP time (seconds since Jan 1 1900):
    unsigned long secsSince1900 = highWord << 16 | lowWord;
    Serial.print("Seconds since Jan 1 1900 = ");
    Serial.println(secsSince1900);
    // now convert NTP time into everyday time:
    Serial.print("Unix time = ");
    // Unix time starts on Jan 1 1970. In seconds, that's 2208988800:
    const unsigned long seventyYears = 2208988800UL;
    // subtract seventy years:
    unsigned long epoch = secsSince1900 - seventyYears;
    // print Unix time:
    Serial.println(epoch);
    // print the hour, minute and second:
    Serial.print("The UTC time is ");       // UTC is the time at Greenwich Meridian (GMT)
    Serial.print((epoch  % 86400L) / 3600); // print the hour (86400 equals secs per day)
    Serial.print(':');
    if (((epoch % 3600) / 60) < 10) {
      // In the first 10 minutes of each hour, we'll want a leading '0'
      Serial.print('0');
    }
    Serial.print((epoch  % 3600) / 60); // print the minute (3600 equals secs per minute)
    Serial.print(':');
    if ((epoch % 60) < 10) {
      // In the first 10 seconds of each minute, we'll want a leading '0'
      Serial.print('0');
    }
    Serial.println(epoch % 60); // print the second
  }
  // wait ten seconds before asking for the time again
  delay(10000);
}
// send an NTP request to the time server at the given address
void sendNTPpacket(IPAddress& address) {
  Serial.println("sending NTP packet...");
  // set all bytes in the buffer to 0
  memset(packetBuffer, 0, NTP_PACKET_SIZE);
  // Initialize values needed to form NTP request
  // (see URL above for details on the packets)
  packetBuffer[0] = 0b11100011;   // LI, Version, Mode
  packetBuffer[1] = 0;     // Stratum, or type of clock
  packetBuffer[2] = 6;     // Polling Interval
  packetBuffer[3] = 0xEC;  // Peer Clock Precision
  // 8 bytes of zero for Root Delay & Root Dispersion
  packetBuffer[12]  = 49;
  packetBuffer[13]  = 0x4E;
  packetBuffer[14]  = 49;
  packetBuffer[15]  = 52;
  // all NTP fields have been given values, now
  // you can send a packet requesting a timestamp:
  udp.beginPacket(address, 123); //NTP requests are to port 123
  udp.write(packetBuffer, NTP_PACKET_SIZE);
  udp.endPacket();
}

보기에도 복잡해 보입니다. 와이파이를 이용한 인터넷 접속, UDP 프로토콜을 통한 NTP 접속, 가져온 정보를 가공하여 출력하는 과정이 포함되어 있기 때문인데, 다행히 이 과정을 아주 편하게 구현해 놓은 사용자 라이브러리가 있습니다.

바로 “NTPClient”라는 라이브러리입니다. 여기서는 이 라이브러리를 이용하여 관련 기능을 구현하겠습니다. 시계 표현을 위한 시간 정보만 필요하고 서머 타임 같은 복잡한 개념도 필요 없으며 날짜도 출력하지 않기 때문에 이 라이브러리만 있으면 됩니다. 아래와 같이 해당 라이브러리를 설치합니다.

아두이노 IDE에서 “Sketch-Include Library-Manage Libraries...” 메뉴를 통해 “Library Manager” 대화상자를 띄운 후, 위와 같이 “NTPClient” 라이브러리를 찾아 설치합니다. “More info”를 클릭하면 아래 링크로 이동하여 추가 정보를 확인할 수 있습니다.

라이브러리 설치 후 제공되는 예제를 불러와 보겠습니다.

#include <NTPClient.h>
// change next line to use with another board/shield
#include <ESP8266WiFi.h>
//#include <WiFi.h> // for WiFi shield
//#include <WiFi101.h> // for WiFi 101 shield or MKR1000
#include <WiFiUdp.h>
const char *ssid     = "<SSID>";
const char *password = "<PASSWORD>";
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP);
void setup(){
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  timeClient.begin();
}
void loop() {
  timeClient.update();
  Serial.println(timeClient.getFormattedTime());
  delay(1000);
}

보이는 것처럼 와이파이 접속을 위한 코드를 제외하고 아주 간단히 구현하였습니다. 이제 이 라이브러리를 이용하여 예제를 작성해 보겠습니다.

2. NTP 접속 예제 작성하기
#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>

우선 필요한 헤더 파일을 포함합니다. 위에서 설명한 NTPClient, ESP8266 보드에서 WiFi를 이용하기 위한 ESP8266WiFi, NTP 서비스는 UDP 프로토콜을 이용하므로 UDP 통신을 위한 WiFiUdp 이렇게 세 개입니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
//
const char *ssid     = "********";
const char *password = "********";

다음은 접속할 무선 네트워크(WiFi)의 접속 정보를 자신의 환경에 맞게 제공합니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
//
const char *ssid     = "********";
const char *password = "********";
//
WiFiUDP udp;
NTPClient timeClient(udp);

그 다음 필요한 객체나 전역 변수들을 생성할 차례입니다. UDP 프로토콜을 위해 “udp”라는 이름으로 객체를 하나 생성하고, 이 객체를 인수로 하여 NTPClient 객체를 “timeClient”라는 이름으로 생성합니다. UDP와 관련된 부분은 여기서 끝이고 나머지는 NTPClient가 알아서 합니다. NTPClient 객체를 생성할 때, 인수는 UDP 객체(udp) 하나만 넘겨주지만, 몇 가지 인수들을 추가하여 NTP 접속 환경을 세팅할 수 있습니다. 이 부분은 아래쪽에서 설명하겠습니다.

void setup(){
  Serial.begin(115200);
  //
  WiFi.begin(ssid, password);
  //
}

이제 setup() 함수를 작성하겠습니다. 결과 확인을 위해 시리얼 모니터를 시작하고, 주어진 정보를 이용하여 WiFi 오브젝트를 시작합니다.

void setup(){
  Serial.begin(115200);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
}

WiFi에 접속하는 코드입니다. WiFi.status() 함수를 체크하여 접속이 성공(WL_CONNECTED)할 때까지 접속 시도를 반복합니다.

void setup(){
  Serial.begin(115200);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
}

접속이 성공하였다면, 미리 생성한 NTPClient 오브젝트 “timeClient”를 시작합니다. UDP를 사용하기 위해 특정한 포트를 이용하겠다면 timeClient.begin(port)처럼 포트 번호를 인수로 제공합니다. 저는 그냥 인수 없이 디폴트값을 사용하겠습니다.

NTPClient 라이브러리에서 제공하는 함수 중 제가 사용할 함수는 위와 같습니다. 이름만 봐도 무슨 기능인지 쉽게 알 수 있습니다. getFormattedTime() 함수는 가져온 시간 정보를 미리 정해진 양식으로 만들어주는 함수이며 테스트에만 사용할 것입니다.

void setup(){
  .
  .
  .
  timeClient.begin();
  //
  timeClient.update();
  //
  Serial.println(timeClient.getFormattedTime());
}

테스트를 위해 NTP 접속을 단 한번만 실행하도록 setup() 함수 내에 이어서 코드를 작성하겠습니다. timeClient.update() 함수는 실제로 NTP 서버에 접속해서 시간 정보를 가져오는 함수입니다. 가져온 정보는 getFormattedTime() 함수를 이용하여 시리얼 모니터에 출력합니다.

그러나 여기까지 코딩한 후 실행하면 제대로 된 결과를 얻을 수 없습니다. NTP 정보를 가져오는 작업이 실패하기 때문인데 update() 함수가 오브젝트의 디폴트 값을 그대로 사용하기 때문입니다. 위쪽에서 잠깐 언급했는데, NTPClient 객체를 생성할 때, UDP 객체외에 추가적인 인수를 제공하여 우리나라 시간대에 맞게 설정하는 부분이 필요합니다.

NTPClient(UDP& udp);
NTPClient(UDP& udp, int timeOffset);
NTPClient(UDP& udp, const char* poolServerName);
NTPClient(UDP& udp, const char* poolServerName, int timeOffset);
NTPClient(UDP& udp, const char* poolServerName, int timeOffset, int updateInterval);

NTPClient 오브젝트를 생성할 때 사용할 수 있는 인수들과 그 조합입니다. 이 구문들을 사용하여 실행 환경을 세팅해야 합니다.

NTPClient timeClient(udp, "kr.pool.ntp.org");

먼저 가장 가까운 NTP 서버의 주소를 두 번째 인수로 제공합니다. 이 부분만 수정한 후 실행하면 접속에 성공할 수 있습니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
//
const char *ssid     = "********";
const char *password = "********";
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org");
//
void setup(){
  Serial.begin(115200);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
  //
  timeClient.update();
  //
  Serial.println(timeClient.getFormattedTime());
}
void loop() {
}

전체 소스와 결과 화면입니다. 소스의 9번 행이 수정된 부분입니다. 그런데 결과 화면을 보면 출력된 시간과 현재 컴퓨터의 시간이 전혀 다른 것을 알 수 있습니다. 이는 라이브러리의 기본 시간대가 UTC(협정 세계표준시) 즉 GMT(그리니치 평균시)이기 때문이며 우리나라 시간대에 맞게 수정해야 합니다.

이를 위해 사용하는 인수가 “int timeOffset”입니다. timeOffset은 이름 그대로 GMT를 기준으로 얼마의 값을 보정해야 하는지를 알려주기 위한 인수인데, 우리나라 시간대가 “GMT+9”이므로 9시간을 보정해야 합니다. 단, timeOffest은 초단위입니다. 아래와 같이 9시간에 해당하는 32,400초를 인수로 제공해야 합니다.

NTPClient timeClient(udp, "kr.pool.ntp.org", 32400);

예제의 9번 행을 위와 같이 수정하고 업로드하여 실행합니다.

결과를 보면, 제대로 실행이 되었고 컴퓨터와의 시간도 일치함을 알 수 있습니다.

NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);

NTPClient 오브젝트 생성의 마지막 인수는 “int updateInterval”입니다. 이 인수는 NTP 서버에 접속하여 시간 정보를 가져오는 작업을 얼마의 시간 마다 수행할 것인지를 지정합니다. 이는 업데이트 최소 간격을 지정하는 것으로, 이 옵션을 설정한다고 해서 그 시간마다 자동으로 업데이트 한다는 뜻은 아닙니다. update() 함수를 수행했을 때 해당 시간이 경과되지 않았다면 작업을 수행하지 않는다는 뜻입니다. 정보를 가져오는 작업은 꼭 update() 함수가 호출되어야 합니다. 단, update() 함수가 최초 실행될 때는 updateInterval과 상관없이 무조건 실행합니다.

이 인수는 밀리 초(milisecond) 단위입니다. 위와 같이 호출하면 한 시간마다 서버에 접속할 수 있습니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
//
const char *ssid     = "coffee";
const char *password = "coffee11";
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);
//
void setup(){
  Serial.begin(115200);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
  //
  timeClient.update();
  //
  Serial.println(timeClient.getFormattedTime());
}
void loop() {
}

여기까지의 전체 소스입니다.

3. NTP 서버에서 가져온 시간 정보 유지하기

이제 NTP 서버에서 가져온 시간 정보를 시계에 출력하기 위해 계속해서 유지하는 부분을 구현해야 합니다. 출력할 때마다 매번 NTP 서버에서 시간 값을 가져오는 것은 배터리 관리 및 자원 관리 측면에서 지양하고, 위 예제에서 세팅한대로 1 시간이나 1일 단위로만 업데이트할 계획입니다. 그래서, 가져온 시간 값을 매 초마다 증가시키며 유지하도록 코딩해야 하는데, 이 부분도 이미 NTPClient 라이브러리에서 제공하고 있습니다. 우선 위 예제의 loop() 함수 부분을 아래와 같이 변경합니다.

void loop() {
  delay(1000);
  Serial.println(timeClient.getFormattedTime());
}

1초마다 같은 양식으로 시간을 출력하는 코드인데, setup() 함수에서 한 번 시간 정보를 업데이트 한 후에, loop() 함수 내에 update() 함수가 없기 때문에 더 이상 시간 업데이트는 없습니다. 만약 update() 함수가 있다 해도 시간 간격을 1시간으로 해 놓았기 때문에 그 안에는 업데이트 하지 않습니다.

결과를 보면 정상적으로 시간이 유지되고 있음을 알 수 있습니다.

unsigned long NTPClient::getEpochTime() {
  return this->_timeOffset + // User offset
         this->_currentEpoc + // Epoc returned by the NTP server
         ((millis() - this->_lastUpdate) / 1000); // Time since last update

NTPClient 라이브러리의 NTPClient.cpp 파일에 보면 위와 같은 구문이 있습니다. update()를 통해서 저장된 시간 값을 가져올 때 항상 마지막 업데이트 이후 경과된 시간을 보정하도록 코딩되어 있습니다.

7 세그먼트 LED 모듈에 시간 정보 출력하기
1. 세그먼트 LED에 시간 출력하기

이제 가져온 NTP 시간 값을 7 세그먼트 LED에 출력하는 코드를 작성하겠습니다. 사용할 세그먼트 LED가 총 네 자리이기 때문에 “시”와 “분”만 표시할 수 있습니다. “초”를 표시할 일이 없어 매 “분”마다 처리하면 되지만, 1초마다 콜론이 점등되는 효과를 주기 위해서 “초”단위의 관리도 필요합니다. 만약, 날짜 정보까지 필요하다면 따로 계산해주거나 Time.h 라이브러리를 이용하여 해결할 수 있습니다.

#include <Adafruit_LEDBackpack.h>
Adafruit_7segment led = Adafruit_7segment();
int previousMin = 0;
#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_LEDBackpack.h>
//
const char *ssid     = "xxxxxxxx";
const char *password = "xxxxxxxx";
//
int previousMin = 0;
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);
Adafruit_7segment led = Adafruit_7segment();

세그먼트 LED에 출력하기 위해 이전 글에서 다루었던 것처럼 관련 라이브러리를 포함하고 “led”라는 이름으로 오브젝트를 선언합니다. 또 매 분마다 처리할 작업이 있으므로 “분”단위 값이 변경되었는지 체크해야 합니다. 이를 위해 “int previousMin”라는 변수를 setup() 함수 이전에 선언합니다.

void setup(){
  Wire.begin(D1, D2);
  led.begin(0x70);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
}

setup() 함수도 약간 변경합니다. 시리얼 모니터와 관련된 코드들은 삭제하고 I2C 방식으로 연결된 세그먼트 LED를 위해 연결 핀 번호와 주소를 제공합니다. 그리고 NTPClient의 업데이트 및 출력은 loop() 함수에서만 처리하도록 setup() 함수에서 삭제합니다.

void loop() {
  timeClient.update();
  int currentMin = timeClient.getMinutes();
  if (previousMin != currentMin) {
    //
  }
}

loop()를 작성합니다. 먼저 update() 함수를 이용하여 NTPClient 객체를 업데이트 합니다. 오브젝트를 선언할 때 지정했던 시간 간격(updateInterval)이 되었거나 아직 업데이트 한 적이 없다면 함수가 실행되어 시간 정보를 업데이트 합니다. 그리고 “분” 단위 값이 변경되었을 경우 처리하도록 IF문을 작성합니다.

previousMin = currentMin;

IF문 안쪽을 작성하겠습니다. 우선 다음 번 비교를 위해 현재 “분” 시간 값을 previousMin 변수에 저장합니다.

int currentTime = timeClient.getHours() * 100 + currentMin;

그 다음, getHours() 함수를 이용해 “시” 단위 값을 가져오고 “분” 단위 값과 조합하여 세그먼트에 출력할 10진수 네 자리 이하 값으로 만듭니다. “12:30”이면 “1230”이 될 것입니다.

led.print(currentTime, DEC);
led.writeDisplay();

이제 currentTime 변수에 저장된 10진수 값을 세그먼트 LED의 버퍼에 저장한 후 출력합니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_LEDBackpack.h>
//
const char *ssid     = "xxxxxxxx";
const char *password = "xxxxxxxx";
//
int previousMin = 0;
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);
Adafruit_7segment led = Adafruit_7segment();
//
void setup(){
  Wire.begin(D1, D2);
  led.begin(0x70);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
}
//
void loop() {
  timeClient.update();
  int currentMin = timeClient.getMinutes();
  if (previousMin != currentMin) {
    previousMin = currentMin;
    int currentTime = timeClient.getHours() * 100 + currentMin;
    led.print(currentTime, DEC);
    led.writeDisplay();
  }
}

전체 소스와 결과 화면입니다.

2. 12/24 시간제 선택하여 출력하기

다음으로 12/24 시간제로 선택하여 출력하는 기능을 구현하겠습니다. 우선 어떤 시간제인지 구별할 변수가 필요합니다.

bool hours12 = true;

위와 같이 setup() 함수 위쪽에 변수를 하나 선언합니다. “true”는 12시간제를 사용하겠다는 의미로 쓰겠습니다.

void loop() {
  timeClient.update();
  int currentMin = timeClient.getMinutes();
  if (previousMin != currentMin) {
    previousMin = currentMin;
    int currentTime = timeClient.getHours() * 100 + currentMin;
    //
    if (hours12) {
      //
    } else {
      led.print(currentTime, DEC);
    }
    led.writeDisplay();
  }
}

loop() 함수 내에서 currentTime을 구한 후, 위와 같이 IF문을 추가합니다. hours12 변수의 값이 “true”이면 12시간제이므로 적당한 처리를 하고, 아니라면 세그먼트 LED에 그대로 출력합니다.

if (currentTime > 1259) {
  led.print(currentTime - 1200, DEC);
} else {
  led.print(currentTime, DEC);
}

IF문 안쪽에 추가될 부분은 위와 같습니다. 시간을 의미하는 10진 숫자 값이 1259 보다 크면 1200을 빼고 출력하고 아니라면 그대로 출력합니다. 12:59분까지는 그대로 출력하고 13:00시는 1:00시로 출력하기 위함입니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_LEDBackpack.h>
//
const char *ssid     = "xxxxxxxx";
const char *password = "xxxxxxxx";
//
int previousMin = 0;
bool hours12 = true;
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);
Adafruit_7segment led = Adafruit_7segment();
//
void setup(){
  Wire.begin(D1, D2);
  led.begin(0x70);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
}
//
void loop() {
  timeClient.update();
  int currentMin = timeClient.getMinutes();
  if (previousMin != currentMin) {
    previousMin = currentMin;
    int currentTime = timeClient.getHours() * 100 + currentMin;
    //
    if (hours12) {
      if (currentTime > 1259) {
        led.print(currentTime - 1200, DEC);
      } else {
        led.print(currentTime, DEC);
      }
    } else {
      led.print(currentTime, DEC);
    }
    led.writeDisplay();
  }
}

여기까지의 전체 소스와 결과 화면입니다.

3. AM/PM 표시하기

세그먼트 LED의 맨 앞쪽에 있는 콜론은 위, 아래 도트를 따로 제어할 수 있습니다. 이를 이용해서 AM/PM을 표시하겠습니다. 24시간제일 때는 필요 없으므로, 역시 12시간제를 처리하는 부분에 코딩합니다.

if (currentTime > 1159) {
  // PM
} else {
  // AM
}

12시간제에서 12:00시가 넘어가면 PM을, 아니면 AM을 의미하도록 IF문을 구성합니다. 이 프로젝트에 사용하는 7 세그먼트 LED 장치는 5개의 도트 포인트를 제어하기 위해서 버퍼를 직접 수정해야 합니다. 가운데 콜론만 전용 함수가 있기 때문인데, led.displaybuffer[2] 변수가 바로 도트 포인트를 위한 데이터가 저장되는 곳입니다. 이 부분은 앞선 연재에서 이미 설명한 부분이고, 아래와 같이 IF문을 채우면 됩니다.

if (currentTime > 1159) {
  led.displaybuffer[2] = 8; // PM
} else {
  led.displaybuffer[2] = 4; // AM
}

이 IF문 위쪽에서 시간 데이터를 버퍼에 저장했기 때문에, 이전 도트는 지워진 상태입니다. 여기서 해당하는 도트만 추가하여 줍니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_LEDBackpack.h>
//
const char *ssid     = "xxxxxxxx";
const char *password = "xxxxxxxx";
//
int previousMin = 0;
bool hours12 = true;
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);
Adafruit_7segment led = Adafruit_7segment();
//
void setup(){
  Wire.begin(D1, D2);
  led.begin(0x70);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
}
//
void loop() {
  timeClient.update();
  int currentMin = timeClient.getMinutes();
  if (previousMin != currentMin) {
    previousMin = currentMin;
    int currentTime = timeClient.getHours() * 100 + currentMin;
    //
    if (hours12) {
      if (currentTime > 1259) {
        led.print(currentTime - 1200, DEC);
      } else {
        led.print(currentTime, DEC);
      }
      //
      if (currentTime > 1159) {
        led.displaybuffer[2] = 8; // PM
      } else {
        led.displaybuffer[2] = 4; // AM
      }
    } else {
      led.print(currentTime, DEC);
    }
    led.writeDisplay();
  }
}

전체 소스 및 결과 화면입니다. 앞쪽은 AM, 뒤쪽은 PM입니다.

4. 가운데 콜론 표시하기

시와 분을 구분하는 가운데 콜론을 표시하겠습니다. 가운데 콜론은 항상 표시하거나 사용자의 선택에 따라 깜박이는 효과를 표시하도록 구성합니다. 깜박임은 1초마다 실행하고 500밀리 초 동안 On, 500밀리 초 동안 Off 상태로 깜박이는 형태입니다. 코드는 켜는 동작과 끄는 동작을 구분하여 구성하겠습니다.

먼저 콜론을 켜는 코드를 작성합니다. 우선 깜박임을 염두에 두어야하기 때문에 매번 “초” 단위가 바뀔 때마다 콜론을 켜도록 합니다. 그래서 변수 하나가 필요합니다.

int previousSec = 0;
  int currentSec = timeClient.getSeconds();
  if (previousSec != currentSec) {
    previousSec = currentSec;
    .
    .
  }

int previousSec 변수는 “초” 단위 값이 변경되었는지 체크하기 위한 것이고 “분” 단위의 변경을 체크하는 코드와 동일합니다. 이를 바탕으로 IF문을 구성합니다.

  int currentSec = timeClient.getSeconds();
  if (previousSec != currentSec) {
    previousSec = currentSec;
    led.displaybuffer[2] = led.displaybuffer[2] | 0x02;
    led.writeDisplay();
  }

“초”가 변경될 때마다 세그먼트 LED에 콜론을 출력합니다. 가운데 콜론을 위한 drawColon(), writeColon() 함수도 있지만, 버퍼에 직접 값을 저장하여 처리하였습니다. led.displaybuffer[2] 변수에 숫자 “2”를 넣을 경우 가운데 콜론은 출력되지만 다른 도트 플레이트는 삭제됩니다. 12시간제의 AM/PM을 표시하기 위한 앞쪽 도트 정보가 있을 경우를 생각해서 원래 변수 값에 “OR” 연산을 통해서 해당 비트를 세팅하였습니다.

마지막으로, 깜박임을 처리하기 위해선 On/Off 시간을 계산해야 합니다. 콜론을 켤 때마다 변수에 현재 시간을 저장합니다.

unsigned long colonOnTime;
  int currentSec = timeClient.getSeconds();
  if (previousSec != currentSec) {
    previousSec = currentSec;
    led.displaybuffer[2] = led.displaybuffer[2] | 0x02;
    led.writeDisplay();
    colonOnTime = millis();
  }

이를 위해서 colonOnTime 변수를 선언하고 콜론을 On 시킬 때 현재 시간을 millis() 함수를 이용해 저장합니다. 시간 값을 저장해야 하므로 colonOnTime 변수의 데이터 형은 unsigned long이어야 합니다.

이제 깜박임을 표시해야 하는데, 사용자의 선택에 따라 깜박임 여부를 결정해야 합니다. 이를 위해 변수 하나를 선언하였습니다.

bool colonBlink = true;
  int currentSec = timeClient.getSeconds();
  if (previousSec != currentSec) {
    .
    .
    colonOnTime = millis();
  }
  //
  if (colonBlink) {
    //
  }

colonBlink 변수는 깜박임 여부를 결정합니다. “true”일 때 깜박이고 IF문 안쪽에서 깜박임을 제어합니다.

  if (colonBlink) {
    if (millis() - colonOnTime > 500) {
      led.displaybuffer[2] = led.displaybuffer[2] & 0xFD;
      colonOnTime = millis();
    }
    led.writeDisplay();
  }

IF문 안쪽을 완성하였습니다. 현재 시간(millis())에서 콜론이 켜진 시간을 빼면 경과된 시간을 구할 수 있습니다. 이 시간이 500밀리 초가 지났다면 비트 “AND” 연산(bitmask)을 이용해서 콜론을 Off 합니다. 그리고 이 부분이 다시 실행되지 않도록 colonOnTime 변수를 다시 현재 시간으로 세팅합니다.

#include <NTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiUdp.h>
#include <Adafruit_LEDBackpack.h>
//
const char *ssid     = "xxxxxxxx";
const char *password = "xxxxxxxx";
//
int previousMin = 0;
int previousSec = 0;
bool colonBlink = true;
unsigned long colonOnTime;
bool hours12 = false;
//
WiFiUDP udp;
NTPClient timeClient(udp, "kr.pool.ntp.org", 32400, 3600000);
Adafruit_7segment led = Adafruit_7segment();
//
void setup(){
  Wire.begin(D1, D2);
  led.begin(0x70);
  //
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }
  //
  timeClient.begin();
}
//
void loop() {
  timeClient.update();
  int currentMin = timeClient.getMinutes();
  if (previousMin != currentMin) {
    previousMin = currentMin;
    int currentTime = timeClient.getHours() * 100 + currentMin;
    //
    if (hours12) {
      if (currentTime > 1259) {
        led.print(currentTime - 1200, DEC);
      } else {
        led.print(currentTime, DEC);
      }
      //
      if (currentTime > 1159) {
        led.displaybuffer[2] = 8; // PM
      } else {
        led.displaybuffer[2] = 4; // AM
      }
    } else {
      led.print(currentTime, DEC);
    }
    led.writeDisplay();
  }
  //
  int currentSec = timeClient.getSeconds();
  if (previousSec != currentSec) {
    previousSec = currentSec;
    led.displaybuffer[2] = led.displaybuffer[2] | 0x02;
    led.writeDisplay();
    colonOnTime = millis();
  }
  //
  if (colonBlink) {
    if (millis() - colonOnTime > 500) {
      led.displaybuffer[2] = led.displaybuffer[2] & 0xFD;
      colonOnTime = millis();
    }
    led.writeDisplay();
  }
}

전체 소스입니다. 작동 결과는 글 아래쪽 영상을 참고하세요.

5. WI-FI 접속 대기 화면 만들기
  WiFi.begin(ssid, password);
  //
  while ( WiFi.status() != WL_CONNECTED ) {
    delay ( 500 );
    Serial.print ( "." );
  }

setup() 함수에 있는 무선 네트워크 접속 코드입니다. 접속을 시도하는 동안 시리얼 모니터에 “.”을 표시하는데, 시리얼 모니터를 사용하지 않기 때문에 접속 시도 중인지 에러가 났는지 알 수가 없습니다. 기다리는 동안 약간 답답한 면이 있어서, 이 표시를 세그먼트 LED에 출력하겠습니다.

  while ( WiFi.status() != WL_CONNECTED ) {
    led.displaybuffer[0] = 1;
    led.displaybuffer[1] = 1;
    led.displaybuffer[3] = 1;
    led.displaybuffer[4] = 1;
    led.writeDisplay();
    delay ( 500 );
  }

위와 같이 코딩하면 접속하는 동안 세그먼트 LED의 A segment가 “On”됩니다. 버퍼에 직접 데이터를 대입하면, 해당하는 비트가 세팅되기 때문인데, 가운데 G segment를 제외한 A, B, C, D, E, F 세그먼트를 켜려면 차례로 1, 2, 4, 8, 16, 32 값을 대입하면 됩니다.

led.displaybuffer[0]은 첫째 자리, led.displaybuffer[1]은 둘째 자리, led.displaybuffer[3]은 셋째 자리, led.displaybuffer[4]는 넷째 자리입니다. led.displaybuffer[2]는 위에서 본 바와 같이 도트 포인트를 담당합니다.

  int count = 1;
  while ( WiFi.status() != WL_CONNECTED ) {
    led.displaybuffer[0] = count;
    led.displaybuffer[1] = count;
    led.displaybuffer[3] = count;
    led.displaybuffer[4] = count;
    led.writeDisplay();
    count = count * 2;
    if ( count > 32 ) count = 1;
    delay ( 500 );
  }

완성된 코드입니다. A 세그먼트에서 F 세그먼트까지 차례로 출력하기 위해 count 라는 변수를 만들고 while문이 순환할 때마다 2씩 곱해 나갑니다. 또, G 세그먼트는 제외하고 다시 처음부터 순환하기 위해 count 값을 체크하여 다시 1로 초기화 합니다.

  int count = 0;
  while ( WiFi.status() != WL_CONNECTED ) {
    led.displaybuffer[0] = 0x01 << count;
    led.displaybuffer[1] = 0x01 << count;
    led.displaybuffer[3] = 0x01 << count;
    led.displaybuffer[4] = 0x01 << count;
    led.writeDisplay();
    count = count + 1;
    if ( count > 5 ) count = 0;
    delay ( 500 );
  }

위 코드는 1 비트씩 왼쪽으로 시프트하며 출력하고 결과는 동일합니다. 결과 화면은 아래와 같습니다.

이것으로 이번 포스트도 마무리 짓겠습니다. 12/24 시간제와 콜론의 깜박임 여부에 대한 선택 방법은 사용자 인터페이스를 구성할 때 구현하겠습니다. 이상입니다.



Comments