Написание программы


В этом уроке мы поговорим о том, чем ардуинщики отличаются от программистов, почему они друг друга не понимают и не любят, а также чем “скетч” отличается от программы.

Начнем издалека: с языков программирования. Микроконтроллер сам по себе программируется на языке ассемблер (assembler, asm), причем у каждого МК есть свой ограниченный набор команд. Каждая команда выполняется за один такт процессора, есть программируя на асме мы имеем максимальный контроль за скоростью выполнения кода, его размером и вообще происходящими внутри МК процессами: именно поэтому он называется низкоуровневым языком. Программировать на ассемблере очень непросто, потому что набор команд очень небольшой, команды эти элементарные (не в плане использования, а в самой сути) и многие стандартные для других языков вещи приходится буквально изобретать заново и описывать вручную: вручную перемещаться по адресному пространству, манипулировать памятью и работать в обнимку с документацией на конкретный МК и знанием его архитектуры. Кода получается очень много и он выглядит мягко говоря непонятно:

// пример кода на асме
loop:
st X, %[set_hi]
sbrs %[LEDbuffer], 7
st X, %[set_lo]
lsl  %[LEDbuffer]
dec   %[counter]
rjmp .+0
rjmp .+0
rjmp .+0
brcc to_end
st  X,%[set_lo]
to_end:
brne  loop
: [counter] "=&d" (ctr)
: [LEDbuffer] "r" (*data_ptr++), "x" (ws2812_port_1)

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

Сейчас ассемблерный код генерируется компилятором из языков более высокого уровня, писать на которых легко и приятно: ту же Ардуину можно программировать на C, C++, Basic, а также на целой куче оболочек визуального программирования. Мы используем готовые и понятные функции и инструменты языка, пишем на них пару десятков или сотен строчек, а компилятор потом преобразует их в десятки тысяч строк уже ассемблерного кода, который будет работать на МК.

В Arduino IDE мы программируем на C++… или на C? Хороший вопрос, потому что фактически мы пишем на обоих: язык С++ является языком С, в который добавили классы, объекты, наследование и всё что с ними связано, С++ изначально даже назывался как “С с классами“. С++ позволяет использовать все прелести ООП – объектно ориентированного программирования, благодаря которому огромную программу можно организовать очень красиво, читаемо, можно создавать различные независимые друг от друга модули и библиотеки, выстроить струкруру проекта наиболее эффективно, а также обеспечить его удобную доработку и редактирование. А вот теперь мы переходим к части, где я скажу “вспомните все примеры из предыдущих уроков и исходники моих проектов. Так делать нельзя.

В общем и целом можно выделить два подхода к разработке программы: процедурный и объектно-ориентированный (есть ещё третий – Ардуино-стайл, когда все функции и переменные намешаны в одном файле). Если описывать в общих чертах, то процедурный подход представляет собой нагромождение функций и переменных, иногда раскиданных по отдельным файлам, а ООП – оборачивание всего кода в классы, которые взаимодействуют между собой и основной программой. В Ардуино-сообществе практически всегда встречается первый тип, потому что новичку так работать гораздо проще, программы в целом небольшие и несложные: сама основная программа пишется процедурно, но использует “библиотеки”, которые в основном являются классами. Все официальные и неофициальные примеры построены таким образом, да и сама Arduino IDE называет свои документы скетчами (от англ. sketch – набросок), потому что Ардуино задумана как платформа для обучения и быстрого прототипирования, а не для разработки крупных и серьезных проектов. В Arduino IDE вместо нормального менеджера документов у нас есть вкладки, которые работают не самым очевидным образом и явно сделаны для процедурного подхода. Давайте более подробно разберём особенности подходов.

ООП и процедурный подход


Порог вхождения

Мы начинаем мыслить и писать процедурно с самых первых уроков, потому что это просто. Работа с ООП требует гораздо более глубоких знаний С++, а эффективная работа – максимальных.

Объем кода

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

