From 31720fff957b4d9ee8f784ca0ac27c1f7cd54454 Mon Sep 17 00:00:00 2001 From: Eugene Date: Mon, 26 Jan 2026 16:57:54 +0300 Subject: [PATCH] first commit --- grunt_stand.ino | 816 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 816 insertions(+) create mode 100644 grunt_stand.ino diff --git a/grunt_stand.ino b/grunt_stand.ino new file mode 100644 index 0000000..7bc0827 --- /dev/null +++ b/grunt_stand.ino @@ -0,0 +1,816 @@ +#include +#include +#include + +// LCD Keypad Shield 1602 pins +LiquidCrystal lcd(8, 9, 4, 5, 6, 7); + +// HX711 pins +const int LOADCELL_DOUT_PIN = A3; +const int LOADCELL_SCK_PIN = A2; + +// Button pin +const int BUTTON_PIN = 13; + +// Heat control pin +const int HEAT_PIN = 3; // D3 для управления нагревом + +// LCD Backlight pin +const int BACKLIGHT_PIN = 10; // D10 для подсветки LCD + +// Voltage measurement pin +const int VOLTAGE_PIN = A1; // Пин для измерения напряжения +const float VOLTAGE_DIVIDER = 5.0; // Делитель 1:10 (16V -> 1.6V на пине) +const float VREF = 5.0; // Опорное напряжение Arduino (5V) + +// Calibration +float calibration_factor = 1.0; +const float CALIBRATION_WEIGHT = 100.0; + +// EEPROM +const int EEPROM_CALIBRATION_ADDR = 0; + +// Весовые переменные +float current_weight = 0.0; +float displayed_weight = 0.0; +float m = 0.0; // Масса пустой кюветы +float m1 = 0.0; // Масса заполненной кюветы +float m0 = 0.0; // Текущая масса во время нагрева + +// Режимы работы +enum SystemState { + STATE_READY, + STATE_EMPTY_CUVETTE, + STATE_SAVING_EMPTY, + STATE_FILLED_CUVETTE, + STATE_SAVING_FILLED, + STATE_HEATING, + STATE_HEATING_COMPLETE +}; + +SystemState current_state = STATE_READY; +bool need_display_clear = true; + +// Таймеры для нагрева +unsigned long heat_start_time = 0; +const unsigned long HEAT_DURATION = 900000; // 15 минут = 900000 мс +bool heating_active = false; + +// Циклический нагрев +const unsigned long HEAT_ON_TIME = 30000; // 30 секунд нагрева +const unsigned long HEAT_OFF_TIME = 20000; // 20 секунд паузы +unsigned long last_cycle_change = 0; // Время последнего переключения +bool heat_cycle_on = false; // Текущее состояние цикла (вкл/выкл) + +// Для постоянного расчета W с фильтрацией +float current_W = 0.0; // Текущее значение влажности (сырое) +float filtered_W = 0.0; // Отфильтрованное значение W для отображения +float max_W = 0.0; // Максимальное значение W за время нагрева +unsigned long last_W_update = 0; +const unsigned long W_UPDATE_INTERVAL = 500; // Обновлять W каждые 500 мс + +// Button timing +bool button_pressed = false; +unsigned long button_press_time = 0; +const unsigned long MIN_TARE_TIME = 4000; // 4 секунды для тарирования +const unsigned long MAX_TARE_TIME = 6000; // 6 секунд максимум для тарирования +const unsigned long MIN_CAL_TIME = 8000; // 8 секунд для калибровки +const unsigned long MAX_CAL_TIME = 10000; // 10 секунд максимум для калибровки +bool showing_countdown = false; // Флаг отображения отсчета + +// Scale +HX711 scale; + +// === ФИЛЬТРЫ === + +// МЕДИАННЫЙ ФИЛЬТР +class MedianFilter { + private: + float buffer[7]; + int index = 0; + bool filled = false; + + public: + float update(float new_val) { + buffer[index] = new_val; + index = (index + 1) % 7; + if (index == 0) filled = true; + + float temp[7]; + int count = filled ? 7 : index; + for (int i = 0; i < count; i++) temp[i] = buffer[i]; + + for (int i = 0; i < count - 1; i++) { + for (int j = i + 1; j < count; j++) { + if (temp[i] > temp[j]) { + float t = temp[i]; + temp[i] = temp[j]; + temp[j] = t; + } + } + } + + return temp[count / 2]; + } + + void reset() { + index = 0; + filled = false; + } +}; + +MedianFilter median_filter; + +// ФИЛЬТР ВЫВОДА W (с дедбэндом для уменьшения дрейфа) +class WFilter { + private: + float filtered_value = 0.0; + float displayed_value = 0.0; + const float ALPHA = 0.15; // Коэффициент EMA (меньше = больше сглаживание) + const float DEADBAND = 0.01; // Игнорировать изменения меньше 0.3% + bool initialized = false; + + public: + float update(float raw_value) { + if (!initialized) { + filtered_value = raw_value; + displayed_value = raw_value; + initialized = true; + return displayed_value; + } + + // Экспоненциальное сглаживание (EMA) + filtered_value = ALPHA * raw_value + (1.0 - ALPHA) * filtered_value; + + // Дедбэнд: обновляем отображаемое значение только если изменение значительное + if (abs(filtered_value - displayed_value) >= DEADBAND) { + displayed_value = filtered_value; + } + + return displayed_value; + } + + float getValue() { + return displayed_value; + } + + void reset() { + filtered_value = 0.0; + displayed_value = 0.0; + initialized = false; + } + + void setImmediate(float value) { + filtered_value = value; + displayed_value = value; + initialized = true; + } +}; + +WFilter w_filter; + +// ФИЛЬТР ВЫВОДА веса +class DisplayFilter { + private: + float target_value = 0.0; + float current_display = 0.0; + const float SMOOTH_SPEED = 0.5; + + public: + float update(float new_value) { + target_value = new_value; + float diff = target_value - current_display; + if (abs(diff) < 0.01) { + current_display = target_value; + } else { + current_display += diff * SMOOTH_SPEED; + } + return current_display; + } + + void reset() { + current_display = 0.0; + target_value = 0.0; + } + + void setImmediate(float value) { + current_display = value; + target_value = value; + } +}; + +DisplayFilter display_filter; + +// EEPROM функции +void saveCalibrationToEEPROM(float factor) { + EEPROM.put(EEPROM_CALIBRATION_ADDR, factor); +} + +float loadCalibrationFromEEPROM() { + float factor = 1.0; + EEPROM.get(EEPROM_CALIBRATION_ADDR, factor); + + if (isnan(factor) || factor < 0.001 || factor > 10000.0) { + factor = 1.0; + } + + return factor; +} + +// Функция для измерения среднего значения +float measureAverageWeight(int samples = 10) { + float sum = 0; + for (int i = 0; i < samples; i++) { + float raw = scale.get_units(1); + float filtered = median_filter.update(raw); + sum += filtered; + delay(100); + } + return sum / samples; +} + +// Функция центрированного вывода текста +void printCentered(int row, String text) { + int spaces = (16 - text.length()) / 2; + lcd.setCursor(spaces, row); + lcd.print(text); +} + +// Функция центрированного вывода веса +void printWeightCentered(int row, float weight) { + char buffer[16]; + + if (weight >= 100.0) { + strcpy(buffer, "cuvette:>99.99g"); + } else if (weight <= -100.0) { + strcpy(buffer, "cuvette:<-99.99g"); + } else { + // Форматируем число с 2 знаками после запятой + char num_buf[8]; + dtostrf(fabs(weight), 0, 2, num_buf); // Без пробелов, 2 знака после запятой + + // Формируем полную строку + if (weight < 0) { + snprintf(buffer, sizeof(buffer), "cuvette:-%sg", num_buf); + } else { + snprintf(buffer, sizeof(buffer), "cuvette:%sg", num_buf); + } + } + + // Центрируем + int text_len = strlen(buffer); + int spaces = (16 - text_len) / 2; + lcd.setCursor(spaces, row); + lcd.print(buffer); +} + +// Функция расчета W +float calculateW() { + if (m0 > m && m1 > m) { + return 100.0 * (m1 - m0) / (m0 - m); + } + return 0.0; +} + +// Функция измерения напряжения (сырое значение) +float readVoltageRaw() { + int raw = analogRead(VOLTAGE_PIN); + float voltage_on_pin = (raw / 1023.0) * VREF; // Напряжение на пине (0-5V) + float actual_voltage = voltage_on_pin * VOLTAGE_DIVIDER; // Реальное напряжение с учетом делителя + return actual_voltage; +} + +// Фильтрованное напряжение (EMA) +float filtered_voltage = 0.0; +bool voltage_initialized = false; +const float VOLTAGE_ALPHA = 0.1; // Коэффициент EMA для напряжения (меньше = больше сглаживание) + +// Функция обновления и получения отфильтрованного напряжения +float updateVoltage() { + float raw_voltage = readVoltageRaw(); + + if (!voltage_initialized) { + filtered_voltage = raw_voltage; + voltage_initialized = true; + } else { + filtered_voltage = VOLTAGE_ALPHA * raw_voltage + (1.0 - VOLTAGE_ALPHA) * filtered_voltage; + } + + return filtered_voltage; +} + +void setup() { + Serial.begin(9600); + + delay(500); + + // LCD + lcd.begin(16, 2); + + // Настройка пина нагрева + pinMode(HEAT_PIN, OUTPUT); + digitalWrite(HEAT_PIN, LOW); + + // Яркость подсветки + pinMode(BACKLIGHT_PIN, OUTPUT); + analogWrite(BACKLIGHT_PIN, 180); + + // Button + pinMode(BUTTON_PIN, INPUT); + + // HX711 + scale.begin(LOADCELL_DOUT_PIN, LOADCELL_SCK_PIN); + scale.set_gain(128); + + // Быстрое тарирование + scale.tare(); + + // Загрузка калибровки + calibration_factor = loadCalibrationFromEEPROM(); + scale.set_scale(calibration_factor); + + // Стартовое сообщение + displayReadyScreen(); + + delay(1000); +} + +void loop() { + // Обработка кнопки + handleButton(); + + // Обновление веса и напряжения + static unsigned long last_weight_update = 0; + if (millis() - last_weight_update > 100) { + float raw = scale.get_units(1); + float median = median_filter.update(raw); + current_weight = median; + displayed_weight = display_filter.update(current_weight); + + // Обновляем отфильтрованное напряжение + updateVoltage(); + + // В режиме нагрева обновляем W только когда индуктор ВЫКЛЮЧЕН + // (чтобы избежать влияния электромагнитных помех на измерения) + if (current_state == STATE_HEATING && !heat_cycle_on) { + m0 = current_weight; // Обновляем текущую массу только при выключенном индукторе + + // Обновляем W каждые W_UPDATE_INTERVAL мс + if (millis() - last_W_update > W_UPDATE_INTERVAL) { + current_W = calculateW(); // Вычисляем сырое значение W + filtered_W = w_filter.update(current_W); // Фильтруем для отображения + + // Обновляем максимальное значение W + if (filtered_W > max_W) { + max_W = filtered_W; + } + + last_W_update = millis(); + } + } + + // Обновление дисплея в зависимости от состояния + updateDisplay(); + + last_weight_update = millis(); + } + + // Управление нагревом + if (current_state == STATE_HEATING) { + manageHeating(); + } + + delay(10); +} + +void displayReadyScreen() { + lcd.clear(); + printCentered(0, "Gruntometriya"); + printCentered(1, "ready"); +} + +void updateDisplay() { + if (need_display_clear) { + lcd.clear(); + need_display_clear = false; + } + + switch (current_state) { + case STATE_READY: + // Показываем центрированный текст + printCentered(0, "Gruntometriya"); + + // Вторая строка: "ready" и напряжение + { + char ready_line[17]; + char v_buf[5]; + dtostrf(filtered_voltage, 4, 1, v_buf); + snprintf(ready_line, sizeof(ready_line), "ready %sV", v_buf); + lcd.setCursor(0, 1); + lcd.print(ready_line); + } + + // Показываем отсчет в правом нижнем углу если нужно + if (showing_countdown) { + unsigned long hold_time = millis() - button_press_time; + int seconds = hold_time / 1000; + + // Отображаем отсчет в правом нижнем углу (позиция 13-15) + lcd.setCursor(13, 1); + if (seconds < 10) { + lcd.print(" "); + lcd.setCursor(15, 1); + lcd.print(seconds); + } else { + lcd.setCursor(14, 1); + lcd.print(seconds); + } + } + break; + + case STATE_EMPTY_CUVETTE: + printCentered(0, "hang an empty"); + // Во второй строке показываем текущий вес кюветы + printWeightCentered(1, displayed_weight); + break; + + case STATE_SAVING_EMPTY: + printCentered(0, "saving value"); + printCentered(1, "measuring..."); + + // Измерение и сохранение массы пустой кюветы + m = measureAverageWeight(10); + + delay(500); + current_state = STATE_FILLED_CUVETTE; + need_display_clear = true; + break; + + case STATE_FILLED_CUVETTE: + printCentered(0, "Hang the filled"); + // Во второй строке показываем текущий вес заполненной кюветы + printWeightCentered(1, displayed_weight); + break; + + case STATE_SAVING_FILLED: + printCentered(0, "saving value"); + printCentered(1, "measuring..."); + + // Измерение и сохранение массы заполненной кюветы + m1 = measureAverageWeight(10); + + delay(500); + current_state = STATE_HEATING; + need_display_clear = true; + heat_start_time = millis(); + heating_active = true; + heat_cycle_on = true; + last_cycle_change = millis(); + last_W_update = millis(); + w_filter.reset(); // Сбрасываем фильтр W при начале нагрева + max_W = 0.0; // Сбрасываем максимальное W + digitalWrite(HEAT_PIN, HIGH); + break; + + case STATE_HEATING: + displayHeating(); + break; + + case STATE_HEATING_COMPLETE: + displayHeatingComplete(); + break; + } +} + +void handleButton() { + int button_state = digitalRead(BUTTON_PIN); + + if (button_state == 0) { // Кнопка нажата + if (!button_pressed) { + button_pressed = true; + button_press_time = millis(); + showing_countdown = false; + } + + unsigned long hold_time = millis() - button_press_time; + + // Показываем отсчет начиная с 1 секунды (только в режиме READY) + if (current_state == STATE_READY && hold_time >= 1000 && !showing_countdown) { + showing_countdown = true; + need_display_clear = true; // Обновляем дисплей для показа отсчета + } + + // Обновляем отсчет каждую секунду (только в режиме READY) + if (current_state == STATE_READY && showing_countdown) { + // Обновляем отсчет когда меняется секунда + static int last_seconds = -1; + int seconds = hold_time / 1000; + + if (seconds != last_seconds && seconds <= 10) { + last_seconds = seconds; + need_display_clear = true; // Запрашиваем обновление дисплея + } + } + } + else { // Кнопка отпущена + if (button_pressed) { + unsigned long press_duration = millis() - button_press_time; + button_pressed = false; + + // Скрываем отсчет + if (showing_countdown) { + showing_countdown = false; + need_display_clear = true; // Запрашиваем обновление экрана + } + + // Определяем действие по времени нажатия (только в режиме READY) + if (current_state == STATE_READY) { + if (press_duration >= MIN_TARE_TIME && press_duration <= MAX_TARE_TIME) { + // Тарирование 4-6 секунд + performTaring(); + } + else if (press_duration >= MIN_CAL_TIME && press_duration <= MAX_CAL_TIME) { + // Калибровка 8-10 секунд + performCalibration(); + } + else if (press_duration < 1000) { + // Короткое нажатие + processShortPress(); + } + } else { + // Короткое нажатие в других состояниях + if (press_duration < 1000) { + processShortPress(); + } + } + } + } +} + +void processShortPress() { + switch (current_state) { + case STATE_READY: + current_state = STATE_EMPTY_CUVETTE; + need_display_clear = true; + break; + + case STATE_EMPTY_CUVETTE: + current_state = STATE_SAVING_EMPTY; + need_display_clear = true; + break; + + case STATE_SAVING_EMPTY: + // Сохранение уже произошло, состояние изменится автоматически + break; + + case STATE_FILLED_CUVETTE: + current_state = STATE_SAVING_FILLED; + need_display_clear = true; + break; + + case STATE_SAVING_FILLED: + // Сохранение уже произошло, состояние изменится автоматически + break; + + case STATE_HEATING: + // Прерывание нагрева - max_W уже содержит максимальное значение + digitalWrite(HEAT_PIN, LOW); + heating_active = false; + heat_cycle_on = false; + current_state = STATE_HEATING_COMPLETE; + need_display_clear = true; + break; + + case STATE_HEATING_COMPLETE: + // Возврат к началу + current_state = STATE_READY; + need_display_clear = true; + break; + } +} + +void displayHeating() { + // Первая строка: время, напряжение + unsigned long elapsed = millis() - heat_start_time; + unsigned long remaining = HEAT_DURATION - elapsed; + int minutes_remaining = (remaining / 60000) + 1; // +1 чтобы показывать целые минуты + if (minutes_remaining < 0) minutes_remaining = 0; + + char line1[17]; + char v_buf[5]; + dtostrf(filtered_voltage, 4, 1, v_buf); + + // Формат: "Heat 15m 16.0V" + snprintf(line1, sizeof(line1), "Heat %2dm %sV", minutes_remaining, v_buf); + lcd.setCursor(0, 0); + lcd.print(line1); + + // Вторая строка: только максимальный W + char line2[17]; + char m_buf[6]; + + // Форматируем максимальный W + if (max_W >= 100) { + strcpy(m_buf, ">99"); + } else { + dtostrf(max_W, 5, 1, m_buf); + } + + // Формат: "Wmax: XX.X%" + snprintf(line2, sizeof(line2), "W:%s%%", m_buf); + + printCentered(1, line2); +} + +void displayHeatingComplete() { + // Первая строка + printCentered(0, "end of heating"); + + // Вторая строка с максимальным W (это и есть результат измерения влажности) + String w_text = "W: "; + if (max_W >= 100) { + w_text += ">99%"; + } else if (max_W >= 0) { + w_text += String(max_W, 1); + w_text += "%"; + } else { + w_text += "0.0%"; // Если max_W отрицательный, показываем 0 + } + + printCentered(1, w_text); +} + +void manageHeating() { + if (!heating_active) return; + + unsigned long elapsed = millis() - heat_start_time; + unsigned long cycle_elapsed = millis() - last_cycle_change; + + // Управление циклическим нагревом (30 сек ON / 20 сек OFF) + if (heat_cycle_on) { + // Если прошло 30 секунд нагрева - выключаем + if (cycle_elapsed >= HEAT_ON_TIME) { + heat_cycle_on = false; + last_cycle_change = millis(); + digitalWrite(HEAT_PIN, LOW); + } + } else { + // Если прошло 20 секунд паузы - включаем + if (cycle_elapsed >= HEAT_OFF_TIME) { + heat_cycle_on = true; + last_cycle_change = millis(); + digitalWrite(HEAT_PIN, HIGH); + } + } + + // Проверка завершения общего времени нагрева (15 минут) + if (elapsed >= HEAT_DURATION) { + // max_W уже содержит максимальное значение W за время нагрева + + // Гарантированно выключаем нагрев + digitalWrite(HEAT_PIN, LOW); + heat_cycle_on = false; + heating_active = false; + current_state = STATE_HEATING_COMPLETE; + need_display_clear = true; + } +} + +void performTaring() { + lcd.clear(); + printCentered(0, "Auto-Taring..."); + + scale.tare(); + median_filter.reset(); + display_filter.setImmediate(0.0); + w_filter.reset(); + + // Если был режим нагрева - выходим из него + if (heating_active) { + digitalWrite(HEAT_PIN, LOW); + heating_active = false; + heat_cycle_on = false; + } + + lcd.setCursor(0, 1); + lcd.print("Done!"); + delay(500); + + lcd.clear(); + need_display_clear = true; + + // Возвращаемся в состояние READY + current_state = STATE_READY; +} + +void performCalibration() { + calibrationProcedure(); +} + +void calibrationProcedure() { + lcd.clear(); + lcd.setCursor(0, 0); + lcd.print("Calibration "); + + // Если был режим нагрева - выходим из него + if (heating_active) { + digitalWrite(HEAT_PIN, LOW); + heating_active = false; + heat_cycle_on = false; + } + + // Шаг 1: Clear platform + lcd.setCursor(0, 1); + lcd.print("Clear platform "); + + // Ждем нажатия кнопки + while (digitalRead(BUTTON_PIN) == 1) { + delay(10); + } + while (digitalRead(BUTTON_PIN) == 0) { + delay(10); + } + delay(100); + + // Сообщение о тарировании + lcd.setCursor(0, 1); + lcd.print("Taring... "); + + // Тарируем весы (устанавливаем ноль) + delay(500); // Даем время на стабилизацию + scale.tare(); + + // Шаг 2: Set 100g + lcd.setCursor(0, 1); + lcd.print("Set 100g "); + + // Ждем нажатия кнопки + while (digitalRead(BUTTON_PIN) == 1) { + delay(10); + } + while (digitalRead(BUTTON_PIN) == 0) { + delay(10); + } + delay(100); + + // Сообщение о сохранении результатов 100г + lcd.setCursor(0, 1); + lcd.print("saving results "); + + // Измеряем среднее значение 100г (15 измерений) + delay(1000); // Даем время на стабилизацию + float weight_sum = 0; + for (int i = 0; i < 15; i++) { + float reading = scale.get_units(1); + weight_sum += reading; + delay(100); + } + float weight_average = weight_sum / 15; + + // Вычисляем коэффициент калибровки + // После tare() шкала обнулена, поэтому weight_average - это разница веса напрямую + // Проверяем, что груз действительно поставлен (вес больше 10 единиц) + if (abs(weight_average) > 10) { + calibration_factor = weight_average / CALIBRATION_WEIGHT; + + // Проверяем, что коэффициент положительный + if (calibration_factor <= 0) { + // Ошибка: отрицательный или нулевой коэффициент - неправильная калибровка + // (датчик подключен неправильно или вес убрали вместо добавления) + lcd.clear(); + lcd.setCursor(0, 0); + lcd.print("Calibration "); + lcd.setCursor(0, 1); + lcd.print("ERROR: coef<0! "); + delay(2000); + } else { + // Сохраняем коэффициент в EEPROM + saveCalibrationToEEPROM(calibration_factor); + + // Устанавливаем новый коэффициент + scale.set_scale(calibration_factor); + + // Сообщение об успешной калибровке + lcd.clear(); + lcd.setCursor(0, 0); + lcd.print("Calibration "); + lcd.setCursor(0, 1); + lcd.print("OK! "); + delay(1000); + } + } else { + // Сообщение о неудачной калибровке (груз не поставлен или слишком легкий) + lcd.clear(); + lcd.setCursor(0, 0); + lcd.print("Calibration "); + lcd.setCursor(0, 1); + lcd.print("Failed! "); + delay(1000); + } + + lcd.clear(); + + // Возврат в режим READY + current_state = STATE_READY; + need_display_clear = true; +} \ No newline at end of file