Загрузка данных


#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <OneWire.h>
#include <DallasTemperature.h>

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDRESS 0x3C

Adafruit_SSD1306 display(
  SCREEN_WIDTH,
  SCREEN_HEIGHT,
  &Wire,
  OLED_RESET
);


// ======================================================
// ПИНЫ
// ======================================================

const byte injectorPins[4] = {2, 3, 4, 5};

const byte injectorButtonPin = 6;

const byte relay1ButtonPin = 7;
const byte relay1Pin = 8;

const byte relay2ButtonPin = 9;
const byte relay2Pin = 10;

const byte temperaturePin = 11;

// Пассивный динамик HY-508
const byte buzzerPin = 12;

const byte timePotPin = A0;
const byte countPotPin = A1;


// ======================================================
// DS18B20
// ======================================================

OneWire oneWire(temperaturePin);
DallasTemperature sensors(&oneWire);

float currentTemperature = 0.0;

bool temperatureValid = false;
bool temperatureFault = false;


// ======================================================
// НАСТРОЙКИ ФОРСУНОК
// ======================================================

const unsigned int MIN_INJECTOR_TIME = 5;
const unsigned int MAX_INJECTOR_TIME = 25;

const int MIN_CYCLES = 10;
const int MAX_CYCLES = 100;


// ======================================================
// ТЕМПЕРАТУРНАЯ ЗАЩИТА
// ======================================================

const float OVERHEAT_TEMP = 65.0;
const float COOL_DOWN_TEMP = 60.0;


// ======================================================
// СЧЁТЧИК РЕЗКОГО ПОВЫШЕНИЯ ТЕМПЕРАТУРЫ
// ======================================================

const float TRIGGER_TEMP_RISE = 5.0;

const unsigned long TRIGGER_WINDOW = 2000UL;

const unsigned long TEMPERATURE_READ_TIME = 500UL;

float triggerStartTemperature = 0.0;

unsigned long triggerStartTime = 0;

unsigned long triggerCount = 0;

bool triggerArmed = true;


// ======================================================
// ВРЕМЕНА
// ======================================================

const unsigned long INJECTOR_LOCK_TIME = 10000UL;

const unsigned long RELAY1_TIME = 10000UL;

const unsigned long DEBOUNCE_TIME = 25UL;

const unsigned long DISPLAY_UPDATE_TIME = 100UL;


// ======================================================
// ПОТЕНЦИОМЕТРЫ
// ======================================================

const byte POT_SAMPLES = 8;

unsigned int injectorOpenTime = MIN_INJECTOR_TIME;

int injectorCycles = MIN_CYCLES;

int filteredTimeValue = 0;

int filteredCountValue = 0;


// ======================================================
// ФОРСУНКИ
// ======================================================

bool injectorRunning = false;

byte currentInjector = 0;

int currentCycle = 0;

unsigned long injectorStartTime = 0;


// ======================================================
// БЛОКИРОВКА КНОПКИ
// ======================================================

bool injectorButtonLocked = false;

unsigned long injectorUnlockTime = 0;

bool overheatLock = false;


// ======================================================
// РЕЛЕ 1
// ======================================================

bool relay1Active = false;

unsigned long relay1StartTime = 0;


// ======================================================
// КНОПКИ
// ======================================================

struct Button {

  byte pin;

  bool stableState;
  bool lastReading;

  unsigned long lastChangeTime;
};


Button injectorButton = {
  injectorButtonPin,
  HIGH,
  HIGH,
  0
};

Button relay1Button = {
  relay1ButtonPin,
  HIGH,
  HIGH,
  0
};

Button relay2Button = {
  relay2ButtonPin,
  HIGH,
  HIGH,
  0
};


// ======================================================
// OLED
// ======================================================

unsigned long lastDisplayUpdate = 0;


// ======================================================
// ЗВУКОВОЙ ДВИЖОК
// ======================================================
//
// Звуки проигрываются без delay().
// Поэтому Arduino продолжает выполнять основной код.
// ======================================================

struct SoundNote {

  unsigned int frequency;

  unsigned int duration;

  unsigned int pause;
};


enum SoundType {

