Рейтинг@Mail.ru

Как сделать сервер времени (NTP) на Arduino

автор:
15 comments Arduino
Print Friendly, PDF & Email
Рассмотрим создание сервера времени, который берёт значение времени по спутниковому сигналу GPS или ГЛОНАСС, а затем при запросе клиентом возвращает его значение по протоколу NTP.

Для проекта нам понадобятся:

1Схема NTP сервера на Arduino

Протокол NTP – это протокол, который используется для синхронизации часов компьютера или иного устройства с сервером времени по сети. Любое сетевое устройство может послать сетевой пакет определённого вида серверу времени, а тот в ответ пришлёт точное значение времени. Запросившее устройство установит это значение на своих системных часах. Таким образом осуществляется синхронизация времени. Существует несколько популярных серверов точного времени. Так, например, в операционных системах Windows для синхронизации времени используются time.windows.com или time.nist.gov. Можно использовать и другие сервера, в том числе и свой собственный сервер. Мы реализуем сервер времени с помощью Arduino.

Откуда же мы будем брать значение точного времени? Для этого воспользуемся приёмником ГНСС сигналов. Такие приёмники с определённой периодичностью (обычно 1 раз в секунду или около того) возвращают данные в формате NMEA о своём местоположении, а также дате и времени. Описание формата NMEA можно скачать в конце статьи. Мы подключимся к приёмнику и по протоколу UART получим с него данные о точном времени.

GPS/GLONASS приёмник с UART
GPS/GLONASS приёмник с UART
GPS/GLONASS приёмник с UART
GPS/GLONASS приёмник с UART (Digma RGM-3600LPX)

Также нам понадобится модуль с часами реального времени (RTC), в который мы сохраним значение времени. И по запросу клиента будем возвращать время, считанное с этого модуля. Такие модули обычно имеют собственный элемент питания и сохраняют время даже при отсутствии внешнего источника питания.

Модуль с часами реального времени DS1307
Модуль с часами реального времени DS1307

И, конечно же, нам понадобится модуль с сетевым интерфейсом или т.н. Ethernet-шилд. Этот модуль позволит подключить Arduino к локальной сети или к компьютеру по Ethernet.

Ethernet-шилд с микросхемой Wiznet W5100
Ethernet-шилд с микросхемой Wiznet W5100

В качестве альтернативы можно использовать Wi-Fi модуль, разумеется. Лишь бы ваши устройства, время на которых необходимо синхронизировать с сервером времени, находились в одной локальной сети с сервером времени на Arduino.

Теперь соединим все наши части воедино. Для этого сначала соберём «бутерброд» из Arduino и сетевого шилда, который выполнен в виде мезонинной платы. Далее подключим модуль часов DS1307 к выводам A4 и A5, а это шина I2C, как мы помним. Следовательно, пин A4 – это SDA, пин A5 – SCL. Приёмник сигналов ГНСС необходимо подключить к UART. Для этого можно подключить его к стандартным выводам RX и TX Arduino (пины 0 и 1, соответственно). Но тогда мы не сможем одновременно работать с приёмником и отлаживаться с выводом отладочных сообщений в последовательный порт. Поэтому рекомендую реализовать программный UART с помощью штатной библиотеки SoftwareSerial. Для этого подключим GPS приёмник к любым цифровым выводам (кроме 0 и 1), например, к 10 и 11.

Общий вид NTP сервера на Arduino
Общий вид NTP сервера на Arduino

Не забудем питание и землю, разумеется. И модуль часов, и приёмник питаются одним напряжением, равным 5 вольтам.

2Скетч NTP сервера для Arduino

Напишем скетч для Arduino, в котором реализуем функциональность сервера времени с поддержкой протокола NTP и с минимальным использованием сторонних библиотек.

Общий алгоритм следующий. Сначала будем опрашивать приёмник спутникового сигнала, пока не получим от него NMEA пакет с корректным значением времени. Нужный нам пакет с временем начинается с заголовка "$GPRMC". В этих пакетах время и дата хранятся на 2 и на 10 позициях соответственно, разделённых запятыми. Координаты достоверны, когда статус (позиция 3) будет "A".

Формат пакета NMEA с данными о времени
Формат пакета NMEA с данными о времени

Когда получим значение времени, запишем его в модуль RTC. Подробно работу с часами реального времени мы рассматривали здесь.

Далее запустим сервер и в цикле будем постоянно слушать входящие запросы по протоколу UDP на порту 123 (это стандартный порт протокола NTP). Как только сервер получит NTP запрос, прочитаем время из модуля часов реального времени, «упакуем» в ответный NTP пакет и отправим клиенту, который запросил время.

