Послесловие к базовым урокам


Вот и закончился базовый курс уроков программирования Arduino. Мы с вами изучили самые базовые понятия, вспомнили (или изучили) часть школьной программы по информатике, изучили большую часть синтаксиса и инструментов языка C++, и вроде бы весь набор Ардуино-функций, который предлагает нам платформа. Подчеркну – мы изучили C++ и функции Ардуино, потому что никакого “языка Arduino” нет, это ложное понятие. Arduino программируется на C или ассемблере, а платформа предоставляет нам всего лишь несколько десятков удобных функций для работы с микроконтроллером, именно функций, а не язык. Теперь перед нами чистый лист блокнота Arduino IDE и желание творить и программировать, давайте попробуем!

Структура программы


Прежде, чем переходить к реальным задачам, нужно поговорить о некоторых фундаментальных вещах. Микроконтроллер,  как мы обсуждали в самом начале пути, это комплексное устройство, состоящее из вычислительного ядра, постоянной и оперативной памяти и различных периферийных устройств (таймеры/счётчики, АЦП и проч.). Обработкой нашего с вами кода занимается именно ядро микроконтроллера, оно раздаёт команды остальным “железкам”, которые в дальнейшем могут работать самостоятельно. Ядро выполняет различные команды, подгоняемое тактовым генератором: на большинстве плат Arduino стоит генератор с частотой 16 МГц. Каждый толчок тактового генератора заставляет вычислительное ядро выполнить следующую команду, таким образом Ардуино выполняет 16 миллионов операций в секунду. Много ли это? Для большинства задач более чем достаточно, главное использовать эту скорость с умом.

Зачем я об этом рассказываю: микроконтроллер может выполнить только одну задачу в один момент времени, так как у него только одно вычислительное ядро, поэтому реальной “многозадачности” нет и быть не может, но за счёт большой скорости выполнения ядро может выполнять задачи по очереди, и для человека это будет казаться многозадачностью, ведь что для нас “раз Миссисипи“, для микроконтроллера – 16 миллионов действий!

Основной парадигмой работы с микроконтроллером является так называемый суперцикл, то есть главный цикл программы, который выполняется сверху вниз (если смотреть на код) и начинает с самого начала, когда доходит до конца, и так далее. В Arduino IDE нашим суперциклом является loop(). В главном цикле мы можем опрашивать датчики, управлять внешними устройствами, выводить данные на дисплеи, производить вычисления и всё такое, но в любом случае эти действия будут происходить друг за другом, последовательно.

Помимо цикла у нас есть прерывания, которые позволяют реализовать некую “поточность” выполнения задач, особенно в тех ситуациях, когда важна скорость работы. Прерывание позволяет остановить выполнение основного цикла в любом его месте, отвлечься на выполнение некоторого блока кода, и после успешного его завершения вернуться к основному циклу и продолжить работу. Некоторые задачи можно решить только на прерываниях, не написав ни одной строчки в цикл loop()! Мы с вами изучали аппаратные прерывания, позволяющие прерваться при замыкании контактов. Такие прерывания являются внешними, то есть их провоцируют внешние факторы (человек нажал на кнопку, сработал датчик, и т.д.). Также у микроконтроллера есть внутренние прерывания, которые вызываются периферией микроконтроллера, и этих прерываний может быть не один десяток!

Одним из таких прерываний является прерывание таймера: по умолчанию Arduino IDE настраивает один из таймеров на счёт реального времени, благодаря этому у нас работают такие функции как millis() и micros(). Именно эти функции являются готовым инструментом для тайм-менеджмента нашего кода.

Многозадачность?


Большинство примеров к различным модулям/датчикам используют задержку delay() в качестве “торможения” программы, например для вывода данных с датчика в последовательный порт. Именно такие примеры портят восприятие новичка, и он тоже начинает использовать задержки. А на задержках далеко не уедешь!

Давайте вспомним конструкцию таймера на millis() из урока о функциях времени: у нас есть переменная, которая хранит время прошлого “срабатывания” таймера. Мы вычитаем это время из текущего времени, эта разница постоянно увеличивается, и по условию мы можем поймать тот момент, когда пройдёт нужное нам время. Будем учиться избавляться от delay()! Начнём с простого: классический blink:

void setup() {
  pinMode(13, OUTPUT);  // пин 13 как выход
}