Вес и скорость выполнения кода

Компиляторы все время обновляются и улучшаются, даже встроенный в Arduino IDE avr-gcc: новые версии всё лучше оптимизируют код, делая его легче и быстрее. Тем не менее, большое количество вложенных друг в друга классов и длинные цепочки передачи данных будут неизбежно работать чуть медленнее и занимать больше места, чем при более компактном процедурном подходе.

Область определения и названия

В процедурном подходе приходится постоянно следить за использованием имён переменных и функций и вынужденно избегать повторений, по возможности прятать их внутри отдельных документов и всегда держать в голове. Маленькая ошибка, например использование глобальной переменной вместо локальной может привести к багам, которые трудно отследить. Оборачивая части кода в классы мы получаем грубо говоря отдельные программы, у которых названия функций и переменных отделены от остального кода и им все равно, что у других классов или в основной программе есть такие же.

Крупные проекты

Большую программу с кучей подпрограмм гораздо приятнее писать с ООП, причем не только писать, но и улучшать в будущем.

Мелкие проекты и прототипы

Написание небольшой программы в процедурном стиле гораздо легче и быстрее классического оопшного программирования на C++, именно поэтому Ардуино IDE это просто блокнот, который сохраняет скетч в своём расширении .ino, вместо взрослых .h и .cpp: нам не нужно думать о файловой структуре проекта, мы просто пишем код и всё. Для быстрого прототипирования и отладки небольших алгоритмов это работает отлично, но с крупным проектом могут начаться проблемы и неудобства.

Библиотеки и совместимость

Если вы откроете любую библиотеку для Ардуино, то с 99.9% вероятностью увидите там класс. Сила ООП в том, что мы берём библиотеку (или просто какой то класс), добавляем в свой код и не получаем никаких конфликтов имён и вообще пересечения кода (в 99% случаев): класс работает сам по себе, это отдельная программа. Если вы возьмёте например какой то кусок “голого” кода и вставите в свой такой же голый код – будет довольно большой шанс пересечений и конфликтов, а самое страшное – когда компилятор не видит ошибок, а программа работает неадекватно.

Что делать и как писать дальше?


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

Если в программе есть одинаковые “блоки”, требующие одинакового набора переменных – будет гораздо удобнее обернуть их в класс. Более того, со временем накопится свой набор таких мини-библиотек и их будет очень удобно использовать в дальнейшей работе. У меня в уроках есть урок по классам и по написанию библиотек, но там разобрана лишь небольшая и самая основная часть возможностей ООП. Для написания мощных и универсальных инструментов изучайте любые уроки С++, после изучения моих уроков вы уже будете к ним готовы и там всё будет понятно.

Также классы в C++ имеют такую мощную фишку, как наследование: один класс может наследовать возможности другого класса. Например, практически все библиотеки дисплеев, а также Serial и soft Serial имеют “всеядный” метод print(), который выводит переменные любых типов, умеет показывать число в разном представлении, форматировать вывод float чисел и прочее прочее. Интересный момент здесь в том, что все эти возможности реализованы в стандартном классе Print, который лежит среди остальных файлов в ядре Arduino IDE, и все остальные библиотеки просто наследуют все возможности вывода из него. Фактически в библиотеке дисплея/Serial должен быть реализован только write(), а абсолютно вся остальная универсальность вывода обеспечивается “сотрудничеством” с классом Print. В данном цикле уроков мы не будем разбирать наследование и другие инструменты ООП, потому что оно вряд-ли вам даже пригодится и уже прекрасно разобрано в любой книжке или любых уроках по C++ в интернете (мне нравятся уроки на сайте Ravesli).

Оборачивание в класс не является панацеей: если кусок программы не подразумевает создание и использование нескольких экземпляров себя, то его можно просто поместить его в отдельный файл, как набор функций и переменных. Глобальные переменные в этом случае следует делать статическими, чтобы их “не было видно” из других файлов программы.