  SOUND_NONE,

  SOUND_STARTUP,

  SOUND_BUTTON,

  SOUND_OVERHEAT,

  SOUND_ERROR
};


SoundType currentSound = SOUND_NONE;

byte currentSoundNote = 0;

unsigned long soundStartTime = 0;

bool soundPlaying = false;


// ======================================================
// НОТЫ
// ======================================================

// Запуск:
// ДО5 → МИ5 → СОЛЬ5 → ДО6
const SoundNote startupSound[] = {

  {523, 100, 25},
  {659, 100, 25},
  {784, 100, 25},
  {1047, 160, 20}
};


// Кнопка:
// короткое ПИ-ПИ
const SoundNote buttonSound[] = {

  {880, 75, 45},
  {1175, 110, 20}
};


// Перегрев:
// нисходящий тревожный сигнал
const SoundNote overheatSound[] = {

  {988, 120, 40},
  {784, 120, 40},
  {523, 200, 30}
};


// Ошибка датчика
const SoundNote errorSound[] = {

  {330, 140, 50},
  {330, 140, 50},
  {220, 220, 30}
};


// ======================================================
// ЗАПУСК ЗВУКА
// ======================================================

void playSound(SoundType type) {

  currentSound = type;

  currentSoundNote = 0;

  soundStartTime = millis();

  soundPlaying = true;


  const SoundNote* sound = getCurrentSound();

  if (sound != nullptr) {

    tone(
      buzzerPin,
      sound[0].frequency
    );
  }
}


// ======================================================
// ПОЛУЧИТЬ ТЕКУЩИЙ МАССИВ НОТ
// ======================================================

const SoundNote* getCurrentSound() {

  switch (currentSound) {

    case SOUND_STARTUP:
      return startupSound;

    case SOUND_BUTTON:
      return buttonSound;

    case SOUND_OVERHEAT:
      return overheatSound;

    case SOUND_ERROR:
      return errorSound;

    default:
      return nullptr;
  }
}


// ======================================================
// КОЛИЧЕСТВО НОТ
// ======================================================

byte getSoundLength() {

  switch (currentSound) {

    case SOUND_STARTUP:
      return sizeof(startupSound) /
             sizeof(startupSound[0]);

    case SOUND_BUTTON:
      return sizeof(buttonSound) /
             sizeof(buttonSound[0]);

    case SOUND_OVERHEAT:
      return sizeof(overheatSound) /
             sizeof(overheatSound[0]);

    case SOUND_ERROR:
      return sizeof(errorSound) /
             sizeof(errorSound[0]);

    default:
      return 0;
  }
}


// ======================================================
// ОБНОВЛЕНИЕ ЗВУКА
// ======================================================

void updateSound() {

  if (!soundPlaying)
    return;


  const SoundNote* sound =
    getCurrentSound();


  if (sound == nullptr) {

    noTone(buzzerPin);

    soundPlaying = false;

    currentSound = SOUND_NONE;

    return;
  }


  unsigned long elapsed =
    millis() - soundStartTime;


  unsigned long noteTotal =
    sound[currentSoundNote].duration +
    sound[currentSoundNote].pause;


  if (
    elapsed >=
    sound[currentSoundNote].duration
  ) {

    noTone(buzzerPin);
  }


  if (elapsed >= noteTotal) {

    currentSoundNote++;


    if (
      currentSoundNote >=
      getSoundLength()
    ) {

      noTone(buzzerPin);

      soundPlaying = false;

      currentSound = SOUND_NONE;

      return;
    }


    soundStartTime = millis();


    tone(
      buzzerPin,
      sound[currentSoundNote].frequency
    );
  }
}


// ======================================================
// РУССКИЙ ШРИФТ 5×7
// ======================================================

const byte glyphA[7] = {
  0b01110,
  0b10001,
  0b10001,
  0b11111,
  0b10001,
  0b10001,
  0b10001
};

const byte glyphB[7] = {
  0b11110,
  0b10001,
  0b10000,
  0b11110,
  0b10001,
  0b10001,
  0b11110
};

const byte glyphV[7] = {
  0b11110,
  0b10001,
  0b10001,
  0b11110,
  0b10001,
  0b10001,
  0b11110
};