void loop() {
  digitalWrite(13, HIGH); // включить
  delay(1000);            // ждать
  digitalWrite(13, LOW);  // выключить
  delay(1000);            // ждать
}

Программа полностью останавливается на команде delay(), ждёт указанное время, а затем продолжает выполнение. Чем это плохо? (А вы ещё спрашиваете?) Во время этой остановки мы ничего не можем делать в цикле loop(), например не сможем опрашивать датчик 10 раз в секунду: задержка не позволит коду пойти дальше. Можно использовать прерывания (например – таймера), но о них мы поговорим в продвинутых уроках. Сейчас давайте избавимся от задержки в самом простом скетче.

Первым делом внесём такую оптимизацию: сократим код вдвое и избавимся от одной задержки, используя флаг:

boolean LEDflag = false;

void setup() {
  pinMode(13, OUTPUT);
}

void loop() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
  delay(1000);        // ждать
}

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

boolean LEDflag = false;
uint32_t myTimer; // переменная времени

void setup() {
  pinMode(13, OUTPUT);
}

void loop() {
  if (millis() - myTimer >= 1000) {
    myTimer = millis(); // сбросить таймер
    digitalWrite(13, LEDflag); // вкл/выкл
    LEDflag = !LEDflag; // инвертировать флаг
  }
}

Что здесь происходит: цикл loop() выполняется несколько сотен тысяч раз в секунду, как ему и положено, потому что мы убрали задержку. Каждую свою итерацию мы проверяем, не настало ли время переключить светодиод, не прошла ли секунда? При помощи этой конструкции и создаётся нужная многозадачность, которой хватит для 99% всех мыслимых проектов, ведь таких “таймеров” можно создать очень много!

boolean LEDflag = false;
// переменные времени
uint32_t myTimer, myTimer1, myTimer2;
uint32_t myTimer3;

void setup() {
  pinMode(13, OUTPUT);
  Serial.begin(9600);
}

void loop() {
  // каждую секунду
  if (millis() - myTimer >= 1000) {
    myTimer = millis(); // сбросить таймер
    digitalWrite(13, LEDflag); // вкл/выкл
    LEDflag = !LEDflag; // инвертировать флаг
  }

  // 3 раза в секунду
  if (millis() - myTimer1 >= 333) {
    myTimer1 = millis(); // сбросить таймер
    Serial.println("timer 1");
  }

  // каждые 2 секунды
  if (millis() - myTimer2 >= 2000) {
    myTimer2 = millis(); // сбросить таймер
    Serial.println("timer 2");
  }

  // каждые 5 секунд
  if (millis() - myTimer3 >= 5000) {
    myTimer3 = millis(); // сбросить таймер
    Serial.println("timer 3");
  }
}

Данный код всё ещё мигает светодиодом раз в секунду, но помимо этого он с разными промежутками времени отправляет сообщения в последовательный порт. Если открыть его, можно увидеть следующий текст:

timer 1
timer 1
timer 1
timer 1
timer 1
timer 1
timer 2
timer 1
timer 1
timer 1
timer 1
timer 1
timer 1
timer 2
timer 1
timer 1
timer 1
timer 3
timer 1
timer 1
timer 1
timer 2
timer 1

Это означает, что у нас спокойно работают 4 таймера с разным периодом срабатывания, работают “параллельно”, обеспечивая нам многозадачность: мы можем выводить данные на дисплей раз в секунду, и заодно опрашивать датчик 10 раз в секунду и усреднять его показания. Хороший пример для первого проекта! Давайте соберём простенькие метео-часы.

Как соединить несколько скетчей?

Общая информация