В конце статьи приложена программа для тестирования связи с NTP сервером.

Скетч сервера времени NTP и Arduino (разворачивается)
#define debug true // для вывода отладочных сообщений

#include <SoftwareSerial.h>
#include <Wire.h>
#include <Ethernet.h>
#include <EthernetUdp.h>

SoftwareSerial Serial1(10, 11);
EthernetUDP Udp;

// MAC, IP-адрес и порт NTP сервера:
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0xFE, 0xED }; // задайте свой MAC
IPAddress ip(192, 168, 0, 147); // задайте свой IP
#define NTP_PORT 123 // стандартный порт, не менять

#define RTC_ADDR 0x68 // i2c адрес RTC

static const int NTP_PACKET_SIZE = 48;
byte packetBuffer[NTP_PACKET_SIZE];

int year;
byte month, day, hour, minute, second, hundredths;
unsigned long date, time, age;
uint32_t timestamp, tempval;

void setup() {
  Wire.begin(); // стартуем I2C
  
#if debug
  Serial.begin(115200);
#endif
  Serial1.begin(4800); // старт UART для GPS модуля

  getGpsTime(); // получаем время GPS
  writeRtc(); // записываем время в RTC

  // запускаем Ethernet шилд в режиме UDP:
  Ethernet.begin(mac, ip);
  Udp.begin(NTP_PORT);
#if debug
  Serial.println("NTP started");
#endif  
}

void loop() {
  processNTP(); // обрабатываем приходящие NTP запросы
}

String serStr; // строка для хранения пакетов от GPS приёмника

// Читает пакеты GPS приёмника из COM-порта и пытается найти в них время
// Если время найдено, возвращает True, иначе - False
void getGpsTime() {
  bool timeFound = false;
  while (!timeFound) {
    while (Serial1.available()>0) {
    char c = Serial1.read();
      if (c != '\n') {
        serStr.concat(c);
      } else {
        timeFound = decodeTime(serStr);
        serStr = "";
      }
    }
  }
}

// Декодирует вермя по NMEA пакету 
// и возвращает True в случае успеха и False в обратном случае
bool decodeTime(String s) {
#if debug
    Serial.println("NMEA Packet = " + s);
#endif
  if (s.substring(0,6)=="$GPRMC") {
    String validFlag = s.substring(18,20);
    // Ждём валидные данные (флаг "V" - данные не валидны, "A" - данные валидны):
    if (validFlag == "A") {
      String timeStr = s.substring(7,17); // строка времени в формате ччммсс.сс
      hour = timeStr.substring(0,2).toInt();
      minute = timeStr.substring(2,4).toInt();
      second = timeStr.substring(4,6).toInt();
      hundredths = timeStr.substring(7,10).toInt();

      // ищем индекс 4-ой запятой с конца, после которой идёт дата
      int commaIndex = 1;
      for (int i=0;i<5;i++) {
        commaIndex = s.lastIndexOf(",", commaIndex-1);
      }
      String date = s.substring(commaIndex+1, commaIndex+7); // строка даты в формате ддммгг
      day = date.substring(0,2).toInt();
      month = date.substring(2,4).toInt();
      year = date.substring(4,6).toInt(); // передаются только десятки и единицы года
#if debug
    printDate();
#endif
      return true;
    }
  }
  return false;
}

// Запоминает время в RTC
void writeRtc() {
  byte arr[] = {0x00, dec2hex(second), dec2hex(minute), dec2hex(hour), 0x01, dec2hex(day), dec2hex(month), dec2hex(year)};
  Wire.beginTransmission(RTC_ADDR);
  Wire.write(arr, 8);
  Wire.endTransmission();
#if debug
  Serial.print("Set date: ");
  printDate();
#endif
}

// Преобразует число из dec представления в hex представление
byte dec2hex(byte b) {
  String bs = (String)b;
  byte res;
  if (bs.length()==2) {
    res = String(bs.charAt(0)).toInt() * 16 + String(bs.charAt(1)).toInt();
  } else {
    res = String(bs.charAt(0)).toInt();
  }
#if debug
  Serial.println("dec " + (String)b + " = hex " + (String)res);
#endif  
  return res;
}

// Читает из RTC время и дату
void getRtcDate() {
  Wire.beginTransmission(RTC_ADDR);
  Wire.write(byte(0));
  Wire.endTransmission();
  
  Wire.beginTransmission(RTC_ADDR);
  Wire.requestFrom(RTC_ADDR, 7);
  byte t[7];
  int i = 0;
  while(Wire.available()) {
    t[i] = Wire.read();
    i++;
  }
  Wire.endTransmission();
  second = t[0];
  minute = t[1];
  hour = t[2];
  day = t[4];
  month = t[5];
  year = t[6];
#if debug
  Serial.print("Get date: ");
  printDate();
#endif
}