const byte glyphG[7] = {
  0b11111,
  0b10000,
  0b10000,
  0b10000,
  0b10000,
  0b10000,
  0b10000
};

const byte glyphD[7] = {
  0b01110,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b11111
};

const byte glyphE[7] = {
  0b11111,
  0b10000,
  0b10000,
  0b11110,
  0b10000,
  0b10000,
  0b11111
};

const byte glyphZh[7] = {
  0b10101,
  0b10101,
  0b01110,
  0b00100,
  0b01110,
  0b10101,
  0b10101
};

const byte glyphZ[7] = {
  0b01110,
  0b00001,
  0b00001,
  0b00110,
  0b00001,
  0b00001,
  0b01110
};

const byte glyphI[7] = {
  0b10001,
  0b10001,
  0b10011,
  0b10101,
  0b11001,
  0b10001,
  0b10001
};

const byte glyphY[7] = {
  0b01010,
  0b00000,
  0b10001,
  0b10001,
  0b10011,
  0b10101,
  0b11001
};

const byte glyphK[7] = {
  0b10001,
  0b10010,
  0b10100,
  0b11000,
  0b10100,
  0b10010,
  0b10001
};

const byte glyphL[7] = {
  0b00111,
  0b01001,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b10001
};

const byte glyphM[7] = {
  0b10001,
  0b11011,
  0b10101,
  0b10101,
  0b10001,
  0b10001,
  0b10001
};

const byte glyphN[7] = {
  0b10001,
  0b10001,
  0b10001,
  0b11111,
  0b10001,
  0b10001,
  0b10001
};

const byte glyphO[7] = {
  0b01110,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b01110
};

const byte glyphP[7] = {
  0b11111,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b10001,
  0b10001
};

const byte glyphR[7] = {
  0b11110,
  0b10001,
  0b10001,
  0b11110,
  0b10000,
  0b10000,
  0b10000
};

const byte glyphS[7] = {
  0b01111,
  0b10000,
  0b10000,
  0b10000,
  0b10000,
  0b10000,
  0b01111
};

const byte glyphT[7] = {
  0b11111,
  0b00100,
  0b00100,
  0b00100,
  0b00100,
  0b00100,
  0b00100
};

const byte glyphU[7] = {
  0b10001,
  0b10001,
  0b01010,
  0b00100,
  0b00100,
  0b01000,
  0b10000
};

const byte glyphF[7] = {
  0b00100,
  0b01110,
  0b10101,
  0b10101,
  0b01110,
  0b00100,
  0b00100
};

const byte glyphH[7] = {
  0b10001,
  0b10001,
  0b01010,
  0b00100,
  0b01010,
  0b10001,
  0b10001
};

const byte glyphC[7] = {
  0b10010,
  0b10010,
  0b10010,
  0b10010,
  0b10010,
  0b10010,
  0b11111
};

const byte glyphSh[7] = {
  0b10101,
  0b10101,
  0b10101,
  0b10101,
  0b10101,
  0b10101,
  0b11111
};

const byte glyphYI[7] = {
  0b10001,
  0b10001,
  0b10001,
  0b11101,
  0b10011,
  0b10011,
  0b11101
};

const byte glyphSoft[7] = {
  0b10000,
  0b10000,
  0b10000,
  0b11110,
  0b10001,
  0b10001,
  0b11110
};


const byte* getGlyph(char c) {

  switch (c) {

    case 'А': return glyphA;
    case 'Б': return glyphB;
    case 'В': return glyphV;
    case 'Г': return glyphG;
    case 'Д': return glyphD;
    case 'Е': return glyphE;
    case 'Ж': return glyphZh;
    case 'З': return glyphZ;
    case 'И': return glyphI;
    case 'Й': return glyphY;
    case 'К': return glyphK;
    case 'Л': return glyphL;
    case 'М': return glyphM;
    case 'Н': return glyphN;
    case 'О': return glyphO;
    case 'П': return glyphP;
    case 'Р': return glyphR;
    case 'С': return glyphS;
    case 'Т': return glyphT;
    case 'У': return glyphU;
    case 'Ф': return glyphF;
    case 'Х': return glyphH;
    case 'Ц': return glyphC;
    case 'Ш': return glyphSh;
    case 'Ы': return glyphYI;
    case 'Ь': return glyphSoft;

    default:
      return nullptr;
  }
}