Чтобы соединить несколько проектов в один, нужно разобраться со всеми возможными конфликтами:

  • Проекты построены на одной и той же плате/платформе?
    • Да – отлично!
    • Нет – нужно убедиться, что “общая” плата сможет работать с железками, которые есть в объединяемых проектах, а также сама обладает нужной периферией.
  • Есть ли в проектах железки, подключенные к интерфейсам связи?
    • Нет – отлично!
    • Да, I2C – все железки подключаются на I2C общей платы. Убедитесь, что адреса устройств не совпадают (случается крайне редко)!
    • Да, SPI – у шины SPI все пины “общие”, кроме CS (Chip Select), этот пин может быть любым цифровым. Подробнее можно почитать вот тут.
    • Да, UART – беда, к UART может быть подключено только одно устройство. Можно повесить одну железку на аппаратный UART, а вторую на SoftwareSerial. Либо заморачиваться с мультиплексорами.
  • Есть ли пины, занятые в обоих проектах?
    • Нет – отлично!
    • Да – разобраться, какую функцию выполняет пин в каждом из проектов и подобрать замену, как в железе, так и в программе:
      • Если это обычный цифровой вход-выход, можно заменить на любой другой
      • Если это измерение аналогового сигнала – заменить на другой аналоговый пин
      • Если это генерация ШИМ, подключить соответственно на другой ШИМ пин и подкорректировать программу
      • Если это прерывание – быть внимательным
  • Используются ли одни и те же периферийные блоки микроконтроллера? Для этого нужно изучить железки и их библиотеки:
    • Нет – ОТЛИЧНО!
    • Да – ситуация требует хорошего опыта работы с Ардуино…
    • Используется один и тот же таймер – нельзя одновременно использовать ШИМ на ногах первого таймера и управление сервоприводами при помощи библиотеки Servo.h
    • Используется генерация звука при помощи tone() – нельзя использовать ШИМ на ногах второго таймера
    • Используются прерывания по таймеру и генерация ШИМ на соответствующем таймере – сложная ситуация
    • И т.д., ситуаций может быть бесконечно много…

Можно внести все правки в схемы и программы объединяемых проектов, чтобы они не конфликтовали. Далее приступаем к сборке общей программы:

  • Подключаем все библиотеки. Некоторые библиотеки могут конфликтовать, например Servo и Timer1, как обсуждалось выше.
  • Сравниваем имена глобальных переменных и дефайны в объединяемых программах: они не должны совпадать. Совпадающие меняем при помощи замены по коду (Правка/Найти) на другие. Далее копипастим все глобальные переменные и дефайны в общую программу
  • Объединяем содержимое блока setup()
  • Копипастим в общую программу все “пользовательские” функции
  • Остаётся у нас только loop(), и это самая сложная задача

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

  • Основной код (который в loop()) из разных проектов должен выполняться по очереди по таймеру
  • Набор действий из разных проектов должен переключаться кнопкой или ещё как-то
  • К одному проекту добавляется датчик  из другого проекта – данные нужно обработать и запрограммировать их дальнейшее движение (вывод на дисплей, отправку и т.д.)
  • Все “проекты” должны работать одновременно на одной Ардуине
  • И так далее

В большинстве случаев нельзя просто так взять и объединить содержимое loop() из разных программ, я надеюсь все это понимают. Даже мигалку и пищалку таким образом объединить не получится, если изначально код был написан с задержками или замкнутыми циклами. Кстати об этом: давайте посмотрим, как избавиться от цикла с задержками и сделать его “прозрачный” аналог, а также некоторые другие алгоритмы.

Структура программы


Рассмотрим несколько полезных алгоритмов для построения логики своей программы:

/*
   Делаем "параллельное" выполнение нескольких задач
   с разным периодом выполнения
*/

#define PERIOD_1 100    // период первой задачи
#define PERIOD_2 2000   // период второй задачи
#define PERIOD_3 666    // ...

unsigned long timer_1, timer_2, timer_3;

void setup() {

}

void loop() {
  if (millis() - timer_1 > PERIOD_1) {    // условие таймера
    timer_1 = millis();                   // сброс таймера
    
    // выполняем блок №1 каждые PERIOD_1 миллисекунд
  }
  if (millis() - timer_2 > PERIOD_2) {
    timer_2 = millis();
    
    // выполняем блок №2 каждые PERIOD_2 миллисекунд
  }
  if (millis() - timer_3 > PERIOD_3) {
    timer_3 = millis();
    
    // выполняем блок №3 каждые PERIOD_3 миллисекунд
  }
}

// Данный код выполняет действия периодически за указанный период

// Нам нужно задать период таймера В МИЛЛИСЕКУНДАХ
// дней*(24 часов в сутках)*(60 минут в часе)*(60 секунд в минуте)*(1000 миллисекунд в секунде)
// (long) обязательно для больших чисел, иначе не посчитает
// можно посчитать на калькуляторе, но какбэ ардуино и есть калькулятор, пусть считает...
unsigned long period_time = (long)5*24*60*60*1000;