// Обрабатывает запросы к NTP серверу
void processNTP() {
  int packetSize = Udp.parsePacket();
  if (packetSize) {
    Udp.read(packetBuffer, NTP_PACKET_SIZE);
    IPAddress remote = Udp.remoteIP();
    int portNum = Udp.remotePort();

#if debug
    Serial.println();
    Serial.print("Received UDP packet size ");
    Serial.println(packetSize);
    Serial.print("From ");

    for (int i=0; i<4; i++) {
      Serial.print(remote[i], DEC);
      if (i<3) { Serial.print("."); }
    }
    Serial.print(", port ");
    Serial.print(portNum);

    byte LIVNMODE = packetBuffer[0];
    Serial.print("  LI, Vers, Mode :");
    Serial.print(packetBuffer[0], HEX);

    byte STRATUM = packetBuffer[1];
    Serial.print("  Stratum :");
    Serial.print(packetBuffer[1], HEX);

    byte POLLING = packetBuffer[2];
    Serial.print("  Polling :");
    Serial.print(packetBuffer[2], HEX);

    byte PRECISION = packetBuffer[3];
    Serial.print("  Precision :");
    Serial.println(packetBuffer[3], HEX);

    for (int z=0; z<NTP_PACKET_SIZE; z++) {
      Serial.print(packetBuffer[z], HEX);
      if (((z+1) % 4) == 0) {
        Serial.println();
      }
    }
    Serial.println();
#endif

    // Упаковываем данные в ответный пакет:
    packetBuffer[0] = 0b00100100;   // версия, режим
    packetBuffer[1] = 1;   // стратум
    packetBuffer[2] = 6;   // интервал опроса
    packetBuffer[3] = 0xFA; // точность

    packetBuffer[7] = 0; // задержка
    packetBuffer[8] = 0;
    packetBuffer[9] = 8;
    packetBuffer[10] = 0;

    packetBuffer[11] = 0; // дисперсия
    packetBuffer[12] = 0;
    packetBuffer[13] = 0xC;
    packetBuffer[14] = 0;
      
    getRtcDate();
    timestamp = numberOfSecondsSince1900Epoch(year,month,day,hour,minute,second);

#if debug
    Serial.println("Timestamp = " + (String)timestamp);
#endif

    tempval = timestamp;

    packetBuffer[12] = 71; //"G";
    packetBuffer[13] = 80; //"P";
    packetBuffer[14] = 83; //"S";
    packetBuffer[15] = 0; //"0";

    // Относительное время 
    packetBuffer[16] = (tempval >> 24) & 0xFF;
    tempval = timestamp;
    packetBuffer[17] = (tempval >> 16) & 0xFF;
    tempval = timestamp;
    packetBuffer[18] = (tempval >> 8) & 0xFF;
    tempval = timestamp;
    packetBuffer[19] = (tempval) & 0xFF;

    packetBuffer[20] = 0;
    packetBuffer[21] = 0;
    packetBuffer[22] = 0;
    packetBuffer[23] = 0;

    // Копируем метку времени клиента 
    packetBuffer[24] = packetBuffer[40];
    packetBuffer[25] = packetBuffer[41];
    packetBuffer[26] = packetBuffer[42];
    packetBuffer[27] = packetBuffer[43];
    packetBuffer[28] = packetBuffer[44];
    packetBuffer[29] = packetBuffer[45];
    packetBuffer[30] = packetBuffer[46];
    packetBuffer[31] = packetBuffer[47];

    // Метка времени 
    packetBuffer[32] = (tempval >> 24) & 0xFF;
    tempval = timestamp;
    packetBuffer[33] = (tempval >> 16) & 0xFF;
    tempval = timestamp;
    packetBuffer[34] = (tempval >> 8) & 0xFF;
    tempval = timestamp;
    packetBuffer[35] = (tempval) & 0xFF;

    packetBuffer[36] = 0;
    packetBuffer[37] = 0;
    packetBuffer[38] = 0;
    packetBuffer[39] = 0;

    // Записываем метку времени 
    packetBuffer[40] = (tempval >> 24) & 0xFF;
    tempval = timestamp;
    packetBuffer[41] = (tempval >> 16) & 0xFF;
    tempval = timestamp;
    packetBuffer[42] = (tempval >> 8) & 0xFF;
    tempval = timestamp;
    packetBuffer[43] = (tempval) & 0xFF;

    packetBuffer[44] = 0;
    packetBuffer[45] = 0;
    packetBuffer[46] = 0;
    packetBuffer[47] = 0;

    // Отправляем NTP ответ 
    Udp.beginPacket(remote, portNum);
    Udp.write(packetBuffer, NTP_PACKET_SIZE);
    Udp.endPacket();
  }
}