// ======================================================
// ВЫВОД РУССКОГО ТЕКСТА
// ======================================================

void printRussian(const char* text) {

  while (*text) {

    // Пробел
    if (*text == ' ') {

      display.setCursor(
        display.getCursorX() + 6,
        display.getCursorY()
      );

      text++;

      continue;
    }


    // Обычный ASCII
    if ((unsigned char)*text < 128) {

      display.write(*text);

      text++;

      continue;
    }


    // UTF-8
    unsigned char b1 =
      (unsigned char)text[0];

    unsigned char b2 =
      (unsigned char)text[1];


    char cyr = '?';


    if (b1 == 0xD0) {

      cyr =
        0x40 +
        (b2 - 0x90);
    }

    else if (b1 == 0xD1) {

      cyr =
        0x70 +
        (b2 - 0x80);
    }


    const byte* glyph =
      getGlyph(cyr);


    if (glyph != nullptr) {

      int x =
        display.getCursorX();

      int y =
        display.getCursorY();


      for (byte row = 0; row < 7; row++) {

        for (byte col = 0; col < 5; col++) {

          if (
            glyph[row] &
            (1 << (4 - col))
          ) {

            display.drawPixel(
              x + col,
              y + row,
              SSD1306_WHITE
            );
          }
        }
      }


      display.setCursor(
        x + 6,
        y
      );
    }

    else {

      display.write('?');
    }


    text += 2;
  }
}


// ======================================================
// SETUP
// ======================================================

void setup() {

  // Форсунки
  for (byte i = 0; i < 4; i++) {

    pinMode(
      injectorPins[i],
      OUTPUT
    );

    digitalWrite(
      injectorPins[i],
      LOW
    );
  }


  // Реле
  pinMode(
    relay1Pin,
    OUTPUT
  );

  pinMode(
    relay2Pin,
    OUTPUT
  );


  digitalWrite(
    relay1Pin,
    LOW
  );

  digitalWrite(
    relay2Pin,
    LOW
  );


  // Кнопки
  pinMode(
    injectorButtonPin,
    INPUT_PULLUP
  );

  pinMode(
    relay1ButtonPin,
    INPUT_PULLUP
  );

  pinMode(
    relay2ButtonPin,
    INPUT_PULLUP
  );


  // Динамик
  pinMode(
    buzzerPin,
    OUTPUT
  );

  noTone(buzzerPin);


  // OLED
  display.begin(
    SSD1306_SWITCHCAPVCC,
    OLED_ADDRESS
  );

  display.clearDisplay();

  display.setTextColor(
    SSD1306_WHITE
  );

  display.setTextSize(1);

  display.setCursor(0, 0);

  printRussian("ФОРСУНКИ");

  display.display();


  // DS18B20
  sensors.begin();

  sensors.setResolution(10);

  sensors.setWaitForConversion(true);


  // Первое измерение
  sensors.requestTemperatures();


  float t =
    sensors.getTempCByIndex(0);


  if (
    t !=
    DEVICE_DISCONNECTED_C
  ) {

    currentTemperature = t;

    temperatureValid = true;

    triggerStartTemperature = t;

    triggerStartTime = millis();
  }

  else {

    temperatureValid = false;

    temperatureFault = true;
  }


  // Небольшая пауза после старта
  delay(500);


  // Звук включения
  playSound(SOUND_STARTUP);
}


// ======================================================
// LOOP
// ======================================================

void loop() {

  // Звук обслуживаем постоянно
  updateSound();


  readSettings();


  updateButton(
    injectorButton
  );

  updateButton(
    relay1Button
  );

  updateButton(
    relay2Button
  );


  updateTemperature();

  updateInjectors();

  updateInjectorLock();

  updateRelay1();

  updateRelay2();

  updateDisplay();
}


// ======================================================
// ПОТЕНЦИОМЕТРЫ
// ======================================================