Зачем и как с этим работать? При создании крупных проектов (да и вообще) следует придерживаться концепции “данные отдельно, код отдельно”, то есть глобальных переменных, которые находятся в области определения всей программы, быть не должно, как минимум их количество должно быть сведено к минимуму. Глобальные переменные могут быть спрятаны внутри файла, что обеспечивается ключевым словом static (переменные должны быть объявлены в .c или .cpp файле!), а делиться их значениями с остальным кодом программы и устанавливать новое значение можно при помощи отдельных функций. Как пример очень крупного проекта, сделанного с понятной файловой структурой и без использования ООП и классов – прошивка GRBL. Также большинство глобальных переменных можно запрятать внутри функций, где они нужны (то есть если они нужны только внутри конкретной функции) опять же при помощи static. Об этом мы говорили в самом начале, в уроке о типах данных.

Пример 1


Давайте рассмотрим пример превращения ужасного “винигретного” кода с кучей глобальных переменных и бардаком в главном цикле в понятную программу с отдельными независимыми подпрограммами.

В этом примере у нас подключены две кнопки (на пины D2 и D3) и светодиод (используем бортовой на пине D13). Напишем программу, которая будет мигать светодиодом и асинхронно опрашивать кнопки с программным гашением дребезга контактов. При помощи кнопок можно будет изменять частоту мигания светодиода. Подробно комментировать код нет смысла, потому что все используемые конструкции мы не раз разбирали в цикле уроков.

// пины
const byte btn1 = 2;
const byte btn2 = 3;
const byte led = 13;

// шаг изменения
const int step = 50;

// таймеры дебаунса кнопок
uint32_t btn1Tmr;
uint32_t btn2Tmr;

// флаги опроса кнопок
bool btn1Flag;
bool btn2Flag;

// переменные для светодиода
uint32_t ledTmr;
int ledPeriod = 1000; // начальный период 1 секунда
bool ledState = false;

void setup () {
  // настраиваем пины
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(led, OUTPUT);
}

void loop() {
  // таймер светодиода
  if (millis() - ledTmr >= ledPeriod) {
    ledTmr = millis();
    ledState = !ledState;
    digitalWrite(led, ledState);
  }

  // опрос первой кнопки с дебаунсом 100мс
  bool btn1State = digitalRead(btn1);
  if (!btn1State && !btn1Flag && millis() - btn1Tmr >= 100) {
    btn1Flag = true;    
    btn1Tmr = millis();
    ledPeriod += step;    // увеличить период
  }
  if (btn1State && btn1Flag) {
    btn1Flag = false;
    btn1Tmr = millis();
  }

  // опрос второй кнопки с дебаунсом 100мс
  bool btn2State = digitalRead(btn2);
  if (!btn2State && !btn2Flag && millis() - btn2Tmr >= 100) {
    btn2Flag = true;    
    btn2Tmr = millis();
    ledPeriod -= step;    // уменьшить период
  }
  if (btn2State && btn2Flag) {
    btn2Flag = false;
    btn2Tmr = millis();
  }
}

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

// класс кнопки
#pragma once
#include <Arduino.h>
#define _BTN_DEB_TIME 100  // таймаут антидребезга

class Button {
  public:
    Button (byte pin) : _pin(pin) {
      pinMode(_pin, INPUT_PULLUP);
    }
    bool click() {
      bool btnState = digitalRead(_pin);
      if (!btnState && !_flag && millis() - _tmr >= _BTN_DEB_TIME) {
        _flag = true;
        _tmr = millis();
        return true;
      }
      if (btnState && _flag) {
        _flag = false;
        _tmr = millis();
      }
      return false;
    }

  private:
    const byte _pin;
    uint32_t _tmr;
    bool _flag;
};

Обработчик кнопки теперь работает следующим образом: возвращает true, если соответствующая кнопка была нажата. В основной программе мы поместим метод click() в условие и будем изменять период светодиода по нему.

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