// переменная таймера, максимально большой целочисленный тип (он же uint32_t)
unsigned long my_timer;

void setup() {
  my_timer = millis();   // "сбросить" таймер
}
void loop() {
  if ((long)millis() - my_timer > period_time) {
    my_timer = millis();   // "сбросить" таймер
    // набор функций, который хотим выполнить один раз за период
    // бла бла бла
    // ...
  }
}

/*
   Данный код демонстрирует переключение режимов работы при помощи кнопки
   Для удобства используется библиотека отработки нажатий кнопки
*/

#define PIN 3        // кнопка подключена сюда (PIN --- КНОПКА --- GND)
#define MODE_AM 5    // количество режимов (от 0 до указанного)

#include "GyverButton.h"
// моя библиотека для более удобной работы с кнопкой
// скачать мождно здесь https://github.com/AlexGyver/GyverLibs

GButton butt1(PIN);  // создаём нашу "кнопку"

byte mode = 0;       // переменная режима

void setup() {
  Serial.begin(9600);
}

void loop() {
  butt1.tick();             // обязательная функция отработки. Должна постоянно опрашиваться
  if (butt1.isPress()) {    // правильная отработка нажатия с защитой от дребезга

    // увеличиваем переменную номера режима. Если вышла за количество режимов - обнуляем
    if (++mode >= MODE_AM) mode = 0;
  }

  // всё переключение в итоге сводится к оператору switch
  switch (mode) {
    case 0: task_0();
      break;
    case 1: task_1();
      break;
    case 2: task_2();
      break;
    case 3: task_3();
      break;
    case 4: task_4();
      break;
  }
}

// наши задачи, внутри функций понятное дело может быть всё что угодно
void task_0() {
  Serial.println("Task 0");
}
void task_1() {
  Serial.println("Task 1");
}
void task_2() {
  Serial.println("Task 2");
}
void task_3() {
  Serial.println("Task 3");
}
void task_4() {
  Serial.println("Task 4");
}

/*
   Данный код демонстрирует переключение режимов работы при помощи кнопки
   Для удобства используется библиотека отработки нажатий кнопки
   В этом варианте примера функции "режимов" вызываются только один раз
*/

#define PIN 3        // кнопка подключена сюда (PIN --- КНОПКА --- GND)
#define MODE_AM 5    // количество режимов (от 0 до указанного)

#include "GyverButton.h"
// моя библиотека для более удобной работы с кнопкой
// скачать мождно здесь https://github.com/AlexGyver/GyverLibs

GButton butt1(PIN);  // создаём нашу "кнопку"

byte mode = 0;       // переменная режима

void setup() {
  Serial.begin(9600);
}

void loop() {
  butt1.tick();             // обязательная функция отработки. Должна постоянно опрашиваться
  if (butt1.isPress()) {    // правильная отработка нажатия с защитой от дребезга

    // увеличиваем переменную номера режма. Если вышла за количество режимов - обнуляем
    if (++mode >= MODE_AM) mode = 0;

    // всё переключение в итоге сводится к оператору switch
    // переключение и вызов происходит только при нажатии!!!
    switch (mode) {
      case 0: task_0();
        break;
      case 1: task_1();
        break;
      case 2: task_2();
        break;
      case 3: task_3();
        break;
      case 4: task_4();
        break;
    }
  }
}

// наши задачи, внутри функций понятное дело может быть всё что угодно
void task_0() {
  Serial.println("Task 0");
}
void task_1() {
  Serial.println("Task 1");
}
void task_2() {
  Serial.println("Task 2");
}
void task_3() {
  Serial.println("Task 3");
}
void task_4() {
  Serial.println("Task 4");
}

Допустим есть у нас задача: переключать режимы по одному и “по кругу”, в простейшем варианте это реализуется вот так:

#define MODE_AMOUNT 5
byte mode = 0;

void nextMode() {
  mode++;  // увеличиваем переменную номера режима
  if (mode >= MODE_AMOUNT) mode = 0;  // закольцовываем
}
// Время выполнения 0.5 мкс

Есть ещё парочка интересных вариантов. Результат не отличается, но сам механизм знать будет полезно:

// второй вариант. Время выполнения 0.5 мкс
if (++mode >= MODE_AMOUNT) mode = 0;  // тут инкремент внесён в условие, получаем более короткую запись