int readPotFiltered(byte pin) {

  long sum = 0;


  for (
    byte i = 0;
    i < POT_SAMPLES;
    i++
  ) {

    sum += analogRead(pin);
  }


  return sum / POT_SAMPLES;
}


void readSettings() {

  int newTimeValue =
    readPotFiltered(timePotPin);

  int newCountValue =
    readPotFiltered(countPotPin);


  if (
    newTimeValue <= 3
  ) {

    filteredTimeValue = 0;
  }

  else if (
    newTimeValue >= 1020
  ) {

    filteredTimeValue = 1023;
  }

  else if (
    abs(
      newTimeValue -
      filteredTimeValue
    ) >= 3
  ) {

    filteredTimeValue =
      newTimeValue;
  }


  if (
    newCountValue <= 3
  ) {

    filteredCountValue = 0;
  }

  else if (
    newCountValue >= 1020
  ) {

    filteredCountValue = 1023;
  }

  else if (
    abs(
      newCountValue -
      filteredCountValue
    ) >= 3
  ) {

    filteredCountValue =
      newCountValue;
  }


  injectorOpenTime =
    map(
      filteredTimeValue,
      0,
      1023,
      MIN_INJECTOR_TIME,
      MAX_INJECTOR_TIME
    );


  injectorCycles =
    map(
      filteredCountValue,
      0,
      1023,
      MIN_CYCLES,
      MAX_CYCLES
    );


  injectorOpenTime =
    constrain(
      injectorOpenTime,
      MIN_INJECTOR_TIME,
      MAX_INJECTOR_TIME
    );


  injectorCycles =
    constrain(
      injectorCycles,
      MIN_CYCLES,
      MAX_CYCLES
    );
}


// ======================================================
// КНОПКИ
// ======================================================

bool buttonPressed(Button &button) {

  bool reading =
    digitalRead(button.pin);

  unsigned long now =
    millis();


  if (
    reading !=
    button.lastReading
  ) {

    button.lastChangeTime =
      now;

    button.lastReading =
      reading;
  }


  if (
    now -
    button.lastChangeTime >=
    DEBOUNCE_TIME
  ) {

    if (
      reading !=
      button.stableState
    ) {

      button.stableState =
        reading;


      if (
        button.stableState ==
        LOW
      ) {

        return true;
      }
    }
  }


  return false;
}


void updateButton(Button &button) {

  // ====================================================
  // КНОПКА ФОРСУНОК
  // ====================================================

  if (
    &button ==
    &injectorButton
  ) {

    if (
      buttonPressed(button)
    ) {

      if (
        !injectorRunning &&
        !injectorButtonLocked &&
        !overheatLock &&
        temperatureValid &&
        !temperatureFault
      ) {

        // Звук подтверждения
        playSound(
          SOUND_BUTTON
        );


        startInjectorCycle();
      }

      else if (
        overheatLock
      ) {

        // При попытке запуска
        // во время перегрева —
        // тревожный звук
        playSound(
          SOUND_OVERHEAT
        );
      }
    }
  }


  // ====================================================
  // РЕЛЕ 1
  // ====================================================

  if (
    &button ==
    &relay1Button
  ) {

    if (
      buttonPressed(button)
    ) {

      if (
        !injectorRunning &&
        !relay1Active
      ) {

        relay1Active = true;

        relay1StartTime =
          millis();

        digitalWrite(
          relay1Pin,
          HIGH
        );
      }
    }
  }
}


// ======================================================
// ЗАПУСК ФОРСУНОК
// ======================================================

void startInjectorCycle() {

  if (!temperatureValid)
    return;

  if (temperatureFault)
    return;

  if (overheatLock)
    return;


  if (
    currentTemperature >=
    OVERHEAT_TEMP
  ) {

    overheatLock = true;

    injectorButtonLocked = true;

    playSound(
      SOUND_OVERHEAT
    );

    return;
  }


  injectorRunning = true;

  currentInjector = 0;

  currentCycle = 0;

  injectorButtonLocked = true;


  digitalWrite(
    injectorPins[
      currentInjector
    ],
    HIGH
  );


  injectorStartTime =
    micros();
}