// Выводит отформатированноую дату
void printDate() {
  char sz[32];
  sprintf(sz, "%02d.%02d.%04d %02d:%02d:%02d.%03d", day, month, year+2000, hour, minute, second, hundredths);
  Serial.println(sz);
}

const uint8_t daysInMonth [] PROGMEM = { 31,28,31,30,31,30,31,31,30,31,30,31 }; // число дней в месяцах
const unsigned long seventyYears = 2208988800UL; // перевод времени unix в эпоху

// Формирует метку времени от момента 01.01.1900
static unsigned long int numberOfSecondsSince1900Epoch(uint16_t y, uint8_t m, uint8_t d, uint8_t h, uint8_t mm, uint8_t s) {
  if (y >= 1970) { y -= 1970; }
  uint16_t days = d;
  for (uint8_t i=1; i<m; ++i) {
    days += pgm_read_byte(daysInMonth + i - 1);
  }
  if (m>2 && y%4 == 0) { ++days; }
  days += 365 * y + (y + 3) / 4 - 1;
  return days*24L*3600L + h*3600L + mm*60L + s + seventyYears;
}

Функция getGpsTime() постоянно читает приходящие от ГНСС приёмника пакеты, и когда получает очередной пакет, проверяет, нет ли в нём валидных данных времени. Если время есть, то происходит его разбор. Также время можно сохранить в модуле RTC и таким образом проводить периодическую синхронизацию.

Проверка NMEA пакетов осуществляется в функции decodeTime().

Несколько слов о функции dec2hex(). В ней несколько извращённо число переводится из десятичного представления в 16-ное. Точнее, так. Модуль часов показывает время в виде, например, 16:52:08. Но здесь каждое из этих чисел не десятичное, а 16-ное. То есть, в действительности это время в RTC хранится так: 0x16:0x52:0x08. А с GPS-приёмника мы получаем время в десятичном формате. И чтобы записать те же 16 часов в модуль RTC, нужно преобразовать десятичное 16 в шестнадцатеричное 0x16, что является десятичным 22. А полное время 0x16:0x52:0x08 будет в десятичном представлении 22:82:08. Хм, 82 минуты, странно, да? :) Но такое уж надо сделать преобразование, чтобы модуль часов реального времени запомнил правильное время.

3 Программа для тестирования NTP сервера на Arduino

В приложении к статье имеется архив с программой тестирования NTP.

Главное окно программы тестирования NTP/SNTP
Главное окно программы тестирования NTP/SNTP

Всё, что требуется для проверки NTP сервера – это ввести адрес сервера и нажать кнопку «Отправить запрос». Соответственно, нужно знать адрес вашего NTP сервера на Arduino. Можно выбрать сервер из списка предложенных в меню («Выбрать сервер»).

Программа также позволяет запустить локальный NTP сервер. Время она будет брать из операционной системы. Данная возможность пригодится для каких-то отладочных целей.

Программа работает под ОС Windows с установленным .NET Framework версии 3.5 или выше.

Last modified onВторник, 09 Январь 2024 20:17 Read 13423 times

Поблагодарить автора:

Поделиться

Print Friendly, PDF & Email