// третий вариант. Время выполнения 5.5 мкс. НЕ ИСПОЛЬЗУЙТЕ ЕГО!
mode = ++mode % MODE_AMOUNT;  // очень интересный вариант, без использования условия! Работает остаток от деления

Избавляемся от циклов и задержек


Как мигать светодиодом без задержки мы обсуждали выше. А как избавиться от цикла? Очень просто – цикл заменяется на счётчик и условие. Пусть у нас есть цикл for, выводящий значение счётчика:

for (int i = 0; i < 10; i++) {
  Serial.println(i);
}

Для избавления от цикла нам нужно сделать свою переменную-счётчик, поместить всё это дело в другой цикл (например, в loop) и самостоятельно увеличивать переменную и проверять условие:

int counter = 0;
void loop() {
  Serial.println(counter);
  counter++;
  if (counter >= 10) counter = 0;
}

И всё.

А как быть, если в цикле была задержка? Вот пример

for (int i = 0; i < 30; i++) {
  // например, зажигаем i-ый светодиод
  delay(100);
}

Нужно избавиться и от цикла, и от delay(). Введём таймер на millis(), и будем работать по нему:

int counter = 0;      // замена i
uint32_t timer = 0;   // переменная таймера
#define T_PERIOD 100  // период переключения

void loop() {
  
  if (millis() - timer >= T_PERIOD) { // таймер на millis()
    timer = millis(); // сброс
    // действие с counter - наш i-ый светодиод например
    counter++;  // прибавляем счётчик
    if (counter > 30) counter = 0;  // закольцовываем изменение
  }
  
}

Вот собственно и всё! Вместо переменной цикла i у нас теперь свой глобальный счётчик counter, который бегает от 0 до 30 (в этом примере) с периодом 100 мс.

Пример “Метеостанция”


Сила Arduino как конструктора заключается в том, что абсолютно по любой железке вы сможете найти в Гугле подробное описание, библиотеку, схему подключения и пример работы: полностью готовый набор для интеграции в свой проект! Вернёмся к нашим метео-часам и попробуем “собрать” такой проект из скетчей-примеров, ведь именно для этого примеры и нужны! Нам понадобится:

  • Arduino NANO
  • Дисплей. Пусть будет LCD1602 с переходником на i2c
  • Модуль реального времени, возьмём DS3231
  • Термометр ds18b20

Начинаем гуглить информацию по подключению и примеру для каждой железки:

Из уроков из Гугла мы узнаём такую важную информацию, как схемы подключения: дисплей и часы подключаются к шине i2c, а датчик ds18b20 можно подключить в любой другой пин. Схема нашего проекта:

Качаем библиотеки для наших модулей и устанавливаем. Библиотеку дисплея нам дают прямо в статье: https://iarduino.ru/file/134.html, библиотеку для часов по своему опыту советую RTClib (та, что в статье – не очень удобная). В статье про датчик температуры нам рассказали про библиотеку DallasTemperature.h, ссылку – не дали. Ну чтож, поищем сами “DallasTemperature.h”, найдём по первой ссылке. Для неё нужна ещё библиотека OneWire, ссылку на неё дали в статье про термометр. Итого у нас должны быть установлены 4 библиотеки.

Сейчас наша цель – найти рабочие примеры для каждой железки, убедиться в их работоспособности и выделить для себя минимальный набор кода для управления модулем, это бывает непросто – в статьях бывают ошибки и просто нерабочий код: эти статьи чаще всего являются копипастой от людей, далёких от темы. Я взял пример работы с дисплеем из статьи, а вот часы и термометр пришлось смотреть в примерах библиотеки. Немного причешем примеры, оставим только нужные нам функции получения значений или вывода, я оставил всё что мне нужно в setup():

#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // Устанавливаем дисплей
// адрес может быть 0x27 или 0x3f
void setup() {
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея

  // Устанавливаем курсор на вторую строку и нулевой символ.
  lcd.setCursor(0, 1);
  lcd.print("Hello!");  // пишем
}
void loop() {
}

#include "RTClib.h"
RTC_DS3231 rtc;