// ======================================================
// ФОРСУНКИ
// ======================================================

void updateInjectors() {

  if (!injectorRunning)
    return;


  if (
    overheatLock ||
    temperatureFault ||
    !temperatureValid
  ) {

    for (
      byte i = 0;
      i < 4;
      i++
    ) {

      digitalWrite(
        injectorPins[i],
        LOW
      );
    }


    injectorRunning = false;

    injectorUnlockTime =
      millis();

    return;
  }


  unsigned long now =
    micros();


  unsigned long requiredTime =
    (unsigned long)
    injectorOpenTime *
    1000UL;


  if (
    now -
    injectorStartTime >=
    requiredTime
  ) {

    digitalWrite(
      injectorPins[
        currentInjector
      ],
      LOW
    );


    currentInjector++;


    if (
      currentInjector >= 4
    ) {

      currentInjector = 0;

      currentCycle++;


      if (
        currentCycle >=
        injectorCycles
      ) {

        injectorRunning = false;

        injectorUnlockTime =
          millis();

        return;
      }
    }


    digitalWrite(
      injectorPins[
        currentInjector
      ],
      HIGH
    );


    injectorStartTime =
      micros();
  }
}


// ======================================================
// БЛОКИРОВКА КНОПКИ
// ======================================================

void updateInjectorLock() {

  if (
    !injectorButtonLocked
  )
    return;


  if (overheatLock)
    return;


  if (injectorRunning)
    return;


  if (
    millis() -
    injectorUnlockTime >=
    INJECTOR_LOCK_TIME
  ) {

    injectorButtonLocked =
      false;
  }
}


// ======================================================
// РЕЛЕ 1
// ======================================================

void updateRelay1() {

  if (!relay1Active)
    return;


  if (
    millis() -
    relay1StartTime >=
    RELAY1_TIME
  ) {

    digitalWrite(
      relay1Pin,
      LOW
    );

    relay1Active = false;
  }
}


// ======================================================
// РЕЛЕ 2
// ======================================================

void updateRelay2() {

  if (
    relay2Button.stableState ==
    LOW
  ) {

    digitalWrite(
      relay2Pin,
      HIGH
    );
  }

  else {

    digitalWrite(
      relay2Pin,
      LOW
    );
  }
}


// ======================================================
// ТЕМПЕРАТУРА
// ======================================================

void updateTemperature() {

  static unsigned long
    lastTemperatureRead = 0;


  if (
    millis() -
    lastTemperatureRead <
    TEMPERATURE_READ_TIME
  ) {

    return;
  }


  lastTemperatureRead =
    millis();


  sensors.requestTemperatures();


  float newTemperature =
    sensors.getTempCByIndex(0);


  // ====================================================
  // ОШИБКА ДАТЧИКА
  // ====================================================

  if (
    newTemperature ==
    DEVICE_DISCONNECTED_C
  ) {

    // Чтобы не пищать каждые 500 мс
    static bool errorAlreadyReported =
      false;


    temperatureValid = false;

    temperatureFault = true;


    // Отключаем форсунки
    for (
      byte i = 0;
      i < 4;
      i++
    ) {

      digitalWrite(
        injectorPins[i],
        LOW
      );
    }


    injectorRunning = false;

    injectorButtonLocked = true;


    if (
      !errorAlreadyReported
    ) {

      playSound(
        SOUND_ERROR
      );

      errorAlreadyReported = true;
    }


    return;
  }


  // Датчик снова работает
  temperatureValid = true;

  temperatureFault = false;


  currentTemperature =
    newTemperature;


  // ====================================================
  // ПЕРЕГРЕВ
  // ====================================================

  if (
    currentTemperature >=
    OVERHEAT_TEMP
  ) {

    if (!overheatLock) {

      playSound(
        SOUND_OVERHEAT
      );
    }


    overheatLock = true;

    injectorButtonLocked =
      true;


    for (
      byte i = 0;
      i < 4;
      i++
    ) {

      digitalWrite(
        injectorPins[i],
        LOW
      );
    }


    injectorRunning = false;
  }


  // ====================================================
  // ОХЛАЖДЕНИЕ
  // ====================================================

  if (
    overheatLock &&
    currentTemperature <=
    COOL_DOWN_TEMP
  ) {

    overheatLock = false;

    injectorButtonLocked =
      false;
  }


  // ====================================================
  // СЧЁТЧИК РЕЗКОГО ПОВЫШЕНИЯ
  // ====================================================

  if (!triggerArmed) {

    if (
      currentTemperature <
      triggerStartTemperature - 1.0
    ) {

      triggerArmed = true;

      triggerStartTemperature =
        currentTemperature;

      triggerStartTime =
        millis();
    }


    return;
  }


  unsigned long now =
    millis();


  float rise =
    currentTemperature -
    triggerStartTemperature;


  // БОЛЕЕ 5 °C ЗА 2 СЕКУНДЫ
  if (
    rise > TRIGGER_TEMP_RISE &&
    now -
    triggerStartTime <=
    TRIGGER_WINDOW
  ) {

    triggerCount++;


    triggerArmed = false;

    triggerStartTemperature =
      currentTemperature;

    triggerStartTime =
      now;


    return;
  }


  // Окно закончилось —
  // начинаем новое
  if (
    now -
    triggerStartTime >
    TRIGGER_WINDOW
  ) {

    triggerStartTemperature =
      currentTemperature;

    triggerStartTime =
      now;
  }
}