15 comments

  • Вадим
    Вадим Пятница, 24 Апрель 2020 17:34 Ссылка на комментарий

    Добрый день! Наткнулся на Вашу статью по NTP серверу, решил собрать, т.к все для этого было, но возникла сложность с библиотекой EthernetUdp, которая присутствует в скетче. Где ее можно скачать? В интернете найти не удалось.

  • aave1
    aave1 Пятница, 24 Апрель 2020 17:39 Ссылка на комментарий

    Добрый день, Вадим!
    EthernetUdp - это один из заголовочных файлов, который присутствует в библиотеке Ethernet. Эта библиотека присутствует в Arduino IDE "из коробки". Ничего специально скачивать не нужно. Просто добавьте импорт, как написано в скетче, и скетч должен скомпилироваться без проблем.

  • Вадим
    Вадим Суббота, 25 Апрель 2020 21:12 Ссылка на комментарий

    Да с этим, разобрался, но почему то при подключении к ПК, пропинговать его не могу и когда к роутеру подключаю с этим скетчем я его не вижу в устройствах подключенных к роутеру, хотя подсеть такая же как у роутра 192.168.1.1, индикатор ЛАН загорается. А в сеть не проходит, то есть также пропинговать не могу, в чем может быть сложнось?

  • aave1
    aave1 Воскреснье, 26 Апрель 2020 08:34 Ссылка на комментарий

    Вероятно, неправильно инициализировался Ethernet-шилд, а точнее - микросхема Wiznet. Эти шилды бывают нескольких разновидностей: на микросхемах Wiznet W5100, W5500 и т.д. И они могут несколько отличаться друг от друга. Я бы в таком случае попробовал другую библиотеку, например, Ethernet2 (https://github.com/adafruit/Ethernet2). Скетч при этом останется без изменений, кроме того, что в секции импорта будет указана другая библиотека.

  • Вадим
    Вадим Вторник, 28 Апрель 2020 04:14 Ссылка на комментарий

    Заработало, спасибо!
    А можете подсказать есть ли такой же модуль реального времени только который считает миллисекунды? Если известны конкретные модели подскажите пожалуйста.

  • aave1
    aave1 Вторник, 28 Апрель 2020 11:32 Ссылка на комментарий

    Мне не известен такой модуль. И вряд ли вы вообще найдёте такой модуль RTC. Потому что получать время с точностью до миллисекунд, в общем-то, не имеет большого смысла. Ведь время на чтение показаний часов может занять десятки миллисекунд от момента формирования запроса до получения ответа, т.е. вся эта точность потеряется при обмене с Arduino.
    Можно подумать, например, вот в каком направлении с имеющимся модулем часов. Каждый из описанных модулей часов имеет выход меандра SQW. Можно настроить его на частоту 1 кГц, и подать этот сигнал на вход прерываний Arduino. Каждое прерываение будем инкрементировать счётчик, а на 1000-ный отсчёт сбрасывать. Тогда, считывая показания часов и прибавляя к ним значение счётчика, мы как раз и будем иметь миллисекундную точность.

  • kiv69
    kiv69 Суббота, 23 May 2020 12:37 Ссылка на комментарий

    Как-то не очень толково описано.
    Пытаюсь собрать на LGT8F328P и W5500 с 10-пиновым разъёмом.
    Если я понял правильно, надо переопределить пины SoftwareSerial на другие, например, 3 и 4, так как 10 и 11 уже используются для W5500.

  • kiv69
    kiv69 Понедельник, 25 May 2020 15:56 Ссылка на комментарий

    Блин, сколько времени потратил.
    Ладно, GPS не тот и протокол немного отличается. Переписал.
    При получении валидного пакета printDate() выводила корректные дату-время, но к записи в RTC не переходило. Поправил.
    Теперь вроде всё запустилось, но время выдаёт типа:
    Get date: 37.05.2032 21:73:52.000
    То есть ещё копать и копать. Либо с записью - чтением RTC проблемы, либо в программе что-то не то.

  • kiv69
    kiv69 Понедельник, 25 May 2020 16:23 Ссылка на комментарий

    Ага. При записи в RTC делаем конвертацию, а при чтении что, не надо?

  • kiv69
    kiv69 Понедельник, 25 May 2020 19:40 Ссылка на комментарий

    Всё, запустил. Спасибо за девайс. Хоть и пришлось посидеть над скетчем , но удовольствие от результата всё же огромное.
    Теперь в гараже и комп будет точное время получать, и часы настенные.

  • aave1
    aave1 Понедельник, 25 May 2020 19:48 Ссылка на комментарий

    kiv69, я рад, что вы разобрались! Удачи!)

  • Роман
    Роман Четверг, 18 Март 2021 18:39 Ссылка на комментарий

    Подскажите, пожалуйста, функция writeRtc(); содержится в void setup(), т.е. выполняется при старте программы
    каким образом потом часы RTC из которых мы отправляем врямя корректируются от данных полученных с GPS. т.е. вызов синхронизации где?

  • aave1
    aave1 Четверг, 18 Март 2021 18:51 Ссылка на комментарий

    Роман! Мы вызываем writeRtc() один раз при старте скетча. Синхронизация времени в данном примере не показана. Показанного функционала практически достаточно, чтобы добавить синхронизацию самостоятельно.

  • Роман
    Роман Суббота, 20 Март 2021 07:14 Ссылка на комментарий

    значит описанного события по тексту "то происходит его разбор, далее запоминание в модуле RTC" не происходит, зачем тогда это писать и вводить людей в заблуждение

  • aave1
    aave1 Суббота, 20 Март 2021 22:44 Ссылка на комментарий

    Согласен! Видимо, планировал добавить это в скетч, но не добавил. Спасибо, что обратили внимание. Доделаю, когда будет время.

Leave a comment