// мигающий светодиод
#pragma once
#include <Arduino.h>

void LEDinit(byte pin, int period);
void LEDblink();
void LEDadjust(int val);

#include "led.h"

// статические переменные будут "видны" только в этом файле
static int _period;
static byte _pin;
static uint32_t _tmr;
static bool _flag;

void LEDinit(byte pin, int period) {
  _pin = pin;
  _period = period;
  pinMode(_pin, OUTPUT);
}

void LEDblink() {
  if (millis() - _tmr >= _period) {
    _tmr = millis();
    _flag = !_flag;
    digitalWrite(_pin, _flag);
  }
}

void LEDadjust(int val) {
  _period += val;
}

Заметьте, что внутри файлов я использовал переменные с одинаковыми именами, но эти переменные я сделал либо статическими, либо спрятал в класс. Это очень удобно, потому что они никогда не пересекутся друг с другом и можно использовать одинаковые для обозначения переменных с схожим смыслом. Таким образом мы получили два обособленных модуля, две отдельных подпрограммы, с которыми можно взаимодействовать из основой программы. Изменение периода мигания светодиода я сделал через функцию LEDadjust(), которая принимает поправку к текущей величине. Начальная “текущая” величина задаётся при инициализации в LEDinit().

Ну чтож, положим наши библиотеки рядом с основным файлом программы, подключим их в код и посмотрим, как теперь выглядит наш проект:

// шаг изменения
const int step = 50;

// библиотека светодиода
#include "led.h"

// библиотека кнопки
#include "button.h"
Button btn1(2);
Button btn2(3);

void setup() {
  // указываем пин и стартовый период
  LEDinit(13, 1000);
}

void loop() {
  LEDblink();   // мигаем
  if (btn1.click()) LEDadjust(step);
  if (btn2.click()) LEDadjust(-step);
}

Ну вот, совсем другое дело! Теперь мухи у нас отдельно от котлет и можно аккуратно заниматься доработкой обоих модулей независимо друг от друга. Кстати, что по поводу размера кода? Самый первый пример занимает 1306 байт Flash и 26 байт оперативки, а новый… 1216 байт Flash и 29 байт оперативки. Объём кода (количество строк) увеличился, но его вес уменьшился на 100 байт! Дело в том, что у нас два экземпляра кнопки, которые опрашиваются по сути одинаково. Мы сделали опрос как метод класса, и компилятор не продублировал его на разные кнопки.

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

В обработчик кнопки добавим хитрое условие, которое будет возвращать true по таймеру, если кнопка удерживается, то есть ещё не была отпущена после нажатия. Таким образом можно будет изменять величину однократно “кликом”, либо удерживать и она будет изменятся ступенчато, как в любых китайских часах.

// класс кнопки
#pragma once
#include <Arduino.h>
#define _BTN_DEB_TIME 100  // таймаут антидребезга
#define _BTN_HOLD_TIME 400  // таймаут импульсного удержания

class Button {
  public:
    Button (byte pin) : _pin(pin) {
      pinMode(_pin, INPUT_PULLUP);
    }
    bool click() {
      bool btnState = digitalRead(_pin);
      if (!btnState && !_flag && millis() - _tmr >= _BTN_DEB_TIME) {
        _flag = true;
        _tmr = millis();
        return true;
      }
      if (!btnState && _flag && millis() - _tmr >= _BTN_HOLD_TIME) {
        _tmr = millis();
        return true;
      }
      if (btnState && _flag) {
        _flag = false;
        _tmr = millis();
      }
      return false;
    }

  private:
    const byte _pin;
    uint32_t _tmr;
    bool _flag;
};

Реализовать включение-выключение светодиода как “статус” всего программного модуля можно при помощи флага (основной метод blink() будет выполняться по нему), добавим его в переменные. Дергать флаг можно несколькими способами:

  • Сделать функцию toggle(), которая будет просто инвертировать флаг
  • Сделать функции enable() и disable(), которые будут включать и выключать флаг соответственно
  • Сделать установку и чтение текущего состояния