void setup () {
  Serial.begin(9600);
  // проверка, подключен ли модуль
  if (! rtc.begin()) {
    Serial.println("Couldn't find RTC");
    while (1);
  }

  // установка времени равному времени компиляции
  // если у модуля был сброс питания!
  if (rtc.lostPower()) {
    Serial.println("RTC lost power, lets set the time!");
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }

  // вывод значений времени
  DateTime now = rtc.now();

  Serial.print(now.year(), DEC);
  Serial.print('/');
  Serial.print(now.month(), DEC);
  Serial.print('/');
  Serial.print(now.day(), DEC);
  Serial.print(" ");
  Serial.print(now.hour(), DEC);
  Serial.print(':');
  Serial.print(now.minute(), DEC);
  Serial.print(':');
  Serial.print(now.second(), DEC);
  Serial.println();
}

void loop () {
}

#include <OneWire.h>
#include <DallasTemperature.h>

#define ONE_WIRE_BUS 2
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

void setup() {
  Serial.begin(9600);
  sensors.begin();
  sensors.requestTemperatures(); // запрос температуры
  float tempC = sensors.getTempCByIndex(0);  // получаем
  Serial.println(tempC);  // выводим
}

void loop() {
}

Итак, примеры проверены, все модули работают корректно. Начнём соединять всё в один проект! Первым делом соединяем в начале скетча все библиотеки, объявленные объекты, типы данных и переменные. Для красоты и понятности – сортируем: сначала настройки (define), затем подключенные библиотеки, и в конце уже данные:

// НАСТРОЙКИ
#define ONE_WIRE_BUS 2  // пин ds18b20

// БИБЛИОТЕКИ
#include "RTClib.h"
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <OneWire.h>
#include <DallasTemperature.h>

// ОБЪЕКТЫ И ПЕРЕМЕННЫЕ
// адрес может быть 0x27 или 0x3f
LiquidCrystal_I2C lcd(0x3f, 16, 2); // Устанавливаем дисплей
RTC_DS3231 rtc;
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

void setup() {
}

void loop() {
}

Далее переносим инициализацию в setup():

void setup() {
  // дисплей
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея

  // термометр
  sensors.begin();

  // часы
  rtc.begin();
  // установка времени равному времени компиляции
  if (rtc.lostPower()) {
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
}

Отлично! Теперь самое сложное: нужно продумать логику работы программы. Разобьём на простые действия:

  • 1 раз в секунду – вывод часов (ЧЧ:ММ:СС) и актуального значения с датчика
  • 2 раза в секунду – мигание светодиодом на плате
  • 5 раз в секунду – измерение температуры и усреднение

Вот так будет выглядеть наш loop():

void loop() {
  // 2 раза в секунду
  if (millis() - myTimer1 >= 500) {
    myTimer1 = millis(); // сбросить таймер
    toggleLED();
  }

  // 5 раз в секунду
  if (millis() - myTimer2 >= 200) {
    myTimer2 = millis(); // сбросить таймер
    getTemp();
  }

  // каждую секунду
  if (millis() - myTimer3 >= 1000) {
    myTimer3 = millis(); // сбросить таймер
    redrawDisplay();
  }
}

Выполняемые по таймерам функции мы создадим и заполним

void toggleLED() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
}

void getTemp() {
  // суммируем температуру в общую переменную
  tempSum += sensors.getTempCByIndex(0);
  sensors.requestTemperatures();

  // счётчик измерений
  tempCounter++;
  if (tempCounter >= 5) { // если больше 5
    tempCounter = 0;  // обнулить
    temp = tempSum / 5; // среднее арифметическое
    tempSum = 0;  // обнулить
  }
}

void redrawDisplay() {
  // ВРЕМЯ
  DateTime now = rtc.now(); // получить время
  lcd.setCursor(0, 0);      // курсор в 0,0
  lcd.print(now.hour());    // часы
  lcd.print(':');
  // первый ноль для красоты
  if (now.minute() < 10) lcd.print(0);
  lcd.print(now.minute());
  lcd.print(':');
  // первый ноль для красоты
  if (now.second() < 10) lcd.print(0);
  lcd.print(now.second());

  // TEMP
  lcd.setCursor(11, 0);    // курсор в 11,0
  lcd.print("Temp:");

  lcd.setCursor(11, 1);    // курсор в 11,1
  lcd.print(temp);

  // ДАТА
  lcd.setCursor(0, 1);      // курсор в 0,1
  // первый ноль для красоты
  if (now.day() < 10) lcd.print(0);
  lcd.print(now.day());
  lcd.print('.');
  // первый ноль для красоты
  if (now.month() < 10) lcd.print(0);
  lcd.print(now.month());
  lcd.print('.');
  lcd.print(now.year());
}

