Общие сведения
В этом проекте мы соберём систему автоматизированной нормализации кислотности раствора.
Видео
Нам понадобится
Аппаратная часть
- Микроконтроллер Piranha Set ESP32
- Две TREMA-кнопки
- Устройство преобразования UART-RS485
- Дисплей 1602 и I2C конвертер для связи с ним
- Блок, поддерживающий два перистальтических насоса, с подключением по протоколу RS485
- Датчик, предназначенный для измерения кислотности жидкости
- Блок питания
- Коннектор power jack Мама с клемником
- Провода для подключения устройств: USB, UTP, Макетные провода
Программная часть
- Arduino IDE 1.8.19
- ESP32 core (устанавливается в Arduino IDE, пункт меню Инструменты->Плата->Менеджер плат)
- Библиотека LiquidCrystal_I2C (Устанавливается в Arduino IDE, пункт меню "Скетч"->"Подключить библиотеку"->"Менеджер библиотек" или на клавиатуре <CTRL>+<SHIFT>+<L>)
- Библиотека Button2 (Устанавливается в Arduino IDE, пункт меню "Скетч"->"Подключить библиотеку"->"Менеджер библиотек" или на клавиатуре <CTRL>+<SHIFT>+<L>)
- Библиотека iarduino Modbus
- Библиотека iarduino MB Pump
- Библиотека iarduino MB pH
Про установку библиотек в Arduino IDE можно узнать по ссылке: https://wiki.iarduino.ru/page/Installing_libraries/
Подключение
Установим модули кнопок, преобразователь UART на Piranha Set ESP32. Подключим дисплей к I2C колодке
Соединим блоки Насоса и PH сенсора проводами.
Подключите питание RS-485 при помощи Блока питаия и коннектора. Включите блок питания в розетку, затем подключите контроллер к ПК при помощи USB шнура
Скетч проекта
// esp core - 2.0.11
// arduino ide - 1.8.19
#include <iarduino_Modbus.h> // 1.0.1
#include <iarduino_MB_Pump.h> // 1.0.3
#include <iarduino_MB_pH.h> // 1.2.3
#include <LiquidCrystal_I2C.h> // 1.1.4
#include <Button2.h> // 2.2.4
#include <SPIFFS.h>
#include <list> // для возможных ошибок
// макрос возможных настроек
#define SETTINGS\
X(PUMP_TIME, "HACOC (CEK):")\
X(INTERVAL, "")\
// гистерезис
constexpr float PH_HYST = 0.1;
// пользовательский тип настроек
typedef uint16_t setting_t;
// максимальный допустимый уровень pH
constexpr float MAX_PH_ALLOWED = 8.0;
// минимальный допустимый уровень pH
constexpr float MIN_PH_ALLOWED = 3.0;
// время работы насосов по умолчанию (сек)
constexpr setting_t DEFAULT_PUMP_TIME = 10;
// интервал нормализации (мин)
constexpr setting_t DEFAULT_INTERVAL = 30;
// дельта при смене интервала (настройки)
constexpr setting_t INTERVAL_DELTA = 5;
// максимальное время работы насосов (сек)
constexpr setting_t MAX_PUMP_TIME = 90;
// после какого значения сменить дельту (настройки)
constexpr setting_t PUMP_TIME_ACCEL = 10;
// максимальная дельта для насосов (настройки)
constexpr setting_t PUMP_TIME_HIGH_DELTA = 10;
// максимальный интервал нормализации (мин)
constexpr setting_t MAX_ALLOWED_INTERVAL = 90;
// минимальный интервал нормализации (мин)
constexpr setting_t MIN_ALLOWED_ITERVAL = 5;
// через сколько миллисекунд сохранять настройки после последнего нажатия на кнопку
constexpr unsigned USER_INTERACTED_DELAY = 2000;
// выход из настроек через (миллисекунды)
constexpr unsigned SETTINGS_TIMEOUT = 10000;
// вывод левой кнопки
constexpr unsigned BUTTON_LEFT = 13;
// вывод правой кнопки
constexpr unsigned BUTTON_RIGHT = 33;
// вывод управления Modbus
constexpr unsigned MB_DE = 18;
constexpr unsigned DEBOUNCE_TIME = 50;
// время зажатия для входа в настройки (миллисекунды)
constexpr unsigned HOLD_TIME = 1000;
// время повтора при зажатии кнопки
constexpr unsigned REPEAT_TIME = 200;
// глобальные переменные кнопок
volatile bool g_user_interacted = false;
bool g_left_pressed = false;
bool g_right_pressed = false;
bool g_left_released = false;
bool g_right_released = false;
bool g_left_holding = false;
bool g_right_holding = false;
unsigned g_count = 0;
// глобальные переменные текущего и целевого pH
float g_current_ph = 0.0;
float g_target_ph = 7.1;
// объекты оборудования
LiquidCrystal_I2C disp(0x27, 20, 4);
ModbusClient modbus(Serial2, MB_DE);
iarduino_MB_Pump pump(modbus);
iarduino_MB_pH ph_sensor(modbus);
using namespace std;
// возможные ошибки
typedef enum {
NO_PUMP,
NO_SENSOR,
SENSOR_ERROR,
BAD_SOLUTION
} rig_error_t;
// определение типа указателя на функцию c++
typedef function<void()> func_ptr;
// структура ошибки
struct Error {
Error(func_ptr a, rig_error_t e): action(a), what(e) {} // конструктор
func_ptr action; // функция ошибки
rig_error_t what; // тип ошибки
// переопределение оператора "меньше" для сортировки списка (list::sort())
const bool operator<(Error const& e) const
{
return (what < e.what);
}
// переопределение оператора сравнения для удаления из списка (list::unique())
const bool operator==(Error const& e) const
{
return (what == e.what);
}
};
// список ошибок
list<Error> g_errors;
// объект файла настроек
fs::File g_file;
// объекты кнопок
Button2 leftButton;
Button2 rightButton;
// состояния меню
typedef enum {
MAIN,
CHANGE_TARGET,
SETTINGS_MENU,
ERROR_DISP
} state_t;
state_t menu_state = MAIN;
// возможные настройки (см. макрос SETTINGS)
typedef enum {
#define X(INDEX, STRING) INDEX,
SETTINGS
#undef X
N_SETTING
} settings_state_t;
// переопределение оператора для смены текущей настройки
settings_state_t& operator++(settings_state_t& s)
{
switch (s) {
default:
#define X(INDEX, STRING) \
case INDEX: s = static_cast<settings_state_t>(INDEX + 1); s == N_SETTING ? s = PUMP_TIME : 0; return s;
SETTINGS
#undef X
}
}
// первая настройка
settings_state_t current_item = PUMP_TIME;
// массив настроек
setting_t settings[N_SETTING]{0};
// возможные строки настроек (см. макрос SETTINGS)
const char* settings_strings[N_SETTING] = {
#define X(INDEX, STRING) STRING,
SETTINGS
#undef X
};
// название файла настроек
const char* settings_file = "/settings.cfg";
// декларация функции (для линкера)
void handleInput();
// определения пользовательских символов дисплея
#define sh 0
#define ts 1
#define yi 2
#define uu 3
#define ee 4
#define ll 5
#define pp 6
#define ff 7
uint8_t SH[8] = {0x15, 0x15, 0x15, 0x15, 0x15, 0x1f, 0x01, 0x00};
uint8_t TS[8] = {0x12, 0x12, 0x12, 0x12, 0x12, 0x1f, 0x01, 0x00};
uint8_t YI[8] = {0x04, 0x11, 0x11, 0x13, 0x15, 0x19, 0x11, 0x00};
uint8_t UU[8] = {0x11, 0x11, 0x11, 0x0f, 0x01, 0x01, 0x1e, 0x00};
uint8_t EE[8] = {0x11, 0x11, 0x11, 0x13, 0x15, 0x19, 0x11, 0x00};
uint8_t LL[8] = {0x07, 0x09, 0x09, 0x09, 0x09, 0x09, 0x11, 0x00};
uint8_t PP[8] = {0x1f, 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x00};
uint8_t FF[8] = {0x0e, 0x15, 0x15, 0x15, 0x0e, 0x04, 0x04, 0x00};
void setup()
{
Serial.begin(115200);
initMemory();
initButtons();
initDisplay();
initModbus();
initSettings();
handleSettingsFile();
}
void loop()
{
handleInput();
handleDisplay();
handleMenu();
handleRig();
handleErrors();
handleSettingsSave();
}
// текущая функция для ошибки
func_ptr g_current_error_func;
// обработка ошибок
void handleErrors()
{
static unsigned count = 0;
static unsigned index = 0;
if (g_errors.empty()) {
if (menu_state == ERROR_DISP)
menu_state = MAIN;
return;
}
menu_state = ERROR_DISP;
if (count++ % 500)
return;
Serial.println("Проверка ошибок");
g_errors.sort();
g_errors.unique();
if (g_errors.size()) {
disp.clear();
disp.setCursor(0,0);
auto it = next(g_errors.begin(), index++ % g_errors.size());
g_current_error_func = (*it).action;
}
}
// инициализация памяти
void initMemory()
{
if (!SPIFFS.begin()) {
Serial.println("Форматируем память, нужно подождать...");
disp.setCursor(0,0);
printFormattingMemory();
SPIFFS.format();
}
if (!SPIFFS.begin()) {
Serial.println("Не получилось отформатировать, остановка.");
disp.setCursor(0,1);
printFormatError();
while(true)
;
}
}
// вывод на дисплей "ФОРМАТИРУЕМ ПАМЯТЬ"
void printFormattingMemory()
{
disp.write(ff);disp.print("OPMAT");disp.write(ee);disp.write(uu);disp.print("EM ");
disp.write(pp);disp.print("AMATb...");
}
// вывод на дисплей "Error. ОСТАНОВКА."
void printFormatError()
{
disp.print("Error. "); disp.print("OCTAHOBKA.");
}
// инициализация кнопок
void initButtons()
{
leftButton.begin(BUTTON_LEFT, INPUT, false);
rightButton.begin(BUTTON_RIGHT, INPUT, false);
leftButton.setReleasedHandler(released);
leftButton.setLongClickTime(HOLD_TIME);
leftButton.setLongClickDetectedHandler(longClickDetected);
rightButton.setReleasedHandler(released);
rightButton.setLongClickTime(HOLD_TIME);
rightButton.setLongClickDetectedHandler(longClickDetected);
}
// инициализация дисплея
void initDisplay()
{
disp.init();
disp.backlight();
disp.noBlink();
disp.print("CTAPTyEM....");
disp.createChar(0, SH);
disp.createChar(1, TS);
disp.createChar(2, YI);
disp.createChar(3, UU);
disp.createChar(4, EE);
disp.createChar(5, LL);
disp.createChar(6, PP);
disp.createChar(7, FF);
delay(500);
}
// вывод на дисплей "ТЕКУЩИЙ:"
void printCurrentWord()
{
disp.print("TEK");
disp.write(uu);
disp.write(sh);
disp.write(ee);
disp.write(yi);
disp.print(": ");
}
// вывод на дисплей "ЦЕЛЕВОЙ:"
void printTargetWord()
{
disp.write(ts);
disp.print("E");
disp.write(ll);
disp.print("E");
disp.print("BO");
disp.write(yi);
disp.print(": ");
}
// вывод на дисплей "НАСТРОЙКИ:"
void printSettingsWord()
{
disp.print("HACTPO");
disp.write(yi);
disp.print("K");
disp.write(ee);
disp.print(":");
}
// инициализация Modbus
void initModbus()
{
Serial2.begin(9600);
while (!Serial2)
;
modbus.begin();
pump.enableWDT(3000);
pump.begin();
ph_sensor.begin();
g_current_ph = ph_sensor.getPH();
}
unsigned long submenu_millis = 0;
bool g_already_in_settings = false;
// обработка состояний меню
void handleMenu()
{
if (menu_state == MAIN) {
handleMainMenu();
}
else if (menu_state == CHANGE_TARGET) {
handleChangeTargetMenu();
exitOnTimeOut();
}
else if (menu_state == SETTINGS_MENU) {
handleSettingsMenu();
exitOnTimeOut();
}
}
// обработка главного меню
void handleMainMenu()
{
if (!isLeftReleased() && !isRightReleased() && g_already_in_settings) {
//delay(200);
return;
}
g_already_in_settings = false;
if (isLeftPressed() || isRightPressed()) {
menu_state = CHANGE_TARGET;
submenu_millis = millis();
}
if (areBothHolding()) {
submenu_millis = millis();
menu_state = SETTINGS_MENU;
}
}
// обработка меню смены целевого уровня
void handleChangeTargetMenu()
{
if (isLeftPressed() || isLeftRepeating()) {
submenu_millis = millis();
incrementTargetLevel();
}
if (isRightPressed() || isRightRepeating()) {
submenu_millis = millis();
decrementTargetLevel();
}
if (areBothHolding()) {
menu_state = SETTINGS_MENU;
}
}
// обработка меню настроек
void handleSettingsMenu()
{
if (!isLeftReleased() && !isRightReleased() && !g_already_in_settings) {
return;
}
g_already_in_settings = true;
if (isLeftPressed() || isLeftRepeating()) {
submenu_millis = millis();
changeItem();
}
if (isRightPressed() || isRightRepeating()) {
submenu_millis = millis();
selectItem();
}
if (areBothHolding()) {
menu_state = MAIN;
}
}
unsigned long last_input_millis = 0;
bool saved = true;
// обработка сохранения настроек
void handleSettingsSave()
{
static state_t last_state = menu_state;
if (last_state != menu_state) {
last_input_millis = millis();
last_state = menu_state;
saved = false;
}
if (saved)
return;
if (millis() - last_input_millis > USER_INTERACTED_DELAY) {
saved = saveSettings();
}
}
// сохранение настроек
bool saveSettings()
{
try {
g_file = SPIFFS.open(settings_file, "w");
if (!g_file) {
Serial.println("Ошибка открытия файла");
return false;
}
for (auto& s:settings) {
g_file.print(s);
g_file.print('\n');
}
g_file.print(g_target_ph, 2);
g_file.flush();
g_file.close();
Serial.println("Настройки сохранены");
return true;
}
catch (...) {
Serial.println("Исключение при сохранении файла");
return false;
}
}
// выход по истечении времени на главный экран
void exitOnTimeOut()
{
if (millis() - submenu_millis > SETTINGS_TIMEOUT) {
menu_state = MAIN;
}
}
// выбор предмета настройки
void selectItem()
{
++current_item;
if (current_item > N_SETTING)
current_item = PUMP_TIME;
}
// изменение предмета настройки
void changeItem()
{
switch (current_item) {
default: break;
case PUMP_TIME: changePumpTime(PUMP_TIME); break;
case INTERVAL: changeInterval(); break;
}
}
// приращение целевого уровня
void incrementTargetLevel()
{
g_target_ph += 0.1;
if (g_target_ph > MAX_PH_ALLOWED)
g_target_ph = MAX_PH_ALLOWED;
}
// уменьшение целевого уровня
void decrementTargetLevel()
{
g_target_ph -= 0.1;
if (g_target_ph < MIN_PH_ALLOWED)
g_target_ph = MIN_PH_ALLOWED;
}
// изменение времени работы насоса
void changePumpTime(setting_t s)
{
static setting_t delta = 1;
setting_t tmp = settings[s];
if (tmp + delta > PUMP_TIME_ACCEL)
delta = PUMP_TIME_HIGH_DELTA;
if (tmp + delta > MAX_PUMP_TIME) {
tmp = delta = 1;
goto exit;
}
tmp += delta;
exit:
settings[s] = tmp;
}
// изменение интервала нормализации
void changeInterval()
{
setting_t tmp = settings[INTERVAL];
tmp += INTERVAL_DELTA;
if (tmp > MAX_ALLOWED_INTERVAL)
tmp = MIN_ALLOWED_ITERVAL;
settings[INTERVAL] = tmp;
}
// инициализация настроек
void initSettings()
{
settings[PUMP_TIME] = DEFAULT_PUMP_TIME;
settings[INTERVAL] = DEFAULT_INTERVAL;
}
// декларация функции парсинга настроек (для линкера)
void parseFile(fs::File&);
// обработка загрузки файла
void handleSettingsFile() try
{
if (!SPIFFS.exists(settings_file)) {
Serial.println("Нет файла настроек");
return;
}
g_file = SPIFFS.open(settings_file, "r");
if (!g_file) {
Serial.println("Не получилось открыть файл");
return;
}
parseFile(g_file);
g_file.close();
}
catch (...)
{
g_file.close();
Serial.println("Проблемы парсинга файла");
}
// парсинг файла настроек
void parseFile(fs::File& file)
{
int b = 0;
char buf[256];
String loaded_setting = "";
while (b != EOF) {
static int i = 0;
static settings_state_t index = PUMP_TIME;
b = file.read();
if (b == '\n') {
loaded_setting = String(buf);
//loaded_setting.trim();
settings[index] = static_cast<settings_state_t>(loaded_setting.toInt());
Serial.println(settings[index]);
++index;
i = 0;
memset(buf, '\0', 256);
continue;
}
else if (b == EOF) {
loaded_setting = String(buf);
//loaded_setting.trim();
g_target_ph = loaded_setting.toFloat();
Serial.println(g_target_ph);
i = 0;
memset(buf, '\0', 256);
continue;
}
buf[i++] = (byte)b;
}
}
// обработка ввода
void handleInput()
{
leftButton.loop();
rightButton.loop();
}
// функции обработки кнопок
bool isLeftPressed()
{
bool tmp = g_left_pressed;
g_left_pressed = false;
return tmp;
}
bool isRightPressed()
{
bool tmp = g_right_pressed;
g_right_pressed = false;
return tmp;
}
bool isLeftRepeating()
{
if (digitalRead(BUTTON_LEFT) == LOW) {
g_left_holding = false;
}
if (g_left_holding && repeatInterval())
return true;
else
return false;
}
bool isRightRepeating()
{
if (digitalRead(BUTTON_RIGHT) == LOW) {
g_right_holding = false;
}
if (g_right_holding && repeatInterval())
return true;
else
return false;
}
bool isLeftHolding()
{
return g_left_holding;
}
bool isRightHolding()
{
return g_left_holding;
}
bool isLeftReleased()
{
return !leftButton.isPressed();
}
bool isRightReleased()
{
return !rightButton.isPressed();
}
bool areBothHolding()
{
if (g_right_holding && g_left_holding && digitalRead(BUTTON_LEFT) && digitalRead(BUTTON_RIGHT))
return true;
else
return false;
}
unsigned long repeat_millis = 0;
bool repeatInterval()
{
if (millis() - repeat_millis > REPEAT_TIME) {
repeat_millis = millis();
return true;
}
else
return false;
}
state_t last_state = MAIN;
// обработка дисплея
void handleDisplay()
{
if (last_state != menu_state) {
disp.clear();
last_state = menu_state;
}
if (menu_state == MAIN) {
disp.setCursor(0,0);
printCurrentWord();
disp.print(g_current_ph, 2);
disp.setCursor(0,1);
printTargetWord();
disp.print(g_target_ph, 1);
}
else if (menu_state == CHANGE_TARGET) {
disp.setCursor(0,0);
printTargetWord();
disp.print(g_target_ph, 1);
}
else if (menu_state == SETTINGS_MENU) {
disp.setCursor(0,0);
printSettingsWord();
disp.setCursor(0,1);
printCurrentSetting();
setting_t curr_setting = settings[current_item];
String disp_setting = curr_setting >= 10 ? String(curr_setting) : " " + String(curr_setting);
disp.print(disp_setting);
disp.print(" ");
}
else if (menu_state == ERROR_DISP) {
disp.setCursor(0,0);
g_current_error_func();
}
}
// вывод текущей строки настроек
void printCurrentSetting()
{
switch (current_item) {
default: disp.print(settings_strings[current_item]); return;
case INTERVAL: disp.write(ee); disp.print("HTEPBA"); disp.write(ll); disp.print("(M"); disp.write(ee); disp.print("H):"); return;
}
}
// работа установки
constexpr unsigned long RIG_UPDATE_INTERVAL = 1000;
unsigned long rig_update_millis = 0;
// возможные состояния насосов
typedef enum {
A_ON,
B_WAIT,
B_ON,
C_WAIT,
C_ON
} pump_state_t;
unsigned long pump_millis = 0;
bool normalization_done = true;
bool low_ph = false;
bool hi_ph = false;
// обработка Modbus
void handleModbus()
{
pump.resetWDT();
checkForAbsentDevices();
}
// проверка устройств на отсутствие
void checkForAbsentDevices()
{
static unsigned count = 0;
if (count++ % 101)
return;
Serial.print("Проверяем устройства Modbus");
if (modbus.checkID(pump.getID() == DEVICE_MB_ABSENT))
g_errors.push_back(Error(printNoPump, NO_PUMP));
if (modbus.checkID(ph_sensor.getID()) == DEVICE_MB_ABSENT)
g_errors.push_back(Error(printNoSensor, NO_SENSOR));
}
// обработка установки
void handleRig()
{
if (millis() - rig_update_millis < RIG_UPDATE_INTERVAL)
return;
rig_update_millis = millis();
handleModbus();
updatePH();
checkPH();
checkNormalizationTime();
if (!normalization_done && (low_ph || hi_ph))
normalize();
}
// проверка pH
void checkPH()
{
if (g_current_ph + PH_HYST < g_target_ph)
low_ph = true;
else if (g_current_ph - PH_HYST > g_target_ph)
hi_ph = true;
else {
low_ph = false;
hi_ph = false;
}
}
// обновление текущего pH
void updatePH()
{
g_current_ph = ph_sensor.getPH();
}
// нормализация
void normalize()
{
if (low_ph)
pump.setTimeOn(PUMP_A, float(settings[PUMP_TIME]));
else if (hi_ph) {
pump.setTimeOn(PUMP_B, float(settings[PUMP_TIME]));
}
low_ph = false;
hi_ph = false;
normalization_done = true;
}
unsigned long normalize_millis = 0;
// проверка времени нормализации
void checkNormalizationTime()
{
if (millis() - normalize_millis > settings[INTERVAL]*1000*60) {
normalize_millis = millis();
normalization_done = false;
}
}
void longClickDetected(Button2& btn)
{
if (&btn == &leftButton)
g_left_holding = true;
if (&btn == &rightButton)
g_right_holding = true;
}
void released(Button2& btn)
{
if (&btn == &leftButton) {
if (g_left_holding)
g_left_holding = false;
else
g_left_pressed = true;
}
if (&btn == &rightButton) {
if (g_right_holding)
g_right_holding = false;
else
g_right_pressed = true;
}
}
void printNoSensor()
{
disp.print("HET CEHCOPA");
}
void printNoPump()
{
disp.print("HET HACOCA");
}
// сенсор неисправен
void printSensorError()
{
disp.print("CEHCOP HE");disp.write(ee);disp.write('C');disp.write(pp);disp.print("PABEH");
}
// плохой раствор
void printBadSolution()
{
disp.write(pp);disp.write(ll);disp.print("OXO");disp.write(yi);disp.print(" PACTBOP");
}

Обсуждение