И так далее. Остановимся на ручной установке и чтении состояния как универсальном варианте.

// мигающий светодиод
#pragma once
#include <Arduino.h>

void LEDinit(byte pin, int period);
void LEDblink();
void LEDadjust(int val);
void LEDsetState(bool state);
bool LEDgetState();

#include "led.h"

// статические переменные будут "видны" только в этом файле
static int _period;
static byte _pin;
static uint32_t _tmr;
static bool _flag;
static bool _state = true;

void LEDinit(byte pin, int period) {
  _pin = pin;
  _period = period;
  pinMode(_pin, OUTPUT);
}

void LEDblink() {
  if (_state && millis() - _tmr >= _period) {
    _tmr = millis();
    _flag = !_flag;
    digitalWrite(_pin, _flag);
  }
}

void LEDadjust(int val) {
  _period += val;
}

void LEDsetState(bool state) {
  _state = state;
  if (_state) digitalWrite(_pin, 0);
}

bool LEDgetState() {
  return _state;
}

В основную программу добавим ещё одну кнопку на пин D4 и будем переключать состояние светодиода:

// шаг изменения
const int step = 50;

// библиотека светодиода
#include "led.h"

// библиотека кнопки
#include "button.h"
Button btn1(2);
Button btn2(3);
Button btn3(4);

void setup() {
  // указываем пин и стартовый период
  LEDinit(13, 1000);
}

void loop() {
  LEDblink();   // мигаем
  if (btn1.click()) LEDadjust(step);
  if (btn2.click()) LEDadjust(-step);
  if (btn3.click()) LEDsetState(!LEDgetState());
}

Теперь кнопки на пинах 2 и 3 кликом увеличивают и уменьшают частоту мигания светодиода, при удержании частота меняется автоматически с тем же шагом и настроенным в button.h периоде, а кликом по кнопке на пине 4 мы можем включить или выключить процесс мигания.

Таким образом у нас уже получились некоторые наработки, которые можно целиком, прямо файлом, вставить в другой проект и использовать. В этом и состоит прелесть ООП и вообще концепции отделения данных от кода и отказа от глобальных переменных для всей программы.

Пример 2


Далее давайте вспомним пример с метеостанцией из урока как написать скетч и попробуем его немного “причесать”: обернём всё в классы, раскидаем по отдельным файлам и отделим данные друг от друга. Хотя всё же сделаем класс для таймера на миллис, потому что там он используется в трёх местах, и при дальнейшей доработке могут понадобиться ещё таймеры. Исходный проект занимает 10078 байт Flash и 511 RAM.

// НАСТРОЙКИ
#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());
}

Итак, я обернул всё в классы, а объекты подключаемых внешних библиотек сделал статическими, чтобы их было “не видно” из основной программы и они не могли ни с чем перемешаться. Дисплея я трогать не стал, весь вывод на него остался как был, сам дисплей “подключается” в основном скетче.

// БИБЛИОТЕКИ
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2); // Устанавливаем дисплей

#include "led.h"
Led led(13);  // светодиод на пине 13

#include "timer.h"
Timer ledTimer(500);      // таймер светодиода на 500 мс
Timer tempTimer(800);     // таймер датчика 800 мс
Timer displayTimer(1000); // вывод на дисплей 1 сек

#include "realTime.h"
RealTime rtc;

#include "temperature.h"
Temperature dallas;

void setup() {
  Serial.begin(9600); // для отладки
  dallas.begin();
  rtc.begin();
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея
}
void loop() {
  if (ledTimer.ready()) led.toggle();
  if (tempTimer.ready()) dallas.filter();
  if (displayTimer.ready()) redrawDisplay();
}