// ======================================================
// OLED
// ======================================================

void updateDisplay() {

  if (
    millis() -
    lastDisplayUpdate <
    DISPLAY_UPDATE_TIME
  ) {

    return;
  }


  lastDisplayUpdate =
    millis();


  display.clearDisplay();

  display.setTextColor(
    SSD1306_WHITE
  );

  display.setTextSize(1);


  // ====================================================
  // НАСТРОЙКИ
  // ====================================================

  display.setCursor(0, 0);

  printRussian("ИМПУЛЬС:");

  display.print(
    injectorOpenTime
  );

  display.print("ms");


  display.setCursor(70, 0);

  printRussian("ЦИКЛЫ:");

  display.print(
    injectorCycles
  );


  // ====================================================
  // ТЕМПЕРАТУРА
  // ====================================================

  display.setCursor(0, 12);

  printRussian("ТЕМП:");

  if (temperatureValid) {

    display.print(
      currentTemperature,
      1
    );

    display.print("C");
  }

  else {

    printRussian("ОШИБКА");
  }


  // ====================================================
  // СОСТОЯНИЕ
  // ====================================================

  display.setCursor(0, 23);


  if (temperatureFault) {

    printRussian(
      "ОШИБКА ДАТЧИКА"
    );
  }

  else if (overheatLock) {

    printRussian(
      "ПЕРЕГРЕВ"
    );
  }

  else if (injectorRunning) {

    printRussian(
      "РАБОТА"
    );


    display.setCursor(70, 23);

    display.print(
      currentInjector + 1
    );

    display.print("/");

    display.print(
      currentCycle + 1
    );
  }

  else if (injectorButtonLocked) {

    printRussian(
      "БЛОК:"
    );


    unsigned long elapsed =
      millis() -
      injectorUnlockTime;


    unsigned long remaining = 0;


    if (
      elapsed <
      INJECTOR_LOCK_TIME
    ) {

      remaining =
        (
          INJECTOR_LOCK_TIME -
          elapsed +
          999
        ) / 1000;
    }


    display.print(
      remaining
    );

    display.print("s");
  }

  else {

    printRussian(
      "ГОТОВ"
    );
  }


  // ====================================================
  // РЕЛЕ
  // ====================================================

  display.setCursor(0, 35);

  display.print("R1:");

  display.print(
    relay1Active ?
    "ON" :
    "OFF"
  );


  display.setCursor(65, 35);

  display.print("R2:");

  display.print(
    relay2Button.stableState == LOW ?
    "ON" :
    "OFF"
  );


  // ====================================================
  // СЧЁТЧИК
  // ====================================================

  display.setCursor(0, 48);

  printRussian(
    "СРАБАТЫВАНИЙ:"
  );


  display.setCursor(0, 56);

  display.print(
    triggerCount
  );


  display.display();
}