Для функционирования таймеров и счёта температуры нам также понадобились глобальные переменные, запишем их до setup():

uint32_t myTimer1, myTimer2, myTimer3;
boolean LEDflag = false;
float tempSum = 0, temp;
byte tempCounter;

И в целом наш проект завершён! По мере разборок с термометром выяснилась интересная особенность: чтение сильно тормозит код, команда requestTemperatures() ждёт ответа датчика и блокирует выполнение кода, из-за чего часы не успевают идти 1 раз в секунду. Покопавшись в примерах, я нашёл асинхронный опрос датчика: в setup добавилась строчка sensors.setWaitForConversion(false); . Соответственно вот весь код проекта:

// НАСТРОЙКИ
#define ONE_WIRE_BUS 2  // пин ds18b20
// БИБЛИОТЕКИ
#include "RTClib.h"
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <OneWire.h>
#include <DallasTemperature.h>
// ОБЪЕКТЫ И ПЕРЕМЕННЫЕ
// адрес может быть 0x27 или 0x3f
LiquidCrystal_I2C lcd(0x3f, 16, 2); // Устанавливаем дисплей
RTC_DS3231 rtc;
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);
uint32_t myTimer1, myTimer2, myTimer3;
boolean LEDflag = false;
float tempSum = 0, temp;
byte tempCounter;
void setup() {
  Serial.begin(9600); // для отладки
  pinMode(13, 1);
  // дисплей
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея
  // термометр
  sensors.begin();
  sensors.setWaitForConversion(false);  // асинхронное получение данных
  // часы
  rtc.begin();
  // установка времени равному времени компиляции
  if (rtc.lostPower()) {
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
}
void loop() {
  // 2 раза в секунду
  if (millis() - myTimer1 >= 500) {
    myTimer1 = millis(); // сбросить таймер
    toggleLED();
  }
  // 5 раз в секунду
  if (millis() - myTimer2 >= 200) {
    myTimer2 = millis(); // сбросить таймер
    getTemp();
  }
  // каждую секунду
  if (millis() - myTimer3 >= 1000) {
    myTimer3 = millis(); // сбросить таймер
    redrawDisplay();
  }
}
void toggleLED() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
}
void getTemp() {
  // суммируем температуру в общую переменную
  tempSum += sensors.getTempCByIndex(0);
  sensors.requestTemperatures();
  // счётчик измерений
  tempCounter++;
  if (tempCounter >= 5) { // если больше 5
    tempCounter = 0;  // обнулить
    temp = tempSum / 5; // среднее арифметическое
    tempSum = 0;  // обнулить
  }
}
void redrawDisplay() {
  // ВРЕМЯ
  DateTime now = rtc.now(); // получить время
  lcd.setCursor(0, 0);      // курсор в 0,0
  lcd.print(now.hour());    // часы
  lcd.print(':');
  // первый ноль для красоты
  if (now.minute() < 10) lcd.print(0);
  lcd.print(now.minute());
  lcd.print(':');
  // первый ноль для красоты
  if (now.second() < 10) lcd.print(0);
  lcd.print(now.second());
  // TEMP
  lcd.setCursor(11, 0);    // курсор в 11,0
  lcd.print("Temp:");
  lcd.setCursor(11, 1);    // курсор в 11,1
  lcd.print(temp);
  // ДАТА
  lcd.setCursor(0, 1);      // курсор в 0,1
  // первый ноль для красоты
  if (now.day() < 10) lcd.print(0);
  lcd.print(now.day());
  lcd.print('.');
  // первый ноль для красоты
  if (now.month() < 10) lcd.print(0);
  lcd.print(now.month());
  lcd.print('.');
  lcd.print(now.year());
}

И вот так это выглядит в жизни

Как видите, ничего особо сложного здесь нет: взяли три примера “из гугла”, проверили их, сократили, и объединили в проект. Да, своего кода тоже пришлось написать прилично, но иначе и не бывает! В программировании главное – практика и наработка собственных “ходов” и алгоритмов, которые потом очень быстро пишутся: я написал код к этому уроку со скоростью печати, особо не задумываясь и не отлаживая, это на самом деле всё очень просто!

Важные страницы