void redrawDisplay() {
  // ВРЕМЯ
  rtc.update(); // получить время
  lcd.setCursor(0, 0);      // курсор в 0,0
  lcd.print(rtc.hour());    // часы
  lcd.print(':');
  
  // первый ноль для красоты
  if (rtc.minute() < 10) lcd.print(0);
  lcd.print(rtc.minute());
  lcd.print(':');
  
  // первый ноль для красоты
  if (rtc.second() < 10) lcd.print(0);
  lcd.print(rtc.second());
  
  // TEMP
  lcd.setCursor(11, 0);    // курсор в 11,0
  lcd.print("Temp:");
  lcd.setCursor(11, 1);    // курсор в 11,1
  lcd.print(dallas.get());
  
  // ДАТА
  lcd.setCursor(0, 1);      // курсор в 0,1
  
  // первый ноль для красоты
  if (rtc.day() < 10) lcd.print(0);
  lcd.print(rtc.day());
  lcd.print('.');
  
  // первый ноль для красоты
  if (rtc.month() < 10) lcd.print(0);
  lcd.print(rtc.month());
  lcd.print('.');
  lcd.print(rtc.year());
}

#pragma once
#include <Arduino.h>
// класс светодиода

class Led {
  public:
    // создать с указанием пина
    Led (byte pin) {
      _pin = pin;
      pinMode(_pin, OUTPUT);
    }

    // переключить состояние
    void toggle() {
      _state = !_state;
      digitalWrite(_pin, _state);
    }
  private:
    byte _pin;
    bool _state;
};

#pragma once
#include <Arduino.h>
// класс таймера на миллис

class Timer {
  public:
    // создать с указанием периода
    Timer (int period) {
      _period = period;
    }

    // возвращает true когда сработал период
    bool ready() {
      if (millis() - _tmr >= _period) {
        _tmr = millis();
        return true;
      }
      return false;
    }
  private:
    uint32_t _tmr;
    int _period;
};

#pragma once
#include <Arduino.h>
#include <RTClib.h>
#include <Wire.h>

class RealTime {
  public:
    void begin();
    void update();
    byte hour();
    byte minute();
    byte second();
    byte day();
    byte month();
    int year();
  private:
    byte _h, _m, _s;
    byte _day, _month;
    int _year;
};

#include "realTime.h"

static RTC_DS3231 rtc;

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

void RealTime::update() {
  DateTime now = rtc.now();
  _h = now.hour();
  _m = now.minute();
  _s = now.second();
  _day = now.day();
  _month = now.month();
  _year = now.year();
}

byte RealTime::hour() {
  return _h;
}

byte RealTime::minute() {
  return _m;
}

byte RealTime::second() {
  return _s;
}

byte RealTime::day() {
  return _day;
}

byte RealTime::month() {
  return _month;
}

int RealTime::year() {
  return _year;
}

#pragma once
// класс опроса и фильтрации датчика
#define ONE_WIRE_BUS 2  // пин ds18b20

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

class Temperature {
  public:
    void begin();
    void filter();
    float get();

  private:
    float tempSum = 0, temp = 0;
    byte tempCounter = 0;
};

#include "temperature.h"

static OneWire oneWire(ONE_WIRE_BUS);
static DallasTemperature sensors(&oneWire);

void Temperature::begin() {
  // термометр
  sensors.begin();
  sensors.setWaitForConversion(false);  // асинхронное получение данных
}

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

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

float Temperature::get() {
  return temp;
}

Да, кода стало сильно больше, писали мы его дольше, занимает он теперь 10322 и 539 байт Flash и RAM соответственно (на 240 и 28 байт больше), но наш скетч превратился в полноценный проект: можно заниматься доработкой каждого “модуля” отдельно и не бояться вмешаться в основной код, можно очень удобно заменить датчик или часы реального времени на любые другие и так далее! С таким кодом будет приятно и понятно работать даже спустя несколько лет, когда всё забудется, да и другому человеку будет проще в нём разобраться. В этом и состоит основная суть такого подхода к написанию крупных программ.

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