Общие сведения
В этом проекте мы соберём систему автоматизированной нормализации кислотности раствора.
Видео
Нам понадобится
Аппаратная часть
- Микроконтроллер 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"); }
Обсуждение