commit aef813e29ad914f9b86f1d9d6601714fb4dc157d Author: Eugene Date: Sat Jan 24 15:17:23 2026 +0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4da915 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Arduino build files +*.elf +*.hex +*.bin +*.eep + +# Build directories +build/ +.build/ +Release/ +Debug/ + +# Arduino IDE +*.arduino-cache/ +.arduino15/ + +# PlatformIO +.pio/ +.pioenvs/ +.piolibdeps/ + +# VSCode Arduino extension +.vscode/c_cpp_properties.json +.vscode/arduino.json +.vscode/launch.json + +# IDE settings +*.swp +*.swo +*~ + +# OS files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Backup files +*.bak +*.orig +*.tmp + +# Log files +*.log + +# Local configuration +secrets.h +config_local.h diff --git a/README.md b/README.md new file mode 100644 index 0000000..804f3cd --- /dev/null +++ b/README.md @@ -0,0 +1,175 @@ +# Тренажер "Бесконечная Лестница" + +Система управления тренажером "Бесконечная лестница" на базе Arduino Mega 2560. + +## Описание проекта + +Данный проект представляет собой систему управления тренажером с функциями: +- Управление скоростью двигателя (0-100%) +- Два режима тренировки: по времени и по дистанции +- Отображение параметров на LCD дисплее 16x2 +- Отображение скорости на LED матрице 8x MAX7219 +- Локальное управление кнопками +- Радиоуправление (7 кнопок) +- Система безопасности с аварийной остановкой +- Сохранение параметров в EEPROM + +## Необходимое оборудование + +- Arduino Mega 2560 +- LCD дисплей 16x2 с I2C модулем (адрес 0x27) +- LED матрица 8x MAX7219 +- Энкодер LPD3806-360BM-G5-24C +- Драйвер двигателя с PWM управлением +- Кнопки управления +- Приемник радиоуправления + +## Установка библиотек + +### Способ 1: Через менеджер библиотек Arduino IDE + +1. Откройте Arduino IDE +2. Перейдите в меню **Скетч** → **Подключить библиотеку** → **Управлять библиотеками...** +3. Установите следующие библиотеки: + +| Библиотека | Поиск в менеджере | Автор | +|------------|-------------------|-------| +| LiquidCrystal I2C | `LiquidCrystal I2C` | Frank de Brabander | +| LedControl | `LedControl` | Eberhard Fahle | + +### Способ 2: Ручная установка + +1. Скачайте библиотеки: + - [LiquidCrystal_I2C](https://github.com/johnrickman/LiquidCrystal_I2C) + - [LedControl](https://github.com/wayoda/LedControl) + +2. Распакуйте архивы в папку библиотек Arduino: + - **Windows:** `C:\Users\<Пользователь>\Documents\Arduino\libraries\` + - **Linux:** `~/Arduino/libraries/` + - **macOS:** `~/Documents/Arduino/libraries/` + +3. Перезапустите Arduino IDE + +### Встроенные библиотеки (устанавливать не нужно) + +Следующие библиотеки входят в состав Arduino IDE: +- `SPI.h` — для работы с SPI шиной (LED матрица) +- `Wire.h` — для работы с I2C шиной (LCD дисплей) +- `EEPROM.h` — для сохранения параметров + +## Прошивка Arduino + +### Шаг 1: Подготовка + +1. Установите [Arduino IDE](https://www.arduino.cc/en/software) (версия 1.8.x или 2.x) +2. Установите необходимые библиотеки (см. выше) +3. Подключите Arduino Mega 2560 к компьютеру через USB кабель + +### Шаг 2: Настройка Arduino IDE + +1. Откройте файл `stairs_arduino.ino` в Arduino IDE +2. Выберите плату: **Инструменты** → **Плата** → **Arduino Mega or Mega 2560** +3. Выберите процессор: **Инструменты** → **Процессор** → **ATmega2560 (Mega 2560)** +4. Выберите порт: **Инструменты** → **Порт** → выберите COM-порт Arduino + - **Windows:** `COM3`, `COM4` и т.д. + - **Linux:** `/dev/ttyACM0` или `/dev/ttyUSB0` + - **macOS:** `/dev/cu.usbmodem*` + +### Шаг 3: Загрузка прошивки + +1. Нажмите кнопку **Загрузка** (стрелка вправо) или используйте `Ctrl+U` +2. Дождитесь сообщения "Загрузка завершена" +3. Откройте **Монитор порта** (`Ctrl+Shift+M`) для проверки работы + - Установите скорость: **115200 бод** + +## Схема подключения + +### Управление двигателем +| Пин Arduino | Назначение | +|-------------|------------| +| 9 | PWM выход | +| 19 | Enable выход | + +### Кнопки локального управления +| Пин Arduino | Назначение | +|-------------|------------| +| 22 | Выбор режима | +| 23 | Увеличение параметра | +| 24 | Уменьшение параметра | +| 25 | Сброс параметров | +| 26 | Старт | +| 27 | Стоп | +| 30 | Уменьшение скорости | +| 31 | Увеличение скорости | +| 29 | Аварийная кнопка (грибок) | +| 32 | Лазерные датчики безопасности | + +### Радиоуправление +| Пин Arduino | Назначение | +|-------------|------------| +| 40 | Скорость + | +| 41 | Скорость - | +| 42 | Старт/Стоп | +| 43 | Выбор режима | +| 44 | Сброс | +| 45 | Уменьшение | +| 46 | Увеличение | + +### Датчики +| Пин Arduino | Назначение | +|-------------|------------| +| 2 | Энкодер A (INT0) | +| 3 | Энкодер B (INT1) | +| 33 | Концевой выключатель | + +### Дисплеи +| Пин Arduino | Назначение | +|-------------|------------| +| 20 (SDA) | LCD I2C данные | +| 21 (SCL) | LCD I2C тактирование | +| 51 (MOSI) | LED матрица DIN | +| 52 (SCK) | LED матрица CLK | +| 53 (SS) | LED матрица CS | + +### Светодиоды статуса +| Пин Arduino | Назначение | +|-------------|------------| +| 35 | Зеленый (работа) | +| 36 | Красный (стоп) | +| 37 | Аварийный | + +## Устранение неполадок + +### Arduino не определяется + +**Windows:** +- Установите драйвер CH340 (для китайских клонов Arduino) +- Проверьте диспетчер устройств + +**Linux:** +- Добавьте пользователя в группу `dialout`: + ```bash + sudo usermod -a -G dialout $USER + ``` +- Перезагрузите компьютер + +### Ошибка "avrdude: stk500v2_ReceiveMessage(): timeout" + +- Проверьте правильность выбора платы и порта +- Попробуйте другой USB кабель +- Нажмите кнопку Reset на Arduino перед загрузкой + +### LCD дисплей не работает + +- Проверьте I2C адрес (может быть 0x27 или 0x3F) +- Проверьте подключение SDA/SCL +- Настройте контрастность потенциометром на I2C модуле + +### LED матрица не работает + +- Проверьте подключение SPI (DIN, CLK, CS) +- Проверьте питание матрицы (5V) + +## Лицензия + +Проект разработан для КГЭУ (Казанский государственный энергетический университет). diff --git a/stairs_arduino.ino b/stairs_arduino.ino new file mode 100644 index 0000000..fa4dd00 --- /dev/null +++ b/stairs_arduino.ino @@ -0,0 +1,2568 @@ +// ============================================================================ +// ТРЕНАЖЕР БЕСКОНЕЧНАЯ ЛЕСТНИЦА - Complete Implementation +// ============================================================================ + +#include +#include +#include +#include +#include +#include + +// ============================================================================ +// PIN DEFINITIONS +// ============================================================================ + +// Motor Control +#define PWM_OUT 9 +#define ENABLE_OUT 19 + +// Local Control Panel Buttons +#define BTN_MODE_SELECT 22 +#define BTN_INCREASE 23 +#define BTN_DECREASE 24 +#define BTN_RESET_PARAMS 25 +#define BTN_START 26 +#define BTN_STOP 27 +#define BTN_SPEED_DOWN 30 +#define BTN_SPEED_UP 31 +#define BTN_EMERGENCY_MUSHROOM 29 +#define EMERGENCY_LASER_SENSORS 32 + +// Radio Remote Control (7 buttons) +#define RADIO_SPEED_UP 40 +#define RADIO_SPEED_DOWN 41 +#define RADIO_START_STOP 42 +#define RADIO_MODE_SELECT 43 +#define RADIO_RESET 44 +#define RADIO_DECREASE 45 +#define RADIO_INCREASE 46 + +// Sensors +#define ENCODER_A 2 // Interrupt capable (INT0) +#define ENCODER_B 3 // Interrupt capable (INT1) +#define LIMIT_SW_SIGNAL 33 + +// Display Systems +#define LCD_SDA 20 +#define LCD_SCL 21 +#define MATRIX_DIN 51 // SPI MOSI +#define MATRIX_CLK 52 // SPI SCK +#define MATRIX_CS 53 // SPI SS + +// Status LEDs +#define LED_GREEN 35 +#define LED_RED 36 +#define LED_EMERGENCY 37 + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const int SPEED_MIN = 0; +const int SPEED_MAX = 100; +const int SPEED_STEP = 1; + +const unsigned long DEBOUNCE_DELAY = 50; +const unsigned long REPEAT_DELAY = 500; +const unsigned long REPEAT_INTERVAL = 100; +const unsigned long EMERGENCY_BLINK_INTERVAL = 500; +const unsigned long COUNTDOWN_INTERVAL = 1000; // 1 second per countdown step + +// Encoder: LPD3806-360BM-G5-24C +// Specifications: 360 pulses per revolution, quadrature decoding gives 4x = 1440 counts per revolution +const int ENCODER_COUNTS_PER_REVOLUTION = 1440; // Quadrature decoding: 360 pulses * 4 edges = 1440 counts +const float ENCODER_METERS_PER_REVOLUTION = 0.350f; // 350mm = 0.35m per revolution (mechanical) +const float ENCODER_METERS_PER_COUNT = ENCODER_METERS_PER_REVOLUTION / ENCODER_COUNTS_PER_REVOLUTION; // ~0.000243m per count + +// LCD I2C address (try common addresses) +#define LCD_ADDRESS 0x27 +#define LCD_COLS 16 +#define LCD_ROWS 2 + +// LED Matrix configuration +#define MATRIX_MODULES 8 // Number of MAX7219 modules in series (1x8 configuration) + +// EEPROM addresses for storing settings +#define EEPROM_TIME_MINUTES_ADDR 0 +#define EEPROM_TIME_SECONDS_ADDR 1 +#define EEPROM_DISTANCE_ADDR 2 // 2 bytes for distance (0-65535 meters) +#define EEPROM_SPEED_ADDR 4 // Speed percentage (0-100) +#define EEPROM_MODE_ADDR 5 // Exercise mode (MODE_TIME=1, MODE_DISTANCE=2) +#define EEPROM_MAGIC_BYTE 6 // Magic byte to check if EEPROM is initialized +#define EEPROM_MAGIC_VALUE 0xAA + +// Default values +#define DEFAULT_TIME_MINUTES 5 +#define DEFAULT_TIME_SECONDS 0 +#define DEFAULT_DISTANCE_METERS 100 + +// ============================================================================ +// ENUMS AND DATA STRUCTURES +// ============================================================================ + +enum ExerciseMode { + MODE_NONE, + MODE_TIME, + MODE_DISTANCE +}; + +enum ExerciseState { + STATE_IDLE, + STATE_MODE_SELECTED, + STATE_READY, + STATE_COUNTDOWN, + STATE_RUNNING, + STATE_PAUSED, + STATE_EMERGENCY +}; + +struct ExerciseParams { + ExerciseMode mode; + int timeMinutes; + int timeSeconds; + int distanceMeters; // in meters, increments of 100 +}; + +struct ExerciseProgress { + unsigned long startTime; + unsigned long pausedTime; + unsigned long totalPausedTime; + volatile long encoderCount; + float distanceTraveled; + bool isPaused; +}; + +// ============================================================================ +// GLOBAL VARIABLES +// ============================================================================ + +// System state +ExerciseState exerciseState = STATE_IDLE; +ExerciseMode exerciseMode = MODE_TIME; // Default to time mode +bool emergency = false; +int speedPercent = 50; + +// Exercise parameters +ExerciseParams exerciseParams; +ExerciseProgress exerciseProgress; + +// Countdown +int countdownValue = 0; +unsigned long countdownStartTime = 0; + +// Button states +bool lastStartBtnState = HIGH; +bool lastStopBtnState = HIGH; +bool lastSpeedUpBtnState = HIGH; +bool lastSpeedDownBtnState = HIGH; +bool lastModeSelectBtnState = HIGH; +unsigned long modeSelectButtonPressTime = 0; // Track when mode button was pressed (for debouncing momentary button) +bool lastIncreaseBtnState = HIGH; +bool lastDecreaseBtnState = HIGH; +bool lastResetParamsBtnState = HIGH; +bool lastEmergencyBtnState = HIGH; + +// Radio button states +bool lastRadioSpeedUpBtnState = HIGH; +bool lastRadioSpeedDownBtnState = HIGH; +bool lastRadioStartStopBtnState = HIGH; +bool lastRadioModeSelectBtnState = HIGH; +bool lastRadioResetBtnState = HIGH; +bool lastRadioDecreaseBtnState = HIGH; +bool lastRadioIncreaseBtnState = HIGH; + +// Button press timers +unsigned long speedUpPressTime = 0; +unsigned long speedDownPressTime = 0; +unsigned long radioSpeedUpPressTime = 0; +unsigned long radioSpeedDownPressTime = 0; +unsigned long increasePressTime = 0; +unsigned long decreasePressTime = 0; +unsigned long radioIncreasePressTime = 0; +unsigned long radioDecreasePressTime = 0; + +// Button repeat timers (for auto-repeat on hold) +unsigned long lastSpeedUpRepeat = 0; +unsigned long lastSpeedDownRepeat = 0; +unsigned long lastIncreaseRepeat = 0; +unsigned long lastDecreaseRepeat = 0; + +// Button processed flags (to prevent multiple triggers) +bool modeSelectButtonProcessed = false; +bool increaseButtonProcessed = false; +bool decreaseButtonProcessed = false; + +// Emergency +bool emergencyBlinkState = false; +unsigned long lastEmergencyBlink = 0; +bool emergencyMatrixFlashState = false; +unsigned long lastEmergencyMatrixFlash = 0; +const unsigned long EMERGENCY_MATRIX_FLASH_INTERVAL = 1000; // 1 second on, 1 second off = 2 second cycle + +// Display update timing +unsigned long lastDisplayUpdate = 0; +const unsigned long DISPLAY_UPDATE_INTERVAL = 250; // Update display every 250ms (reduced flashing) + +// Cache last displayed values to prevent unnecessary updates +unsigned long lastDisplayedMinutes = 999; +unsigned long lastDisplayedSeconds = 999; +int lastDisplayedDistance = -1; +int lastDisplayedSpeed = -1; + +// Display mode for matrix (what to show) +// Note: Remaining time/distance is always shown when running (per requirements) +bool showSpeedOnMatrix = true; // Whether to show speed on matrix (uses last 3 modules) + +// Flags to force reset of display caches (set to true when pausing/resuming) +bool resetTimeDisplayCacheFlag = false; +bool resetSpeedDisplayCacheFlag = false; +bool resetDistanceDisplayCacheFlag = false; + +// Encoder state (for quadrature decoding) +volatile int encoderLastState = 0; + +// Display objects - using standard I2C LCD library with hybrid Russian/Latin support +LiquidCrystal_I2C lcd(LCD_ADDRESS, LCD_COLS, LCD_ROWS); +LedControl matrix = LedControl(MATRIX_DIN, MATRIX_CLK, MATRIX_CS, MATRIX_MODULES); + +// ============================================================================ +// HYBRID RUSSIAN/LATIN LCD SOLUTION +// ============================================================================ +// HD44780 LCD supports 8 custom characters (CGRAM slots 0-7) +// Strategy: Use Latin characters where Russian and Latin look alike, +// create 8 custom characters for unique Russian letters + +// Custom character definitions (5x8 pixels, stored as 8 bytes) +// Each byte represents one row (top to bottom) +// HD44780 uses only bits 0-4 (lower 5 bits) for the 5-pixel width +// Format: B0B1B2B3B4000 where B0-B4 are the pixel columns (left to right) + +// Character slot 0: Б (Be) +byte char_BE[8] = { + B01110, // ### + B10000, // # + B11110, // ##### + B10001, // # # + B10001, // # # + B10001, // ##### + B11110, // + B00000 // +}; + +// Character slot 1: И (I) +byte char_I[8] = { + B10001, // # # + B10001, // # ## + B10011, // # # # + B10101, // ## # + B11001, // # # + B10001, // # # + B10001, // + B00000 // +}; + +// Character slot 2: Ц (Tse) +byte char_TS[8] = { + B10010, // # # + B10010, // # # + B10010, // # # + B10010, // # # + B10010, // # # + B10010, // # # + B11111, // ##### + B00001 // # +}; + +// Character slot 3: Ч (Che) +byte char_CH[8] = { + B10001, // # # + B10001, // # # + B10001, // # # + B10001, // # # + B01111, // #### + B00001, // # + B00001, // # + B00000 // +}; + +// Character slot 4: Я (Ya) +byte char_YA[8] = { + B01111, // #### + B10001, // # # + B10001, // # # + B01111, // #### + B00101, // # # + B01001, // # # + B10001, // # # + B00000 // +}; + +// Character slot 5: Л (El) +byte char_L[8] = { + B00111, // ### + B01101, // ## # + B11001, // ## # + B10001, // # # + B10001, // # # + B10001, // # # + B10001, // + B00000 // +}; + +// Character slot 6: Д (De) +byte char_D[8] = { + B00100, // ## + B01010, // # # + B01010, // # # + B01010, // # # + B11111, // ##### + B10001, // # # + B10001, // # # + B00000 // +}; + +// Character slot 7: ь (Soft sign) +byte char_soft_sign[8] = { + B10000, // # + B10000, // # + B10000, // # + B11110, // # + B10010, // ### + B10010, // # # + B11110, // ### + B00000 // +}; + +// UTF-8 to character mapping for Russian Cyrillic +// Russian Cyrillic characters in UTF-8 are 2 bytes +// Uppercase А-Я: 0xD0 0x90-0xAF +// Lowercase а-я: 0xD0 0xB0-0xBF and 0xD1 0x80-0x8F +byte mapRussianUTF8(const unsigned char* utf8Bytes, int& bytesConsumed) { + bytesConsumed = 1; // Default: consume 1 byte + + // Check if this is a UTF-8 sequence for Cyrillic (starts with 0xD0 or 0xD1) + if (utf8Bytes[0] == 0xD0 && utf8Bytes[1] >= 0x90 && utf8Bytes[1] <= 0xAF) { + // Cyrillic uppercase (А-Я) range: 0xD0 0x90-0xAF + bytesConsumed = 2; + unsigned char code = utf8Bytes[1]; + + // Map Cyrillic uppercase to Latin/custom + switch (code) { + case 0x90: return 'A'; // А + case 0x91: return 0; // Б -> custom 0 + case 0x92: return 'B'; // В + case 0x93: return 'r'; // Г + case 0x94: return 6; // Д -> custom 6 + case 0x95: return 'E'; // Е + case 0x96: return 'b'; // Ж -> use 'b' as approximation (not used) + case 0x97: return '3'; // З + case 0x98: return 1; // И -> custom 1 + case 0x99: return 1; // Й (same as И for display) + case 0x9A: return 'K'; // К + case 0x9B: return 5; // Л -> custom 5 + case 0x9C: return 'M'; // М + case 0x9D: return 'H'; // Н + case 0x9E: return 'O'; // О + case 0x9F: return 'n'; // П + case 0xA0: return 'P'; // Р + case 0xA1: return 'C'; // С + case 0xA2: return 'T'; // Т + case 0xA3: return 'Y'; // У + case 0xA4: return 'F'; // Ф + case 0xA5: return 'X'; // Х + case 0xA6: return 2; // Ц -> custom 2 + case 0xA7: return 3; // Ч -> custom 3 + case 0xA8: return 'W'; // Ш + case 0xA9: return 'W'; // Щ + case 0xAA: return 'B'; // Ъ + case 0xAB: return 'b'; // Ы + case 0xAC: return 7; // Ь -> custom 7 + case 0xAD: return 'E'; // Э + case 0xAE: return 'U'; // Ю + case 0xAF: return 4; // Я -> custom 4 + default: return utf8Bytes[0]; // Return first byte if unknown + } + } else if (utf8Bytes[0] == 0xD0 && utf8Bytes[1] >= 0xB0 && utf8Bytes[1] <= 0xBF) { + // Cyrillic lowercase а-п: 0xD0 0xB0-0xBF + bytesConsumed = 2; + unsigned char code = utf8Bytes[1]; + + switch (code) { + case 0xB0: return 'A'; // а + case 0xB1: return 0; // б -> custom 0 + case 0xB2: return 'B'; // в + case 0xB3: return 'r'; // г + case 0xB4: return 6; // д -> custom 6 + case 0xB5: return 'E'; // е + case 0xB6: return 'b'; // ж -> use 'b' as approximation (not used) + case 0xB7: return '3'; // з + case 0xB8: return 1; // и -> custom 1 + case 0xB9: return 1; // й (same as и) + case 0xBA: return 'K'; // к + case 0xBB: return 5; // л -> custom 5 + case 0xBC: return 'M'; // м + case 0xBD: return 'H'; // н + case 0xBE: return 'O'; // о + case 0xBF: return 'n'; // п + default: return utf8Bytes[0]; + } + } else if (utf8Bytes[0] == 0xD1 && utf8Bytes[1] >= 0x80 && utf8Bytes[1] <= 0x8F) { + // Cyrillic lowercase р-я: 0xD1 0x80-0x8F + bytesConsumed = 2; + unsigned char code = utf8Bytes[1]; + + switch (code) { + case 0x80: return 'P'; // р + case 0x81: return 'C'; // с + case 0x82: return 'T'; // т + case 0x83: return 'Y'; // у + case 0x84: return 'F'; // ф + case 0x85: return 'X'; // х + case 0x86: return 2; // ц -> custom 2 + case 0x87: return 3; // ч -> custom 3 + case 0x88: return 'W'; // ш + case 0x89: return 'W'; // щ + case 0x8A: return 'B'; // ъ + case 0x8B: return 'b'; // ы + case 0x8C: return 7; // ь -> custom 7 + case 0x8D: return 'E'; // э + case 0x8E: return 'U'; // ю + case 0x8F: return 4; // я -> custom 4 + default: return utf8Bytes[0]; + } + } + + // Not a Russian character, return as-is (ASCII) + return utf8Bytes[0]; +} + +// Initialize custom characters in LCD CGRAM +void initCustomChars() { + // Create custom characters - HD44780 supports 8 custom characters (0-7) + // Each character is 8 bytes (one per row), only lower 5 bits are used + lcd.createChar(0, char_BE); // Б + delay(2); // Small delay between character creation + lcd.createChar(1, char_I); // И + delay(2); + lcd.createChar(2, char_TS); // Ц + delay(2); + lcd.createChar(3, char_CH); // Ч + delay(2); + lcd.createChar(4, char_YA); // Я + delay(2); + lcd.createChar(5, char_L); // Л + delay(2); + lcd.createChar(6, char_D); // Д + delay(2); + lcd.createChar(7, char_soft_sign); // ь (soft sign) + delay(2); +} + +// Function to print Russian text on LCD using hybrid approach +// Handles UTF-8 encoded Russian characters properly +void lcdPrintRussianText(const char* text) { + if (text == NULL) return; + + int i = 0; + while (text[i] != '\0') { + int bytesConsumed = 1; + byte mappedChar = mapRussianUTF8((const unsigned char*)&text[i], bytesConsumed); + + // If mapped to custom character (0-7), write it directly using write() + // Custom characters are stored in CGRAM slots 0-7 + if (mappedChar <= 7) { + lcd.write((uint8_t)mappedChar); + } else { + // Otherwise, print as regular character (Latin equivalent or original) + lcd.print((char)mappedChar); + } + + // Advance by the number of bytes consumed (1 for ASCII, 2 for UTF-8 Cyrillic) + i += bytesConsumed; + } +} + +// ============================================================================ +// FONT DATA FOR LED MATRIX (8x8 Russian/Numeric Font) +// ============================================================================ + +// Font data: Each character is 8 bytes (8 rows, 1 byte per row) +// Pre-mirrored both vertically and horizontally for MAX7219 +// Bit pattern: LSB is left, MSB is right (reversed from original) +const byte font[][8] = { + // Numbers 0-9 (mirrored) + {0x7C, 0xC6, 0xE6, 0xF6, 0xDE, 0xCE, 0xC6, 0x7C}, // 0 + {0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x38, 0x18}, // 1 + {0xFE, 0xC6, 0xC0, 0x70, 0x1C, 0x06, 0xC6, 0x7C}, // 2 + {0x7C, 0xC6, 0x06, 0x06, 0x3C, 0x06, 0xC6, 0x7C}, // 3 + {0x0C, 0x0C, 0xFE, 0xCC, 0x6C, 0x3C, 0x1C, 0x0C}, // 4 + {0x7C, 0xC6, 0x06, 0x06, 0xFC, 0xC0, 0xC0, 0xFE}, // 5 + {0x7C, 0xC6, 0xC6, 0xC6, 0xFC, 0xC0, 0xC6, 0x7C}, // 6 + {0x30, 0x30, 0x30, 0x18, 0x0C, 0x06, 0xC6, 0xFE}, // 7 + {0x7C, 0xC6, 0xC6, 0xC6, 0x7C, 0xC6, 0xC6, 0x7C}, // 8 + {0x7C, 0xC6, 0x06, 0x7E, 0xC6, 0xC6, 0xC6, 0x7C}, // 9 + + // Russian letters (Cyrillic) - mirrored + {0x38, 0x6C, 0xC6, 0xC6, 0xFE, 0xC6, 0xC6, 0xC6}, // А (corrected) + {0xFC, 0x66, 0x66, 0x7C, 0x66, 0x66, 0x66, 0xFC}, // Б + {0x3C, 0x66, 0xC0, 0xC0, 0xC0, 0xC0, 0x66, 0x3C}, // В + {0xF8, 0x6C, 0x66, 0x66, 0x66, 0x66, 0x6C, 0xF8}, // Г + {0xFE, 0x62, 0x60, 0x68, 0x78, 0x68, 0x62, 0xFE}, // Д + {0xFE, 0xC0, 0xC0, 0xFC, 0xC0, 0xC0, 0xC0, 0xFE}, // Е + {0xC0, 0xC0, 0xC0, 0xFC, 0xC0, 0xC0, 0xC0, 0xFE}, // Ж + {0x7C, 0xC6, 0x06, 0x0C, 0x18, 0x30, 0x60, 0xFE}, // З (corrected - looks like 3) + {0xC6, 0xC6, 0xC6, 0xFE, 0xC6, 0xC6, 0xC6, 0xC6}, // И + {0xC6, 0xC6, 0x6C, 0x38, 0x38, 0x6C, 0xC6, 0xC6}, // К + {0xC6, 0xCE, 0xDE, 0xF6, 0xE6, 0xC6, 0xC6, 0xC6}, // Л + {0xC6, 0xEE, 0xFE, 0xD6, 0xC6, 0xC6, 0xC6, 0xC6}, // М + {0x7E, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x7E}, // Н (corrected) + {0x7C, 0xC6, 0xC6, 0xC6, 0xC6, 0xC6, 0xC6, 0x7C}, // О + {0xFC, 0xC6, 0xC6, 0xC6, 0xFC, 0xC0, 0xC0, 0xC0}, // П (corrected - should look like П) + {0x76, 0x7E, 0xDE, 0xD6, 0xC6, 0xC6, 0xC6, 0x7C}, // Р + {0x66, 0x66, 0x6C, 0x78, 0x7C, 0x66, 0x66, 0xFC}, // С + {0xFC, 0xC6, 0xC6, 0xC6, 0xFC, 0xC0, 0xC0, 0xC0}, // Т + {0x7C, 0xC6, 0x06, 0x3C, 0x60, 0xC6, 0xC6, 0x7C}, // У (corrected - should look like Y shape) + {0xF8, 0x6C, 0x66, 0x66, 0x66, 0x66, 0x6C, 0xF8}, // Ф + {0xFE, 0x86, 0x06, 0x3E, 0x06, 0x86, 0xC6, 0xFE}, // Х + {0x06, 0x06, 0x06, 0x3E, 0x06, 0x86, 0xC6, 0xFE}, // Ц + {0xFC, 0xC6, 0xC6, 0xE6, 0x06, 0x06, 0xC6, 0x7C}, // Ч + {0x38, 0x6C, 0xC6, 0xC6, 0xFE, 0xC6, 0xC6, 0xC6}, // Ш + {0xDB, 0xDB, 0x7E, 0x18, 0xD8, 0xD8, 0x78, 0x78}, // Щ + {0xFE, 0xC6, 0x60, 0x30, 0x18, 0x0C, 0xC6, 0xFE}, // Ъ + {0xEE, 0x6C, 0x6C, 0xC6, 0xC6, 0xC6, 0x6C, 0x38}, // Ы + {0x3E, 0x66, 0x66, 0x66, 0x3E, 0x06, 0x06, 0x06}, // Ь + {0x7C, 0xC6, 0xC6, 0xF6, 0xF6, 0xC6, 0xC6, 0x7C}, // Э + {0x66, 0x66, 0x66, 0x7E, 0x66, 0x66, 0x3C, 0x18}, // Ю + {0x60, 0x60, 0x60, 0x78, 0x7C, 0x66, 0x66, 0xFC}, // Я + + // Special characters - mirrored + {0x00, 0x00, 0x00, 0x00, 0x7E, 0x00, 0x00, 0x00}, // - (minus/dash) + {0x00, 0x18, 0x18, 0x00, 0x00, 0x18, 0x18, 0x00}, // : (colon) + {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // space + {0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // _ (underscore) +}; + +// Character mapping function +byte getCharIndex(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; // 0-9 + } + // Russian letters mapping (simplified - using ASCII-like mapping) + // For full Cyrillic support, would need proper Unicode handling + switch (c) { + case 'А': case 'а': return 10; // А + case 'Б': case 'б': return 11; // Б + case 'В': case 'в': return 12; // В + case 'Г': case 'г': return 13; // Г + case 'Д': case 'д': return 14; // Д + case 'Е': case 'е': return 15; // Е + case 'Ж': case 'ж': return 16; // Ж + case 'З': case 'з': return 17; // З + case 'И': case 'и': return 18; // И + case 'К': case 'к': return 19; // К + case 'Л': case 'л': return 20; // Л + case 'М': case 'м': return 21; // М + case 'Н': case 'н': return 22; // Н + case 'О': case 'о': return 23; // О + case 'П': case 'п': return 24; // П + case 'Р': case 'р': return 25; // Р + case 'С': case 'с': return 26; // С + case 'Т': case 'т': return 27; // Т + case 'У': case 'у': return 28; // У + case 'Ф': case 'ф': return 29; // Ф + case 'Х': case 'х': return 30; // Х + case 'Ц': case 'ц': return 31; // Ц + case 'Ч': case 'ч': return 32; // Ч + case 'Ш': case 'ш': return 33; // Ш + case 'Щ': case 'щ': return 34; // Щ + case 'Ъ': case 'ъ': return 35; // Ъ + case 'Ы': case 'ы': return 36; // Ы + case 'Ь': case 'ь': return 37; // Ь + case 'Э': case 'э': return 38; // Э + case 'Ю': case 'ю': return 39; // Ю + case 'Я': case 'я': return 40; // Я + case '-': return 41; // minus + case ':': return 42; // colon + case ' ': return 43; // space + case '_': return 44; // underscore + default: return 43; // space for unknown + } +} + +// Clear all matrix modules +void clearAllMatrix() { + for (int i = 0; i < MATRIX_MODULES; i++) { + matrix.clearDisplay(i); + } +} + +// Bit reversal lookup table for faster horizontal mirroring +const byte REVERSE_BITS_TABLE[256] PROGMEM = { + 0x00, 0x80, 0x40, 0xC0, 0x20, 0xA0, 0x60, 0xE0, 0x10, 0x90, 0x50, 0xD0, 0x30, 0xB0, 0x70, 0xF0, + 0x08, 0x88, 0x48, 0xC8, 0x28, 0xA8, 0x68, 0xE8, 0x18, 0x98, 0x58, 0xD8, 0x38, 0xB8, 0x78, 0xF8, + 0x04, 0x84, 0x44, 0xC4, 0x24, 0xA4, 0x64, 0xE4, 0x14, 0x94, 0x54, 0xD4, 0x34, 0xB4, 0x74, 0xF4, + 0x0C, 0x8C, 0x4C, 0xCC, 0x2C, 0xAC, 0x6C, 0xEC, 0x1C, 0x9C, 0x5C, 0xDC, 0x3C, 0xBC, 0x7C, 0xFC, + 0x02, 0x82, 0x42, 0xC2, 0x22, 0xA2, 0x62, 0xE2, 0x12, 0x92, 0x52, 0xD2, 0x32, 0xB2, 0x72, 0xF2, + 0x0A, 0x8A, 0x4A, 0xCA, 0x2A, 0xAA, 0x6A, 0xEA, 0x1A, 0x9A, 0x5A, 0xDA, 0x3A, 0xBA, 0x7A, 0xFA, + 0x06, 0x86, 0x46, 0xC6, 0x26, 0xA6, 0x66, 0xE6, 0x16, 0x96, 0x56, 0xD6, 0x36, 0xB6, 0x76, 0xF6, + 0x0E, 0x8E, 0x4E, 0xCE, 0x2E, 0xAE, 0x6E, 0xEE, 0x1E, 0x9E, 0x5E, 0xDE, 0x3E, 0xBE, 0x7E, 0xFE, + 0x01, 0x81, 0x41, 0xC1, 0x21, 0xA1, 0x61, 0xE1, 0x11, 0x91, 0x51, 0xD1, 0x31, 0xB1, 0x71, 0xF1, + 0x09, 0x89, 0x49, 0xC9, 0x29, 0xA9, 0x69, 0xE9, 0x19, 0x99, 0x59, 0xD9, 0x39, 0xB9, 0x79, 0xF9, + 0x05, 0x85, 0x45, 0xC5, 0x25, 0xA5, 0x65, 0xE5, 0x15, 0x95, 0x55, 0xD5, 0x35, 0xB5, 0x75, 0xF5, + 0x0D, 0x8D, 0x4D, 0xCD, 0x2D, 0xAD, 0x6D, 0xED, 0x1D, 0x9D, 0x5D, 0xDD, 0x3D, 0xBD, 0x7D, 0xFD, + 0x03, 0x83, 0x43, 0xC3, 0x23, 0xA3, 0x63, 0xE3, 0x13, 0x93, 0x53, 0xD3, 0x33, 0xB3, 0x73, 0xF3, + 0x0B, 0x8B, 0x4B, 0xCB, 0x2B, 0xAB, 0x6B, 0xEB, 0x1B, 0x9B, 0x5B, 0xDB, 0x3B, 0xBB, 0x7B, 0xFB, + 0x07, 0x87, 0x47, 0xC7, 0x27, 0xA7, 0x67, 0xE7, 0x17, 0x97, 0x57, 0xD7, 0x37, 0xB7, 0x77, 0xF7, + 0x0F, 0x8F, 0x4F, 0xCF, 0x2F, 0xAF, 0x6F, 0xEF, 0x1F, 0x9F, 0x5F, 0xDF, 0x3F, 0xBF, 0x7F, 0xFF +}; + +// Fast bit reversal using lookup table +inline byte reverseBits(byte b) { + return pgm_read_byte(&REVERSE_BITS_TABLE[b]); +} + +// Display a single character on a specific matrix module +// Font data is vertically mirrored, apply horizontal mirroring at runtime +void displayChar(char c, int moduleIndex) { + if (moduleIndex < 0 || moduleIndex >= MATRIX_MODULES) return; + + byte charIndex = getCharIndex(c); + if (charIndex >= sizeof(font) / sizeof(font[0])) { + charIndex = 43; // space + } + + // Font is vertically mirrored, apply horizontal mirroring (bit reversal) at runtime + // Use lookup table for fast bit reversal + for (int row = 0; row < 8; row++) { + byte mirroredData = reverseBits(font[charIndex][row]); + matrix.setRow(moduleIndex, row, mirroredData); + } +} + +// Display a number (0-99999999) on the matrix across multiple modules +void displayNumber(int number) { + clearAllMatrix(); + + if (number < 0) number = 0; + + // Extract digits (up to 8 digits for 8 modules) + int digits[8]; + int digitCount = 0; + + if (number == 0) { + digits[0] = 0; + digitCount = 1; + } else { + int temp = number; + while (temp > 0 && digitCount < 8) { + digits[digitCount] = temp % 10; + temp /= 10; + digitCount++; + } + } + + // Display digits left to right (most significant on left) + // digits[0] is least significant, digits[digitCount-1] is most significant + for (int i = 0; i < digitCount && i < MATRIX_MODULES; i++) { + int digitIndex = digitCount - 1 - i; // Get digit from most to least significant + int moduleIndex = i; // Display from left to right + displayChar('0' + digits[digitIndex], moduleIndex); + } +} + +// Display time MM:SS format across multiple modules +void displayTimeMMSS(unsigned long minutes, unsigned long seconds) { + // Only clear the modules we'll use, not all modules (prevents flashing) + // Limit to 99:59 for display + if (minutes > 99) minutes = 99; + if (seconds > 59) seconds = 59; + + // Extract digits + int minTens = (minutes / 10) % 10; + int minOnes = minutes % 10; + int secTens = (seconds / 10) % 10; + int secOnes = seconds % 10; + + // Only update if values changed + static int lastMinTens = -1, lastMinOnes = -1, lastSecTens = -1, lastSecOnes = -1; + static bool colonDisplayed = false; + + // Reset cache if flag is set (called from pause/resume) + if (resetTimeDisplayCacheFlag) { + lastMinTens = -1; + lastMinOnes = -1; + lastSecTens = -1; + lastSecOnes = -1; + colonDisplayed = false; + resetTimeDisplayCacheFlag = false; + // Clear all modules to force full redraw + for (int i = 0; i < 5 && i < MATRIX_MODULES; i++) { + matrix.clearDisplay(i); + } + } + + // Display format: MM:SS (5 characters + colon = 6 positions) + // Modules: [0] [1] [2] [3] [4] [5] [6] [7] + // Display: M M : S S + // Position: 0 1 2 3 4 + + if (MATRIX_MODULES >= 5) { + // Only update modules that changed - don't clear if same character + if (minTens != lastMinTens) { + if (lastMinTens != -1) matrix.clearDisplay(0); // Only clear if not first time + displayChar('0' + minTens, 0); + lastMinTens = minTens; + } + if (minOnes != lastMinOnes) { + if (lastMinOnes != -1) matrix.clearDisplay(1); + displayChar('0' + minOnes, 1); + lastMinOnes = minOnes; + } + // Colon doesn't change, but display it once + if (!colonDisplayed) { + displayChar(':', 2); + colonDisplayed = true; + } + if (secTens != lastSecTens) { + if (lastSecTens != -1) matrix.clearDisplay(3); + displayChar('0' + secTens, 3); + lastSecTens = secTens; + } + if (secOnes != lastSecOnes) { + if (lastSecOnes != -1) matrix.clearDisplay(4); + displayChar('0' + secOnes, 4); + lastSecOnes = secOnes; + } + } else { + // Fallback for fewer modules - show just seconds + if (MATRIX_MODULES >= 2) { + if (secTens != lastSecTens) { + matrix.clearDisplay(MATRIX_MODULES - 2); + displayChar('0' + secTens, MATRIX_MODULES - 2); + lastSecTens = secTens; + } + if (secOnes != lastSecOnes) { + matrix.clearDisplay(MATRIX_MODULES - 1); + displayChar('0' + secOnes, MATRIX_MODULES - 1); + lastSecOnes = secOnes; + } + } else if (MATRIX_MODULES >= 1) { + if (secOnes != lastSecOnes) { + matrix.clearDisplay(0); + displayChar('0' + secOnes, 0); + lastSecOnes = secOnes; + } + } + } +} + +// Display text across multiple modules (up to MATRIX_MODULES characters) +void displayText(const char* text) { + if (text == NULL || strlen(text) == 0) { + clearAllMatrix(); + return; + } + + clearAllMatrix(); + + // Display up to MATRIX_MODULES characters + int len = strlen(text); + int charsToShow = (len < MATRIX_MODULES) ? len : MATRIX_MODULES; + + for (int i = 0; i < charsToShow; i++) { + displayChar(text[i], i); + } +} + +// Display "ПАУЗА" (PAUSE) on the matrix +// Using character indices directly to avoid encoding issues +void displayPause() { + clearAllMatrix(); + + // ПАУЗА = 5 characters + // Character indices in font array: 24 (П), 10 (А), 28 (У), 17 (З), 10 (А) + byte pauseChars[5] = {24, 10, 28, 17, 10}; // П, А, У, З, А + + int charsToShow = (MATRIX_MODULES < 5) ? MATRIX_MODULES : 5; + + for (int i = 0; i < charsToShow; i++) { + byte charIndex = pauseChars[i]; + if (charIndex >= sizeof(font) / sizeof(font[0])) { + charIndex = 43; // space if invalid + } + + // Display character directly from font array + for (int row = 0; row < 8; row++) { + byte mirroredData = reverseBits(font[charIndex][row]); + matrix.setRow(i, row, mirroredData); + } + } +} + +// ============================================================================ +// ENCODER INTERRUPT HANDLERS +// ============================================================================ + +void encoderInterruptA() { + int stateA = digitalRead(ENCODER_A); + int stateB = digitalRead(ENCODER_B); + + // Quadrature decoding + int currentState = (stateA << 1) | stateB; + int lastState = encoderLastState; + + // Determine direction and count + if (lastState == 0 && currentState == 2) exerciseProgress.encoderCount++; + else if (lastState == 0 && currentState == 1) exerciseProgress.encoderCount--; + else if (lastState == 2 && currentState == 3) exerciseProgress.encoderCount++; + else if (lastState == 2 && currentState == 0) exerciseProgress.encoderCount--; + else if (lastState == 3 && currentState == 1) exerciseProgress.encoderCount++; + else if (lastState == 3 && currentState == 2) exerciseProgress.encoderCount--; + else if (lastState == 1 && currentState == 0) exerciseProgress.encoderCount++; + else if (lastState == 1 && currentState == 3) exerciseProgress.encoderCount--; + + encoderLastState = currentState; +} + +void encoderInterruptB() { + encoderInterruptA(); // Same logic for both interrupts +} + +// ============================================================================ +// EEPROM FUNCTIONS +// ============================================================================ + +// Save exercise parameters to EEPROM +void saveSettingsToEEPROM() { + EEPROM.write(EEPROM_TIME_MINUTES_ADDR, exerciseParams.timeMinutes); + EEPROM.write(EEPROM_TIME_SECONDS_ADDR, exerciseParams.timeSeconds); + + // Save distance as 2 bytes (high byte, low byte) + EEPROM.write(EEPROM_DISTANCE_ADDR, (exerciseParams.distanceMeters >> 8) & 0xFF); + EEPROM.write(EEPROM_DISTANCE_ADDR + 1, exerciseParams.distanceMeters & 0xFF); + + // Save speed (0-100) + EEPROM.write(EEPROM_SPEED_ADDR, speedPercent); + + // Save mode (MODE_TIME=1, MODE_DISTANCE=2) + EEPROM.write(EEPROM_MODE_ADDR, (byte)exerciseMode); + + // Write magic byte to indicate EEPROM is initialized + EEPROM.write(EEPROM_MAGIC_BYTE, EEPROM_MAGIC_VALUE); +} + +// Load exercise parameters from EEPROM +void loadSettingsFromEEPROM() { + // Check if EEPROM has been initialized (magic byte check) + byte magic = EEPROM.read(EEPROM_MAGIC_BYTE); + + if (magic == EEPROM_MAGIC_VALUE) { + // EEPROM is initialized, load values + exerciseParams.timeMinutes = EEPROM.read(EEPROM_TIME_MINUTES_ADDR); + exerciseParams.timeSeconds = EEPROM.read(EEPROM_TIME_SECONDS_ADDR); + + // Load distance as 2 bytes + byte distanceHigh = EEPROM.read(EEPROM_DISTANCE_ADDR); + byte distanceLow = EEPROM.read(EEPROM_DISTANCE_ADDR + 1); + exerciseParams.distanceMeters = (distanceHigh << 8) | distanceLow; + + // Load speed + speedPercent = EEPROM.read(EEPROM_SPEED_ADDR); + + // Load mode + byte modeByte = EEPROM.read(EEPROM_MODE_ADDR); + if (modeByte == MODE_TIME || modeByte == MODE_DISTANCE) { + exerciseMode = (ExerciseMode)modeByte; + } else { + exerciseMode = MODE_TIME; // Default to time mode if invalid + } + + // Validate loaded values + if (exerciseParams.timeMinutes > 99) exerciseParams.timeMinutes = DEFAULT_TIME_MINUTES; + if (exerciseParams.timeSeconds > 59) exerciseParams.timeSeconds = DEFAULT_TIME_SECONDS; + if (exerciseParams.distanceMeters > 9999) exerciseParams.distanceMeters = DEFAULT_DISTANCE_METERS; + if (speedPercent < 0 || speedPercent > 100) speedPercent = 50; + + Serial.print("Загружено из EEPROM: Режим="); + Serial.print(exerciseMode == MODE_TIME ? "ВРЕМЯ" : "ДИСТАНЦИЯ"); + Serial.print(", Время="); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.print(exerciseParams.timeSeconds); + Serial.print(", Дистанция="); + Serial.print(exerciseParams.distanceMeters); + Serial.print("м, Скорость="); + Serial.print(speedPercent); + Serial.println("%"); + } else { + // EEPROM not initialized, use defaults + exerciseParams.timeMinutes = DEFAULT_TIME_MINUTES; + exerciseParams.timeSeconds = DEFAULT_TIME_SECONDS; + exerciseParams.distanceMeters = DEFAULT_DISTANCE_METERS; + speedPercent = 50; + exerciseMode = MODE_TIME; // Default to time mode + + // Save defaults to EEPROM + saveSettingsToEEPROM(); + + Serial.print("Использованы значения по умолчанию: Режим=ВРЕМЯ, Время="); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.print(exerciseParams.timeSeconds); + Serial.print(", Дистанция="); + Serial.print(exerciseParams.distanceMeters); + Serial.print("м, Скорость="); + Serial.print(speedPercent); + Serial.println("%"); + } + + // Set state to READY if mode and parameters are valid + if (exerciseMode == MODE_TIME) { + if (exerciseParams.timeMinutes > 0 || exerciseParams.timeSeconds > 0) { + exerciseState = STATE_READY; + } else { + exerciseState = STATE_MODE_SELECTED; + } + } else if (exerciseMode == MODE_DISTANCE) { + if (exerciseParams.distanceMeters > 0) { + exerciseState = STATE_READY; + } else { + exerciseState = STATE_MODE_SELECTED; + } + } +} + +// ============================================================================ +// SETUP +// ============================================================================ + +void setup() { + Serial.begin(115200); + Serial.println("=== ТРЕНАЖЕР БЕСКОНЕЧНАЯ ЛЕСТНИЦА ==="); + Serial.println("Запуск системы..."); + + // Initialize pins + pinMode(BTN_START, INPUT_PULLUP); + pinMode(BTN_STOP, INPUT_PULLUP); + pinMode(BTN_SPEED_DOWN, INPUT_PULLUP); + pinMode(BTN_SPEED_UP, INPUT_PULLUP); + pinMode(BTN_MODE_SELECT, INPUT_PULLUP); + pinMode(BTN_INCREASE, INPUT_PULLUP); + pinMode(BTN_DECREASE, INPUT_PULLUP); + pinMode(BTN_RESET_PARAMS, INPUT_PULLUP); + pinMode(BTN_EMERGENCY_MUSHROOM, INPUT_PULLUP); // D29: Normally LOW, emergency HIGH (external pulldown) + pinMode(EMERGENCY_LASER_SENSORS, INPUT_PULLUP); // D32: Normally HIGH, emergency LOW (external pullup) + + pinMode(RADIO_SPEED_UP, INPUT_PULLUP); + pinMode(RADIO_SPEED_DOWN, INPUT_PULLUP); + pinMode(RADIO_START_STOP, INPUT_PULLUP); + pinMode(RADIO_MODE_SELECT, INPUT_PULLUP); + pinMode(RADIO_RESET, INPUT_PULLUP); + pinMode(RADIO_DECREASE, INPUT_PULLUP); + pinMode(RADIO_INCREASE, INPUT_PULLUP); + + pinMode(LIMIT_SW_SIGNAL, INPUT_PULLUP); // D33: Normally LOW, emergency HIGH (external pulldown) + pinMode(ENCODER_A, INPUT_PULLUP); + pinMode(ENCODER_B, INPUT_PULLUP); + + pinMode(PWM_OUT, OUTPUT); + analogWrite(PWM_OUT, 0); + + pinMode(ENABLE_OUT, OUTPUT); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + + pinMode(LED_GREEN, OUTPUT); + pinMode(LED_RED, OUTPUT); + pinMode(LED_EMERGENCY, OUTPUT); + + // Initialize encoder interrupts + // Encoder: LPD3806-360BM-G5-24C + // - 360 pulses per revolution + // - Quadrature output (A and B phases) + // - NPN open collector (requires INPUT_PULLUP) + // - With quadrature decoding: 1440 counts per revolution + encoderLastState = (digitalRead(ENCODER_A) << 1) | digitalRead(ENCODER_B); + attachInterrupt(digitalPinToInterrupt(ENCODER_A), encoderInterruptA, CHANGE); + attachInterrupt(digitalPinToInterrupt(ENCODER_B), encoderInterruptB, CHANGE); + + // Initialize LCD (try common I2C addresses) + Serial.println("Initializing LCD..."); + + // Try init() first (most common method) + lcd.init(); + delay(200); // Give LCD time to initialize + lcd.backlight(); + delay(100); + lcd.clear(); + delay(100); + + // Initialize custom Russian characters (8 slots for unique Russian letters) + // Must be done after clear() but before first use + initCustomChars(); // Reinitialize after clear + delay(50); + + // Display welcome message in Russian (using hybrid Latin/Russian approach) + lcd.setCursor(0, 0); + lcdPrintRussianText("БЕСКОНЕЧНАЯ"); + lcd.setCursor(0, 1); + lcdPrintRussianText("ЛЕСТНИЦА"); + delay(100); // Give time for characters to display + + Serial.println("LCD initialization complete"); + + // Note: If LCD doesn't work, try changing LCD_ADDRESS to 0x3F + + // Initialize LED matrix (all modules) + for (int i = 0; i < MATRIX_MODULES; i++) { + matrix.shutdown(i, false); + matrix.setIntensity(i, 8); + matrix.clearDisplay(i); + } + + // Display welcome message on matrix + displayWelcome(); + + // Initialize button states + lastStartBtnState = digitalRead(BTN_START); + lastStopBtnState = digitalRead(BTN_STOP); + lastSpeedUpBtnState = digitalRead(BTN_SPEED_UP); + lastSpeedDownBtnState = digitalRead(BTN_SPEED_DOWN); + lastModeSelectBtnState = digitalRead(BTN_MODE_SELECT); + lastIncreaseBtnState = digitalRead(BTN_INCREASE); + lastDecreaseBtnState = digitalRead(BTN_DECREASE); + lastResetParamsBtnState = digitalRead(BTN_RESET_PARAMS); + lastEmergencyBtnState = digitalRead(BTN_EMERGENCY_MUSHROOM); + + lastRadioSpeedUpBtnState = digitalRead(RADIO_SPEED_UP); + lastRadioSpeedDownBtnState = digitalRead(RADIO_SPEED_DOWN); + lastRadioStartStopBtnState = digitalRead(RADIO_START_STOP); + lastRadioModeSelectBtnState = digitalRead(RADIO_MODE_SELECT); + lastRadioResetBtnState = digitalRead(RADIO_RESET); + lastRadioDecreaseBtnState = digitalRead(RADIO_DECREASE); + lastRadioIncreaseBtnState = digitalRead(RADIO_INCREASE); + + // Initialize exercise parameters + exerciseParams.mode = MODE_TIME; // Will be set by loadSettingsFromEEPROM + + // Load settings from EEPROM (or use defaults if first time) + // This will also set exerciseMode and speedPercent + loadSettingsFromEEPROM(); + + exerciseProgress.startTime = 0; + exerciseProgress.pausedTime = 0; + exerciseProgress.totalPausedTime = 0; + exerciseProgress.encoderCount = 0; + exerciseProgress.distanceTraveled = 0.0; + exerciseProgress.isPaused = false; + + // Set initial LED states + digitalWrite(LED_RED, HIGH); + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_EMERGENCY, LOW); + + Serial.print("Концевик: "); + Serial.println(digitalRead(LIMIT_SW_SIGNAL) == HIGH ? "РАЗОМКНУТ (АВАРИЯ)" : "ЗАМКНУТ (НОРМА)"); + Serial.print("Стартовая мощность: "); + Serial.print(speedPercent); + Serial.println("%"); + Serial.println("Система готова"); + Serial.println("-------------------"); + + // Update LCD with initial speed + updateLCDSettings(); +} + +// ============================================================================ +// MAIN LOOP +// ============================================================================ + +void loop() { + unsigned long currentMillis = millis(); + + // Status printing (every 1 second) + static unsigned long lastStatusPrint = 0; + if (currentMillis - lastStatusPrint > 1000) { + printStatus(); + lastStatusPrint = currentMillis; + } + + // Safety checks + if (!emergency) { + checkSafety(); + checkEmergencyButton(currentMillis); + checkEmergencyLaserSensors(currentMillis); + } else { + emergencyHandler(currentMillis); + } + + // State machine update + if (!emergency) { + updateExerciseState(currentMillis); + } + + // Mode and parameter setting (in IDLE, MODE_SELECTED, or READY states) + // STATE_READY allows continued parameter adjustment before starting exercise + // Mode is always TIME or DISTANCE (no MODE_NONE), so we can always check parameters + if (exerciseState == STATE_IDLE || exerciseState == STATE_MODE_SELECTED || + exerciseState == STATE_READY) { + checkModeButtons(currentMillis); + checkParameterButtons(currentMillis); + } + + // Exercise tracking (only in RUNNING state) + if (exerciseState == STATE_RUNNING) { + trackExercise(currentMillis); + checkExerciseCompletion(currentMillis); + } + + // Countdown handling + if (exerciseState == STATE_COUNTDOWN) { + handleCountdown(currentMillis); + } + + // Display updates + if (currentMillis - lastDisplayUpdate >= DISPLAY_UPDATE_INTERVAL) { + updateDisplays(currentMillis); + lastDisplayUpdate = currentMillis; + } + + // Button checks + if (!emergency) { + checkButtons(currentMillis); + checkRadioButtons(currentMillis); + checkSpeedButtons(currentMillis); + } +} + +// ============================================================================ +// STATE MACHINE +// ============================================================================ + +void updateExerciseState(unsigned long currentMillis) { + // State transitions are handled by specific functions + // This function can be used for state-specific periodic updates if needed +} + +// ============================================================================ +// MODE SELECTION +// ============================================================================ + +void checkModeButtons(unsigned long currentMillis) { + // Local mode select button (D22 - momentary button, not toggle/latching) + // Toggle between TIME and DISTANCE modes only + bool currentModeSelectBtnState = digitalRead(BTN_MODE_SELECT); + + // Detect button press (falling edge) - record time for debouncing + if (currentModeSelectBtnState == LOW && lastModeSelectBtnState == HIGH) { + modeSelectButtonPressTime = currentMillis; + modeSelectButtonProcessed = false; // Reset processed flag on new press + } + + // Process button press after debounce delay (non-blocking) + // Only process once per press (when press time is set, debounce has passed, and not yet processed) + if (!modeSelectButtonProcessed && + modeSelectButtonPressTime > 0 && + (currentMillis - modeSelectButtonPressTime) >= DEBOUNCE_DELAY && + currentModeSelectBtnState == LOW) { + + // Re-read to confirm button is still pressed after debounce + if (digitalRead(BTN_MODE_SELECT) == LOW) { + // Toggle between TIME and DISTANCE modes + if (exerciseMode == MODE_TIME) { + exerciseMode = MODE_DISTANCE; + exerciseState = STATE_READY; // Ready if distance is already set + if (exerciseParams.distanceMeters == 0) { + exerciseState = STATE_MODE_SELECTED; + } + Serial.println("Режим: ДИСТАНЦИЯ"); + } else { + exerciseMode = MODE_TIME; + exerciseState = STATE_READY; // Ready if time is already set + if (exerciseParams.timeMinutes == 0 && exerciseParams.timeSeconds == 0) { + exerciseState = STATE_MODE_SELECTED; + } + Serial.println("Режим: ВРЕМЯ"); + } + saveSettingsToEEPROM(); // Save mode to EEPROM + updateLCDSettings(); + + // Mark as processed to prevent multiple triggers during same press + modeSelectButtonProcessed = true; + } + } + + // Detect button release (rising edge) - reset everything + if (currentModeSelectBtnState == HIGH && lastModeSelectBtnState == LOW) { + modeSelectButtonPressTime = 0; + modeSelectButtonProcessed = false; + } + + lastModeSelectBtnState = currentModeSelectBtnState; + + // Radio mode select button (D43 - pulse button, needs debounce) + bool currentRadioModeSelectBtnState = digitalRead(RADIO_MODE_SELECT); + if (currentRadioModeSelectBtnState == LOW && lastRadioModeSelectBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + // Toggle between TIME and DISTANCE modes + if (exerciseMode == MODE_TIME) { + exerciseMode = MODE_DISTANCE; + exerciseState = STATE_READY; // Ready if distance is already set + if (exerciseParams.distanceMeters == 0) { + exerciseState = STATE_MODE_SELECTED; + } + Serial.println("Радио: Режим ДИСТАНЦИЯ"); + } else { + exerciseMode = MODE_TIME; + exerciseState = STATE_READY; // Ready if time is already set + if (exerciseParams.timeMinutes == 0 && exerciseParams.timeSeconds == 0) { + exerciseState = STATE_MODE_SELECTED; + } + Serial.println("Радио: Режим ВРЕМЯ"); + } + saveSettingsToEEPROM(); // Save mode to EEPROM + updateLCDSettings(); + } + lastRadioModeSelectBtnState = currentRadioModeSelectBtnState; +} + +// ============================================================================ +// PARAMETER SETTING +// ============================================================================ + +void checkParameterButtons(unsigned long currentMillis) { + if (exerciseMode == MODE_TIME) { + // Time mode: Increase = +10 seconds, Decrease = -10 seconds + + // Increase button (+10 seconds) + bool currentIncreaseBtnState = digitalRead(BTN_INCREASE); + if (currentIncreaseBtnState == LOW && lastIncreaseBtnState == HIGH) { + // Button just pressed - immediate action + increasePressTime = currentMillis; + lastIncreaseRepeat = currentMillis; + increaseButtonProcessed = false; + exerciseParams.timeSeconds += 10; + if (exerciseParams.timeSeconds >= 60) { + exerciseParams.timeMinutes++; + exerciseParams.timeSeconds -= 60; + } + exerciseState = STATE_READY; + Serial.print("Время: "); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.println(exerciseParams.timeSeconds); + saveSettingsToEEPROM(); + updateLCDSettings(); + } else if (currentIncreaseBtnState == LOW) { + // Button held - check for auto-repeat + if (currentMillis - increasePressTime > REPEAT_DELAY) { + if (currentMillis - lastIncreaseRepeat >= REPEAT_INTERVAL) { + lastIncreaseRepeat = currentMillis; + exerciseParams.timeSeconds += 10; + if (exerciseParams.timeSeconds >= 60) { + exerciseParams.timeMinutes++; + exerciseParams.timeSeconds -= 60; + } + exerciseState = STATE_READY; + Serial.print("Время: "); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.println(exerciseParams.timeSeconds); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + } + } else { + // Button released + increasePressTime = 0; + lastIncreaseRepeat = 0; + increaseButtonProcessed = false; + } + lastIncreaseBtnState = currentIncreaseBtnState; + + // Decrease button (-10 seconds) + bool currentDecreaseBtnState = digitalRead(BTN_DECREASE); + if (currentDecreaseBtnState == LOW && lastDecreaseBtnState == HIGH) { + // Button just pressed - immediate action + decreasePressTime = currentMillis; + lastDecreaseRepeat = currentMillis; + decreaseButtonProcessed = false; + if (exerciseParams.timeSeconds >= 10) { + exerciseParams.timeSeconds -= 10; + } else { + // Need to borrow from minutes + if (exerciseParams.timeMinutes > 0) { + exerciseParams.timeMinutes--; + exerciseParams.timeSeconds += 50; // 60 - 10 = 50 + } else { + // Can't go below 0:00 + exerciseParams.timeSeconds = 0; + } + } + exerciseState = STATE_READY; + Serial.print("Время: "); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.println(exerciseParams.timeSeconds); + saveSettingsToEEPROM(); + updateLCDSettings(); + } else if (currentDecreaseBtnState == LOW) { + // Button held - check for auto-repeat + if (currentMillis - decreasePressTime > REPEAT_DELAY) { + if (currentMillis - lastDecreaseRepeat >= REPEAT_INTERVAL) { + lastDecreaseRepeat = currentMillis; + if (exerciseParams.timeSeconds >= 10) { + exerciseParams.timeSeconds -= 10; + } else { + // Need to borrow from minutes + if (exerciseParams.timeMinutes > 0) { + exerciseParams.timeMinutes--; + exerciseParams.timeSeconds += 50; // 60 - 10 = 50 + } else { + // Can't go below 0:00 + exerciseParams.timeSeconds = 0; + } + } + exerciseState = STATE_READY; + Serial.print("Время: "); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.println(exerciseParams.timeSeconds); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + } + } else { + // Button released + decreasePressTime = 0; + lastDecreaseRepeat = 0; + decreaseButtonProcessed = false; + } + lastDecreaseBtnState = currentDecreaseBtnState; + + // Radio increase (+10 seconds) + bool currentRadioIncreaseBtnState = digitalRead(RADIO_INCREASE); + if (currentRadioIncreaseBtnState == LOW && lastRadioIncreaseBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + exerciseParams.timeSeconds += 10; + if (exerciseParams.timeSeconds >= 60) { + exerciseParams.timeMinutes++; + exerciseParams.timeSeconds -= 60; + } + exerciseState = STATE_READY; + Serial.print("Радио: Время "); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.println(exerciseParams.timeSeconds); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + lastRadioIncreaseBtnState = currentRadioIncreaseBtnState; + + // Radio decrease (-10 seconds) + bool currentRadioDecreaseBtnState = digitalRead(RADIO_DECREASE); + if (currentRadioDecreaseBtnState == LOW && lastRadioDecreaseBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + if (exerciseParams.timeSeconds >= 10) { + exerciseParams.timeSeconds -= 10; + } else { + // Need to borrow from minutes + if (exerciseParams.timeMinutes > 0) { + exerciseParams.timeMinutes--; + exerciseParams.timeSeconds += 50; // 60 - 10 = 50 + } else { + // Can't go below 0:00 + exerciseParams.timeSeconds = 0; + } + } + exerciseState = STATE_READY; + Serial.print("Радио: Время "); + Serial.print(exerciseParams.timeMinutes); + Serial.print(":"); + if (exerciseParams.timeSeconds < 10) Serial.print("0"); + Serial.println(exerciseParams.timeSeconds); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + lastRadioDecreaseBtnState = currentRadioDecreaseBtnState; + + } else if (exerciseMode == MODE_DISTANCE) { + // Distance mode: Increase = +5m, Decrease = -5m + + // Increase button (+5m) + bool currentIncreaseBtnState = digitalRead(BTN_INCREASE); + if (currentIncreaseBtnState == LOW && lastIncreaseBtnState == HIGH) { + // Button just pressed - immediate action + increasePressTime = currentMillis; + lastIncreaseRepeat = currentMillis; + increaseButtonProcessed = false; + exerciseParams.distanceMeters += 5; + exerciseState = STATE_READY; + Serial.print("Дистанция: "); + Serial.print(exerciseParams.distanceMeters); + Serial.println(" м"); + saveSettingsToEEPROM(); + updateLCDSettings(); + } else if (currentIncreaseBtnState == LOW) { + // Button held - check for auto-repeat + if (currentMillis - increasePressTime > REPEAT_DELAY) { + if (currentMillis - lastIncreaseRepeat >= REPEAT_INTERVAL) { + lastIncreaseRepeat = currentMillis; + exerciseParams.distanceMeters += 5; + exerciseState = STATE_READY; + Serial.print("Дистанция: "); + Serial.print(exerciseParams.distanceMeters); + Serial.println(" м"); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + } + } else { + // Button released + increasePressTime = 0; + lastIncreaseRepeat = 0; + increaseButtonProcessed = false; + } + lastIncreaseBtnState = currentIncreaseBtnState; + + // Decrease button (-5m) + bool currentDecreaseBtnState = digitalRead(BTN_DECREASE); + if (currentDecreaseBtnState == LOW && lastDecreaseBtnState == HIGH) { + // Button just pressed - immediate action + decreasePressTime = currentMillis; + lastDecreaseRepeat = currentMillis; + decreaseButtonProcessed = false; + exerciseParams.distanceMeters -= 5; + if (exerciseParams.distanceMeters < 0) { + exerciseParams.distanceMeters = 0; + } + exerciseState = STATE_READY; + Serial.print("Дистанция: "); + Serial.print(exerciseParams.distanceMeters); + Serial.println(" м"); + saveSettingsToEEPROM(); + updateLCDSettings(); + } else if (currentDecreaseBtnState == LOW) { + // Button held - check for auto-repeat + if (currentMillis - decreasePressTime > REPEAT_DELAY) { + if (currentMillis - lastDecreaseRepeat >= REPEAT_INTERVAL) { + lastDecreaseRepeat = currentMillis; + exerciseParams.distanceMeters -= 5; + if (exerciseParams.distanceMeters < 0) { + exerciseParams.distanceMeters = 0; + } + exerciseState = STATE_READY; + Serial.print("Дистанция: "); + Serial.print(exerciseParams.distanceMeters); + Serial.println(" м"); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + } + } else { + // Button released + decreasePressTime = 0; + lastDecreaseRepeat = 0; + decreaseButtonProcessed = false; + } + lastDecreaseBtnState = currentDecreaseBtnState; + + // Radio increase (+100m) + bool currentRadioIncreaseBtnState = digitalRead(RADIO_INCREASE); + if (currentRadioIncreaseBtnState == LOW && lastRadioIncreaseBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + exerciseParams.distanceMeters += 5; + exerciseState = STATE_READY; + Serial.print("Радио: Дистанция "); + Serial.print(exerciseParams.distanceMeters); + Serial.println(" м"); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + lastRadioIncreaseBtnState = currentRadioIncreaseBtnState; + + // Radio decrease (-100m) + bool currentRadioDecreaseBtnState = digitalRead(RADIO_DECREASE); + if (currentRadioDecreaseBtnState == LOW && lastRadioDecreaseBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + exerciseParams.distanceMeters -= 5; + if (exerciseParams.distanceMeters < 0) { + exerciseParams.distanceMeters = 0; + } + exerciseState = STATE_READY; + Serial.print("Радио: Дистанция "); + Serial.print(exerciseParams.distanceMeters); + Serial.println(" м"); + saveSettingsToEEPROM(); + updateLCDSettings(); + } + lastRadioDecreaseBtnState = currentRadioDecreaseBtnState; + } + + // Reset parameters button + bool currentResetParamsBtnState = digitalRead(BTN_RESET_PARAMS); + if (currentResetParamsBtnState == LOW && lastResetParamsBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + if (exerciseMode == MODE_TIME) { + exerciseParams.timeMinutes = DEFAULT_TIME_MINUTES; + exerciseParams.timeSeconds = DEFAULT_TIME_SECONDS; + exerciseState = STATE_READY; // Defaults are valid, ready to start + Serial.println("Время сброшено до значений по умолчанию"); + saveSettingsToEEPROM(); + } else if (exerciseMode == MODE_DISTANCE) { + exerciseParams.distanceMeters = DEFAULT_DISTANCE_METERS; + exerciseState = STATE_READY; // Defaults are valid, ready to start + Serial.println("Дистанция сброшена до значения по умолчанию"); + saveSettingsToEEPROM(); + } + updateLCDSettings(); + } + lastResetParamsBtnState = currentResetParamsBtnState; + + // Radio reset button (resets exercise if running/paused, or parameters if in setup) + bool currentRadioResetBtnState = digitalRead(RADIO_RESET); + if (currentRadioResetBtnState == LOW && lastRadioResetBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + if (exerciseState == STATE_PAUSED) { + resetExercise(); + Serial.println("Радио: Упражнение сброшено"); + } else if (exerciseMode == MODE_TIME) { + exerciseParams.timeMinutes = DEFAULT_TIME_MINUTES; + exerciseParams.timeSeconds = DEFAULT_TIME_SECONDS; + exerciseState = STATE_READY; // Defaults are valid, ready to start + Serial.println("Радио: Время сброшено до значений по умолчанию"); + saveSettingsToEEPROM(); + } else if (exerciseMode == MODE_DISTANCE) { + exerciseParams.distanceMeters = DEFAULT_DISTANCE_METERS; + exerciseState = STATE_READY; // Defaults are valid, ready to start + Serial.println("Радио: Дистанция сброшена до значения по умолчанию"); + saveSettingsToEEPROM(); + } + updateLCDSettings(); + } + lastRadioResetBtnState = currentRadioResetBtnState; +} + +// ============================================================================ +// EXERCISE CONTROL +// ============================================================================ + +void startExercise() { + if (exerciseState != STATE_READY && exerciseState != STATE_IDLE && exerciseState != STATE_MODE_SELECTED) return; + if (emergency) return; + + // Check if parameters are set + if (exerciseMode == MODE_TIME) { + if (exerciseParams.timeMinutes == 0 && exerciseParams.timeSeconds == 0) { + Serial.println("Установите время!"); + return; + } + } else if (exerciseMode == MODE_DISTANCE) { + if (exerciseParams.distanceMeters == 0) { + Serial.println("Установите дистанцию!"); + return; + } + } else { + // If mode is somehow NONE, default to time mode + exerciseMode = MODE_TIME; + if (exerciseParams.timeMinutes == 0 && exerciseParams.timeSeconds == 0) { + exerciseParams.timeMinutes = DEFAULT_TIME_MINUTES; + exerciseParams.timeSeconds = DEFAULT_TIME_SECONDS; + saveSettingsToEEPROM(); + } + } + + // Check limit switch + bool limitTripped = (digitalRead(LIMIT_SW_SIGNAL) == HIGH); + if (limitTripped) { + Serial.println("Не могу стартовать: концевик разомкнут!"); + return; + } + + // Reset progress + exerciseProgress.startTime = 0; + exerciseProgress.pausedTime = 0; + exerciseProgress.totalPausedTime = 0; + exerciseProgress.encoderCount = 0; + exerciseProgress.distanceTraveled = 0.0; + exerciseProgress.isPaused = false; + + // Reset all display caches so displays will redraw properly on start + resetTimeDisplayCacheFlag = true; + resetSpeedDisplayCacheFlag = true; + resetDistanceDisplayCacheFlag = true; + + // Start countdown + exerciseState = STATE_COUNTDOWN; + countdownValue = 3; + countdownStartTime = millis(); + + digitalWrite(LED_GREEN, HIGH); + digitalWrite(LED_RED, LOW); + + Serial.println(">>> НАЧАЛО ОТСЧЕТА"); +} + +void pauseExercise() { + if (exerciseState != STATE_RUNNING) return; + + exerciseState = STATE_PAUSED; + exerciseProgress.isPaused = true; + exerciseProgress.pausedTime = millis(); + + // Reset all display caches so displays will redraw properly when resumed + resetTimeDisplayCacheFlag = true; + resetSpeedDisplayCacheFlag = true; + resetDistanceDisplayCacheFlag = true; + + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, HIGH); + + Serial.println("<<< УПРАЖНЕНИЕ НА ПАУЗЕ"); +} + +void resumeExercise() { + if (exerciseState != STATE_PAUSED) return; + if (emergency) return; + + // Update total paused time + if (exerciseProgress.pausedTime > 0) { + exerciseProgress.totalPausedTime += (millis() - exerciseProgress.pausedTime); + exerciseProgress.pausedTime = 0; + } + + // Reset all display caches so displays will redraw properly (colon, speed, distance, etc.) + resetTimeDisplayCacheFlag = true; + resetSpeedDisplayCacheFlag = true; + resetDistanceDisplayCacheFlag = true; + + exerciseState = STATE_RUNNING; + exerciseProgress.isPaused = false; + + digitalWrite(ENABLE_OUT, LOW); // Inverted: LOW = enabled + digitalWrite(LED_GREEN, HIGH); + digitalWrite(LED_RED, LOW); + + updateMotorSpeed(); + + Serial.println(">>> УПРАЖНЕНИЕ ВОЗОБНОВЛЕНО"); +} + +void resetExercise() { + exerciseState = STATE_IDLE; + // Keep current mode, don't reset to MODE_NONE + + // Reload parameters from EEPROM (restore saved settings) + loadSettingsFromEEPROM(); + + exerciseProgress.startTime = 0; + exerciseProgress.pausedTime = 0; + exerciseProgress.totalPausedTime = 0; + exerciseProgress.encoderCount = 0; + exerciseProgress.distanceTraveled = 0.0; + exerciseProgress.isPaused = false; + + // Reset all display caches so displays will redraw properly on next start + resetTimeDisplayCacheFlag = true; + resetSpeedDisplayCacheFlag = true; + resetDistanceDisplayCacheFlag = true; + + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, HIGH); + + updateLCDSettings(); + clearAllMatrix(); + + Serial.println("<<< УПРАЖНЕНИЕ СБРОШЕНО"); +} + +void completeExercise() { + // Set state to READY so user can start immediately + exerciseState = STATE_READY; + + // Reset all display caches so displays will redraw properly on next start + resetTimeDisplayCacheFlag = true; + resetSpeedDisplayCacheFlag = true; + resetDistanceDisplayCacheFlag = true; + + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, HIGH); + + Serial.println(">>> УПРАЖНЕНИЕ ЗАВЕРШЕНО"); + + // Display completion message + displayCompletion(); +} + +// ============================================================================ +// COUNTDOWN SYSTEM +// ============================================================================ + +void handleCountdown(unsigned long currentMillis) { + static unsigned long lastCountdownUpdate = 0; + static bool firstCountdownDisplay = true; + + // Display initial countdown value (3) immediately on first call + if (firstCountdownDisplay) { + lastCountdownUpdate = currentMillis; + firstCountdownDisplay = false; + displayCountdown(countdownValue); // Display "3" + countdownValue--; // Decrement so next interval shows "2" + return; + } + + // Check if it's time for next countdown step + if (currentMillis - lastCountdownUpdate >= COUNTDOWN_INTERVAL) { + lastCountdownUpdate = currentMillis; + + if (countdownValue > 0) { + displayCountdown(countdownValue); // Display "2" or "1" + countdownValue--; + } else if (countdownValue == 0) { + // Display "ПУСК" and start exercise + displayCountdown(0); // Display "ПУСК" + + // Wait 0.5 seconds for "ПУСК" display + delay(500); + + exerciseState = STATE_RUNNING; + exerciseProgress.startTime = millis(); + exerciseProgress.isPaused = false; + + // Reset all display caches so displays will redraw properly when exercise starts + resetTimeDisplayCacheFlag = true; + resetSpeedDisplayCacheFlag = true; + resetDistanceDisplayCacheFlag = true; + + digitalWrite(ENABLE_OUT, LOW); // Inverted: LOW = enabled + updateMotorSpeed(); + + // Reset static variable for next countdown sequence + firstCountdownDisplay = true; + + Serial.println(">>> УПРАЖНЕНИЕ НАЧАЛОСЬ"); + } + } +} + +// ============================================================================ +// EXERCISE TRACKING +// ============================================================================ + +// Calculate elapsed time accounting for all pause periods (including current pause) +unsigned long getElapsedTime(unsigned long currentMillis) { + if (exerciseProgress.startTime == 0) { + return 0; + } + + unsigned long elapsedTime = currentMillis - exerciseProgress.startTime - exerciseProgress.totalPausedTime; + + // If currently paused, subtract the current pause period + if (exerciseState == STATE_PAUSED && exerciseProgress.pausedTime > 0) { + elapsedTime -= (currentMillis - exerciseProgress.pausedTime); + } + + return elapsedTime; +} + +void trackExercise(unsigned long currentMillis) { + if (exerciseProgress.startTime == 0) { + exerciseProgress.startTime = currentMillis; + } + + // Calculate distance from encoder + // LPD3806-360BM-G5-24C: 1440 counts per revolution (quadrature decoding) + long currentEncoderCount = exerciseProgress.encoderCount; + exerciseProgress.distanceTraveled = abs(currentEncoderCount) * ENCODER_METERS_PER_COUNT; +} + +void checkExerciseCompletion(unsigned long currentMillis) { + if (exerciseMode == MODE_TIME) { + // Check if time elapsed + unsigned long elapsedTime = getElapsedTime(currentMillis); + unsigned long targetTime = (exerciseParams.timeMinutes * 60000UL) + (exerciseParams.timeSeconds * 1000UL); + + if (elapsedTime >= targetTime) { + completeExercise(); + } + } else if (exerciseMode == MODE_DISTANCE) { + // Check if distance reached + if (exerciseProgress.distanceTraveled >= exerciseParams.distanceMeters) { + completeExercise(); + } + } +} + +// ============================================================================ +// BUTTON HANDLERS +// ============================================================================ + +void checkButtons(unsigned long currentMillis) { + // Start button (also works as resume when paused) + bool currentStartBtnState = digitalRead(BTN_START); + if (currentStartBtnState == LOW && lastStartBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + if (exerciseState == STATE_READY || exerciseState == STATE_IDLE || exerciseState == STATE_MODE_SELECTED) { + startExercise(); + } else if (exerciseState == STATE_PAUSED) { + resumeExercise(); + } + } + lastStartBtnState = currentStartBtnState; + + // Stop button (pause or reset) + bool currentStopBtnState = digitalRead(BTN_STOP); + if (currentStopBtnState == LOW && lastStopBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + if (exerciseState == STATE_RUNNING) { + pauseExercise(); + } else if (exerciseState == STATE_PAUSED) { + // If paused, reset exercise + resetExercise(); + } + } + lastStopBtnState = currentStopBtnState; +} + +void checkRadioButtons(unsigned long currentMillis) { + // Radio start/stop toggle + bool currentRadioStartStopBtnState = digitalRead(RADIO_START_STOP); + if (currentRadioStartStopBtnState != lastRadioStartStopBtnState) { + delay(DEBOUNCE_DELAY); + + if (currentRadioStartStopBtnState == LOW && lastRadioStartStopBtnState == HIGH) { + // Button pressed + if (exerciseState == STATE_READY || exerciseState == STATE_IDLE || exerciseState == STATE_MODE_SELECTED) { + startExercise(); + } else if (exerciseState == STATE_RUNNING) { + pauseExercise(); + } else if (exerciseState == STATE_PAUSED) { + resumeExercise(); + } + } + } + lastRadioStartStopBtnState = currentRadioStartStopBtnState; + + // Note: Radio reset is handled in checkParameterButtons() for parameter reset + // and here for exercise reset + bool currentRadioResetBtnState = digitalRead(RADIO_RESET); + if (currentRadioResetBtnState == LOW && lastRadioResetBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + if (exerciseState == STATE_PAUSED) { + resetExercise(); + Serial.println("Радио: Упражнение сброшено"); + } + } + lastRadioResetBtnState = currentRadioResetBtnState; +} + +void checkSpeedButtons(unsigned long currentMillis) { + // Local speed buttons (with auto-repeat on hold) + bool currentSpeedUpBtnState = digitalRead(BTN_SPEED_UP); + if (currentSpeedUpBtnState == LOW && lastSpeedUpBtnState == HIGH) { + // Button just pressed - immediate action + speedUpPressTime = currentMillis; + lastSpeedUpRepeat = currentMillis; + if (!emergency) { + increaseSpeed(); + } + } else if (currentSpeedUpBtnState == LOW) { + // Button held - auto-repeat + if (currentMillis - speedUpPressTime > REPEAT_DELAY) { + // After initial delay, repeat at regular intervals + if (currentMillis - lastSpeedUpRepeat >= REPEAT_INTERVAL) { + lastSpeedUpRepeat = currentMillis; + if (!emergency) { + increaseSpeed(); + } + } + } + } else { + // Button released + speedUpPressTime = 0; + lastSpeedUpRepeat = 0; + } + lastSpeedUpBtnState = currentSpeedUpBtnState; + + bool currentSpeedDownBtnState = digitalRead(BTN_SPEED_DOWN); + if (currentSpeedDownBtnState == LOW && lastSpeedDownBtnState == HIGH) { + // Button just pressed - immediate action + speedDownPressTime = currentMillis; + lastSpeedDownRepeat = currentMillis; + if (!emergency) { + decreaseSpeed(); + } + } else if (currentSpeedDownBtnState == LOW) { + // Button held - auto-repeat + if (currentMillis - speedDownPressTime > REPEAT_DELAY) { + // After initial delay, repeat at regular intervals + if (currentMillis - lastSpeedDownRepeat >= REPEAT_INTERVAL) { + lastSpeedDownRepeat = currentMillis; + if (!emergency) { + decreaseSpeed(); + } + } + } + } else { + // Button released + speedDownPressTime = 0; + lastSpeedDownRepeat = 0; + } + lastSpeedDownBtnState = currentSpeedDownBtnState; + + // Radio speed buttons (with auto-repeat) + bool currentRadioSpeedUpBtnState = digitalRead(RADIO_SPEED_UP); + if (currentRadioSpeedUpBtnState == LOW) { + if (radioSpeedUpPressTime == 0) { + radioSpeedUpPressTime = currentMillis; + if (!emergency) { + increaseSpeed(); + } + } else if (currentMillis - radioSpeedUpPressTime > REPEAT_DELAY) { + if ((currentMillis - radioSpeedUpPressTime - REPEAT_DELAY) % REPEAT_INTERVAL == 0) { + if (!emergency) { + increaseSpeed(); + } + } + } + } else { + radioSpeedUpPressTime = 0; + } + lastRadioSpeedUpBtnState = currentRadioSpeedUpBtnState; + + bool currentRadioSpeedDownBtnState = digitalRead(RADIO_SPEED_DOWN); + if (currentRadioSpeedDownBtnState == LOW) { + if (radioSpeedDownPressTime == 0) { + radioSpeedDownPressTime = currentMillis; + if (!emergency) { + decreaseSpeed(); + } + } else if (currentMillis - radioSpeedDownPressTime > REPEAT_DELAY) { + if ((currentMillis - radioSpeedDownPressTime - REPEAT_DELAY) % REPEAT_INTERVAL == 0) { + if (!emergency) { + decreaseSpeed(); + } + } + } + } else { + radioSpeedDownPressTime = 0; + } + lastRadioSpeedDownBtnState = currentRadioSpeedDownBtnState; +} + +// ============================================================================ +// SPEED CONTROL +// ============================================================================ + +void increaseSpeed() { + if (emergency || speedPercent >= SPEED_MAX) return; + + speedPercent += SPEED_STEP; + if (speedPercent > SPEED_MAX) speedPercent = SPEED_MAX; + + // Save speed to EEPROM + saveSettingsToEEPROM(); + + if (exerciseState == STATE_RUNNING) { + updateMotorSpeed(); + } + + updateLCDSettings(); +} + +void decreaseSpeed() { + if (emergency || speedPercent <= SPEED_MIN) return; + + speedPercent -= SPEED_STEP; + if (speedPercent < SPEED_MIN) speedPercent = SPEED_MIN; + + // Save speed to EEPROM + saveSettingsToEEPROM(); + + if (exerciseState == STATE_RUNNING) { + updateMotorSpeed(); + } + + updateLCDSettings(); +} + +void updateMotorSpeed() { + if (exerciseState != STATE_RUNNING || emergency) { + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + return; + } + + int pwmValue = map(speedPercent, 0, 100, 0, 255); + analogWrite(PWM_OUT, pwmValue); +} + +// ============================================================================ +// SAFETY AND EMERGENCY +// ============================================================================ + +void checkSafety() { + // D33: Normally LOW, emergency HIGH (HIGH = triggered) + bool limitTripped = (digitalRead(LIMIT_SW_SIGNAL) == HIGH); + + if (limitTripped && !emergency) { + emergency = true; + exerciseState = STATE_EMERGENCY; + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, LOW); + digitalWrite(LED_EMERGENCY, HIGH); + + // Initialize matrix flash state and start flashing immediately + emergencyMatrixFlashState = true; + lastEmergencyMatrixFlash = millis(); + // Turn on all LEDs immediately + for (int module = 0; module < MATRIX_MODULES; module++) { + for (int row = 0; row < 8; row++) { + matrix.setRow(module, row, 0xFF); // All pixels ON + } + } + + Serial.println("АВАРИЯ: Концевик разомкнут !!!"); + Serial.println("Для сброса: замкнуть концевики и нажать СТОП"); + } +} + +void checkEmergencyButton(unsigned long currentMillis) { + bool currentEmergencyBtnState = digitalRead(BTN_EMERGENCY_MUSHROOM); + + // D29: Normally LOW, emergency HIGH (HIGH = pressed/triggered) + if (currentEmergencyBtnState == HIGH && lastEmergencyBtnState == LOW) { + // Emergency button pressed (HIGH = emergency) + delay(DEBOUNCE_DELAY); + + emergency = true; + exerciseState = STATE_EMERGENCY; + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, LOW); + digitalWrite(LED_EMERGENCY, HIGH); + + // Initialize matrix flash state and start flashing immediately + emergencyMatrixFlashState = true; + lastEmergencyMatrixFlash = millis(); + // Turn on all LEDs immediately + for (int module = 0; module < MATRIX_MODULES; module++) { + for (int row = 0; row < 8; row++) { + matrix.setRow(module, row, 0xFF); // All pixels ON + } + } + + Serial.println("АВАРИЯ: Нажат аварийный выключатель (Грибок) !!!"); + } else if (currentEmergencyBtnState == LOW && lastEmergencyBtnState == HIGH) { + // Emergency button released (LOW = normal, not pressed) + delay(DEBOUNCE_DELAY); + + bool limitTripped = (digitalRead(LIMIT_SW_SIGNAL) == HIGH); + bool laserTripped = (digitalRead(EMERGENCY_LASER_SENSORS) == LOW); + + if (!limitTripped && !laserTripped) { + emergency = false; + exerciseState = STATE_IDLE; + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, HIGH); + digitalWrite(LED_EMERGENCY, LOW); + + // Clear matrix when emergency is reset + clearAllMatrix(); + emergencyMatrixFlashState = false; + + Serial.println("Авария сброшена (Грибок отпущен)"); + Serial.println("Система в режиме СТОП"); + } else { + if (limitTripped) Serial.println("Концевик все еще разомкнут!"); + if (laserTripped) Serial.println("Лазерные датчики все еще сработали!"); + } + } + + lastEmergencyBtnState = currentEmergencyBtnState; +} + +void checkEmergencyLaserSensors(unsigned long currentMillis) { + // D32: Normally HIGH, emergency LOW (LOW = triggered) + bool laserTripped = (digitalRead(EMERGENCY_LASER_SENSORS) == LOW); + + if (laserTripped && !emergency) { + emergency = true; + exerciseState = STATE_EMERGENCY; + analogWrite(PWM_OUT, 0); + digitalWrite(ENABLE_OUT, HIGH); // Inverted: HIGH = disabled + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, LOW); + digitalWrite(LED_EMERGENCY, HIGH); + + // Initialize matrix flash state and start flashing immediately + emergencyMatrixFlashState = true; + lastEmergencyMatrixFlash = millis(); + // Turn on all LEDs immediately + for (int module = 0; module < MATRIX_MODULES; module++) { + for (int row = 0; row < 8; row++) { + matrix.setRow(module, row, 0xFF); // All pixels ON + } + } + + Serial.println("АВАРИЯ: Сработали аварийные лазерные датчики !!!"); + } +} + +void emergencyHandler(unsigned long currentMillis) { + // Blink emergency LED + if (currentMillis - lastEmergencyBlink >= EMERGENCY_BLINK_INTERVAL) { + emergencyBlinkState = !emergencyBlinkState; + digitalWrite(LED_EMERGENCY, emergencyBlinkState); + lastEmergencyBlink = currentMillis; + } + + // Flash LED matrix full color in 2-second cycle (1 second on, 1 second off) + if (currentMillis - lastEmergencyMatrixFlash >= EMERGENCY_MATRIX_FLASH_INTERVAL) { + emergencyMatrixFlashState = !emergencyMatrixFlashState; + + if (emergencyMatrixFlashState) { + // Turn on all LEDs on all modules (full color = all pixels lit) + for (int module = 0; module < MATRIX_MODULES; module++) { + for (int row = 0; row < 8; row++) { + matrix.setRow(module, row, 0xFF); // 0xFF = all 8 pixels in row are ON + } + } + } else { + // Turn off all LEDs (clear matrix) + clearAllMatrix(); + } + + lastEmergencyMatrixFlash = currentMillis; + } + + // Check for reset (STOP button) + bool currentStopBtnState = digitalRead(BTN_STOP); + + if (currentStopBtnState == LOW && lastStopBtnState == HIGH) { + delay(DEBOUNCE_DELAY); + + bool limitTripped = (digitalRead(LIMIT_SW_SIGNAL) == HIGH); // D33: Normally LOW, emergency HIGH + bool emergencyBtnPressed = (digitalRead(BTN_EMERGENCY_MUSHROOM) == HIGH); // D29: Normally LOW, emergency HIGH + bool laserTripped = (digitalRead(EMERGENCY_LASER_SENSORS) == LOW); // D32: Normally HIGH, emergency LOW + + if (!limitTripped && !emergencyBtnPressed && !laserTripped) { + emergency = false; + exerciseState = STATE_IDLE; + digitalWrite(LED_GREEN, LOW); + digitalWrite(LED_RED, HIGH); + digitalWrite(LED_EMERGENCY, LOW); + + // Clear matrix when emergency is reset + clearAllMatrix(); + emergencyMatrixFlashState = false; + + Serial.println("Авария сброшена"); + Serial.println("Система в режиме СТОП"); + } else { + if (limitTripped) Serial.println("Концевик все еще разомкнут!"); + if (emergencyBtnPressed) Serial.println("Грибок все еще нажат!"); + if (laserTripped) Serial.println("Лазерные датчики все еще сработали!"); + } + } + + lastStopBtnState = currentStopBtnState; +} + +// ============================================================================ +// DISPLAY FUNCTIONS +// ============================================================================ + +void updateLCDSettings() { + lcd.clear(); + + // Reinitialize custom characters after clear (some LCDs clear CGRAM on clear()) + initCustomChars(); + delay(10); // Small delay to ensure characters are loaded + + lcd.setCursor(0, 0); + lcdPrintRussianText("Скорость: "); + lcd.print(speedPercent); + lcd.print("%"); + + lcd.setCursor(0, 1); + if (exerciseMode == MODE_TIME) { + lcdPrintRussianText("Время: "); + lcd.print(exerciseParams.timeMinutes); + lcd.print(":"); + if (exerciseParams.timeSeconds < 10) lcd.print("0"); + lcd.print(exerciseParams.timeSeconds); + } else if (exerciseMode == MODE_DISTANCE) { + lcdPrintRussianText("Дистанция: "); + lcd.print(exerciseParams.distanceMeters); + lcdPrintRussianText(" м"); + } else { + // Should not happen since we default to time mode, but just in case + lcdPrintRussianText("Режим: ВРЕМЯ"); + } +} + +void displayWelcome() { + // Display welcome message on matrix + clearAllMatrix(); + // Display "СТАРТ" (START) or first few letters + const char* welcomeText = "СТАРТ"; + displayText(welcomeText); + delay(2000); + clearAllMatrix(); +} + +void displayCountdown(int value) { + clearAllMatrix(); + + if (value > 0) { + // Display countdown number (3, 2, 1) - show large in center + int centerModule = MATRIX_MODULES / 2; + displayChar('0' + value, centerModule); + } else { + // Display "ПУСК" (GO/START) + const char* startText = "ПУСК"; + displayText(startText); + } +} + +// displayNumber is now defined in font section above + +// displayText is now defined in font section above + +void displayCompletion() { + clearAllMatrix(); + // Display "ГОТОВО" (COMPLETE) or first few letters + const char* completeText = "ГОТОВО"; + displayText(completeText); + delay(1000); + // Show all LEDs briefly on all modules + for (int module = 0; module < MATRIX_MODULES; module++) { + for (int i = 0; i < 8; i++) { + matrix.setRow(module, i, 0xFF); + } + } + delay(500); + clearAllMatrix(); +} + +void updateDisplays(unsigned long currentMillis) { + if (exerciseState == STATE_RUNNING) { + // Always show remaining time/distance on matrix (per requirements) + if (exerciseMode == MODE_TIME) { + // Display remaining time + unsigned long elapsedTime = getElapsedTime(currentMillis); + unsigned long targetTime = (exerciseParams.timeMinutes * 60000UL) + (exerciseParams.timeSeconds * 1000UL); + unsigned long remainingTime = (targetTime > elapsedTime) ? (targetTime - elapsedTime) : 0; + unsigned long remainingSeconds = remainingTime / 1000; + unsigned long remainingMinutes = remainingSeconds / 60; + remainingSeconds = remainingSeconds % 60; + + // Display remaining time on modules 0-4 (MM:SS format = 5 modules) + displayTimeMMSS(remainingMinutes, remainingSeconds); + + // Display speed on remaining modules (if enabled and we have enough modules) + if (showSpeedOnMatrix && MATRIX_MODULES >= 8) { + displaySpeedOnModules(speedPercent, 5); + } else if (showSpeedOnMatrix && MATRIX_MODULES >= 6) { + // If we have 6-7 modules, show speed on last 2-3 modules (compact) + int speedStartModule = MATRIX_MODULES - 3; + if (speedStartModule >= 5) { + displaySpeedOnModules(speedPercent, speedStartModule); + } + } + } else if (exerciseMode == MODE_DISTANCE) { + // Display remaining distance + float remainingDistance = exerciseParams.distanceMeters - exerciseProgress.distanceTraveled; + if (remainingDistance < 0) remainingDistance = 0; + + // Display remaining distance (will use as many modules as needed) + displayDistanceOnModules(remainingDistance, 0); + + // Display speed on last 3 modules (if enabled and we have enough modules) + // Speed will overwrite distance if they overlap, but distance is more important + if (showSpeedOnMatrix && MATRIX_MODULES >= 6) { + int speedStartModule = MATRIX_MODULES - 3; + if (speedStartModule >= 0) { + displaySpeedOnModules(speedPercent, speedStartModule); + } + } + } + } else if (exerciseState == STATE_COUNTDOWN) { + // Countdown is handled separately in handleCountdown() + } else if (exerciseState == STATE_IDLE || exerciseState == STATE_MODE_SELECTED || exerciseState == STATE_READY) { + clearAllMatrix(); + } else if (exerciseState == STATE_PAUSED) { + // Show stopped timer/distance when paused (same as running but frozen) + if (exerciseMode == MODE_TIME) { + // Display remaining time (frozen, timer is stopped) + unsigned long elapsedTime = getElapsedTime(currentMillis); + unsigned long targetTime = (exerciseParams.timeMinutes * 60000UL) + (exerciseParams.timeSeconds * 1000UL); + unsigned long remainingTime = (targetTime > elapsedTime) ? (targetTime - elapsedTime) : 0; + unsigned long remainingSeconds = remainingTime / 1000; + unsigned long remainingMinutes = remainingSeconds / 60; + remainingSeconds = remainingSeconds % 60; + + // Display remaining time on modules 0-4 (MM:SS format = 5 modules) + displayTimeMMSS(remainingMinutes, remainingSeconds); + + // Display speed on remaining modules (if enabled and we have enough modules) + if (showSpeedOnMatrix && MATRIX_MODULES >= 8) { + displaySpeedOnModules(speedPercent, 5); + } else if (showSpeedOnMatrix && MATRIX_MODULES >= 6) { + // If we have 6-7 modules, show speed on last 2-3 modules (compact) + int speedStartModule = MATRIX_MODULES - 3; + if (speedStartModule >= 5) { + displaySpeedOnModules(speedPercent, speedStartModule); + } + } + } else if (exerciseMode == MODE_DISTANCE) { + // Display remaining distance (frozen) + float remainingDistance = exerciseParams.distanceMeters - exerciseProgress.distanceTraveled; + if (remainingDistance < 0) remainingDistance = 0; + + // Display remaining distance (will use as many modules as needed) + displayDistanceOnModules(remainingDistance, 0); + + // Display speed on last 3 modules (if enabled and we have enough modules) + if (showSpeedOnMatrix && MATRIX_MODULES >= 6) { + int speedStartModule = MATRIX_MODULES - 3; + if (speedStartModule >= 0) { + displaySpeedOnModules(speedPercent, speedStartModule); + } + } + } + } +} + +void displayDistance(float distance) { + // Display distance in meters on matrix using font (uses all available modules) + displayDistanceOnModules(distance, 0); +} + +void displayTime(unsigned long minutes, unsigned long seconds) { + // Display time MM:SS on matrix using font (uses modules 0-4) + displayTimeMMSS(minutes, seconds); +} + +// Display distance starting from a specific module index +void displayDistanceOnModules(float distance, int startModule) { + if (startModule < 0 || startModule >= MATRIX_MODULES) return; + + int meters = (int)distance; + int availableModules = MATRIX_MODULES - startModule; + + // Extract digits (up to available modules) + int digits[8]; + int digitCount = 0; + + if (meters == 0) { + digits[0] = 0; + digitCount = 1; + } else { + int temp = meters; + while (temp > 0 && digitCount < availableModules) { + digits[digitCount] = temp % 10; + temp /= 10; + digitCount++; + } + } + + // Cache last displayed digits per module + static int lastDigits[8] = {-1, -1, -1, -1, -1, -1, -1, -1}; + static int lastDigitCount = 0; + + // Reset cache if flag is set (called from pause/resume) + if (resetDistanceDisplayCacheFlag) { + for (int i = 0; i < 8; i++) { + lastDigits[i] = -1; + } + lastDigitCount = 0; + resetDistanceDisplayCacheFlag = false; + // Clear modules to force full redraw + for (int i = 0; i < availableModules && (startModule + i) < MATRIX_MODULES; i++) { + matrix.clearDisplay(startModule + i); + } + } + + // Only update if digit count changed or digits changed + bool needsUpdate = (digitCount != lastDigitCount); + if (!needsUpdate) { + for (int i = 0; i < digitCount; i++) { + int digitIndex = digitCount - 1 - i; + if (digits[digitIndex] != lastDigits[i]) { + needsUpdate = true; + break; + } + } + } + + if (needsUpdate) { + // Clear only the modules we'll actually use (prevents flashing) + for (int i = 0; i < digitCount && (startModule + i) < MATRIX_MODULES; i++) { + matrix.clearDisplay(startModule + i); + } + + // Display digits left to right (most significant on left) + for (int i = 0; i < digitCount && (startModule + i) < MATRIX_MODULES; i++) { + int digitIndex = digitCount - 1 - i; // Get digit from most to least significant + int moduleIndex = startModule + i; // Display from left to right + displayChar('0' + digits[digitIndex], moduleIndex); + lastDigits[i] = digits[digitIndex]; + } + lastDigitCount = digitCount; + } +} + +// Display speed on specific modules (3 digits for 0-100%) +void displaySpeedOnModules(int speed, int startModule) { + if (startModule < 0 || startModule >= MATRIX_MODULES) return; + if (speed < 0) speed = 0; + if (speed > 100) speed = 100; + + int availableModules = MATRIX_MODULES - startModule; + if (availableModules < 3) return; // Need at least 3 modules for speed display + + // Extract digits (hundreds, tens, ones) + int hundreds = speed / 100; + int tens = (speed / 10) % 10; + int ones = speed % 10; + + // Cache last displayed values per module position + static int lastSpeedHundreds = -1, lastSpeedTens = -1, lastSpeedOnes = -1; + static int lastSpeedStartModule = -1; + + // Reset cache if flag is set (called from pause/resume) or start module changed + if (resetSpeedDisplayCacheFlag || lastSpeedStartModule != startModule) { + lastSpeedHundreds = -1; + lastSpeedTens = -1; + lastSpeedOnes = -1; + lastSpeedStartModule = startModule; + resetSpeedDisplayCacheFlag = false; + // Clear modules to force full redraw + for (int i = 0; i < 3 && (startModule + i) < MATRIX_MODULES; i++) { + matrix.clearDisplay(startModule + i); + } + } + + // Only update if values changed + if (hundreds > 0) { + if (hundreds != lastSpeedHundreds) { + matrix.clearDisplay(startModule); + displayChar('0' + hundreds, startModule); + lastSpeedHundreds = hundreds; + } + if (tens != lastSpeedTens) { + matrix.clearDisplay(startModule + 1); + displayChar('0' + tens, startModule + 1); + lastSpeedTens = tens; + } + if (ones != lastSpeedOnes) { + matrix.clearDisplay(startModule + 2); + displayChar('0' + ones, startModule + 2); + lastSpeedOnes = ones; + } + } else { + // Don't show leading zero - show space + if (lastSpeedHundreds > 0 || lastSpeedHundreds == -1) { + matrix.clearDisplay(startModule); + displayChar(' ', startModule); + lastSpeedHundreds = 0; + } + if (tens != lastSpeedTens) { + matrix.clearDisplay(startModule + 1); + displayChar('0' + tens, startModule + 1); + lastSpeedTens = tens; + } + if (ones != lastSpeedOnes) { + matrix.clearDisplay(startModule + 2); + displayChar('0' + ones, startModule + 2); + lastSpeedOnes = ones; + } + } +} + +void displaySpeed(int speed) { + // Display speed percentage on matrix using font (uses all available modules) + displaySpeedOnModules(speed, 0); +} + +// ============================================================================ +// STATUS PRINTING +// ============================================================================ + +void printStatus() { + Serial.print("Состояние: "); + switch (exerciseState) { + case STATE_IDLE: + Serial.print("ОЖИДАНИЕ"); + break; + case STATE_MODE_SELECTED: + Serial.print("ВЫБОР ПАРАМЕТРОВ"); + break; + case STATE_READY: + Serial.print("ГОТОВ"); + break; + case STATE_COUNTDOWN: + Serial.print("ОТСЧЕТ"); + break; + case STATE_RUNNING: + Serial.print("РАБОТА"); + break; + case STATE_PAUSED: + Serial.print("ПАУЗА"); + break; + case STATE_EMERGENCY: + Serial.print("АВАРИЯ"); + break; + } + + Serial.print(" | Режим: "); + switch (exerciseMode) { + case MODE_NONE: + Serial.print("НЕТ"); + break; + case MODE_TIME: + Serial.print("ВРЕМЯ"); + break; + case MODE_DISTANCE: + Serial.print("ДИСТАНЦИЯ"); + break; + } + + Serial.print(" | Концевик: "); + Serial.print(digitalRead(LIMIT_SW_SIGNAL) == HIGH ? "РАЗОМКНУТ" : "ЗАМКНУТ"); + + Serial.print(" | Мощность: "); + Serial.print(speedPercent); + Serial.print("%"); + + if (exerciseState == STATE_RUNNING) { + Serial.print(" | Дистанция: "); + Serial.print(exerciseProgress.distanceTraveled, 1); + Serial.print("м"); + + unsigned long elapsedTime = getElapsedTime(millis()); + Serial.print(" | Время: "); + Serial.print(elapsedTime / 1000); + Serial.print("с"); + } + + Serial.println(); +}