Как написать крупный проект?

Программа новичка в Arduino очень часто выглядит как нечитаемое полотно, в котором всё свалено в кучу. Чем крупнее становится проект, тем сложнее и неприятнее становится его дальнейшая разработка. В этом уроке мы рассмотрим несколько подходов к упрощению и улучшению написания крупных проектов. Для понимания данного урока понадобятся некоторые уроки из следующего блока:

Улучшаем “Ардуино-код”

Разделить на функции


Используйте функции (урок про функции)! Практически любую часть кода можно обернуть в функцию и вынести из основного цикла. Например:

void loop() {
  // конструкция таймера
  // опрос датчика
  // фильтрация значений
  // другой код
  // другой код
  // другой код
}

Сделаем так:

void loop() {
  sensorRead();
  // другой код
  // другой код
  // другой код
}

void sensorRead() {
  // конструкция таймера
  // опрос датчика
  // фильтрация значений
}

Эти функции можно располагать во вкладках и группировать по смыслу.

Глобальные переменные


Нужно стремиться к уменьшению количества глобальных переменных в скетче. Во-первых это просто “некрасиво”, а во вторых – в какой то момент могут начаться проблемы с придумыванием уникальных имён и распознаванием их в полотне кода.

Первый шаг – использовать статические переменные там, где это возможно. Самый простой пример – конструкция программного таймера из урока про многозадачность:

uint32_t tmr1;         // переменная таймера

void setup() {}

void loop() {
  if (millis() - tmr1 >= 1000) {
    tmr1 = millis();
    // выполнить действие
  }
}

Если понадобится несколько таймеров – придётся создать больше переменных и придумывать им названия. Можно вынести каждый таймер в отдельную функцию и в ней сделать переменную статической. Пример с двумя таймерами:

void setup() {}

void loop() {
  sensorTimer();
  timeoutTimer();
}

void sensorTimer() {
  static uint32_t tmr;
  if (millis() - tmr >= 1000) {
    tmr = millis();
    // выполнить действие
  }
}

void timeoutTimer() {
  static uint32_t tmr;
  if (millis() - tmr >= 1000) {
    tmr = millis();
    // выполнить действие
  }
}

Также можно использовать структуры из следующего блока уроков, чтобы объединить несколько схожих по смыслу переменных под одним ярлыком:

float tempRaw;
float tempFilter;
int filterPeriod;

bool btnState;
bool btnFlag;
int btnPeriod;

void setup() {}

void loop() {
}

Объединим в структуры и упростим имена переменных:

struct {
  float raw;
  float filter;
  int period;
} temp;

struct {
  bool state;
  bool flag;
  int period;
} btn;

void setup() {}

void loop() {
  // обращение к элементам
  temp.raw = sensorRead();
  btn.raw = pinRead();
}

Разделить данные и код


Одной из основ программирования на C++ является отделение данных (переменных) от выполняемого кода. В большинстве случаев можно использовать локальные переменные и передавать их в функции, вместо того чтобы брать их из глобальной области.

Рассмотрим абстрактный пример, в котором две независимые функции обращаются к одной и той же глобальной переменной:

float temp; // температура с датчика

void setup() {}

void loop() {
  sensorRead();
  regulator();
}

void sensorRead() {
  temp = читаем датчик;
  // фильтруем/обрабатываем temp
}

void regulator() {
  // например PID регулятор
  output = temp * k;
}

Давайте избавимся от глобальной переменной, например так:

void setup() {}

void loop() {
  float temp = sensorRead();
  regulator(temp);
}

float sensorRead() {
  // читаем датчик
  // фильтруем/обрабатываем
  return значение;
}

void regulator(float temp) {
  // например PID регулятор
  output = temp * k;
}

Можно пойти дальше и избавиться даже от локальной переменной:

void setup() {}

void loop() {
  regulator(sensorRead());
}

float sensorRead() {
  // читаем датчик;
  // фильтруем/обрабатываем temp
  return значение;
}

void regulator(float temp) {
  // например PID регулятор
  output = temp * k;
}

Использовать классы


Ещё более правильным будет создание класса (урок про классы), в котором реализуется полностью независимая часть программы, со своими переменными и функциями. Удобство класса также состоит в том, что оформленные в виде классов наработки можно использовать в других проектах, не меняя код класса. В качестве примера можно рассмотреть любую библиотеку – для датчика, дисплея или какого-то алгоритма.

Разбиваем на файлы


Чтобы проект можно было разделить на несколько файлов, он изначально должен состоять из частей, которые могут функционировать независимо друг от друга и не пересекаться друг с другом. Такую часть можно назвать “подпрограммой”, которая содержат свой код и набор переменных и точно так же может состоять из нескольких файлов. Знакомая история? Ведь именно так и реализованы библиотеки! Библиотека содержит набор инструментов, который не зависит от основной программы может использоваться даже в другом проекте. Используя библиотеку, мы уже разделяем свой проект на несколько файлов.

Не существует библиотек на все случаи жизни, поэтому приличную часть кода придётся писать самостоятельно: всякие алгоритмы, эффекты и так далее. Вот как раз их и можно вынести в отдельные файлы, есть два способа: обернуть в класс или просто вынести код в отдельный файл.

Пример 1


Давайте рассмотрим пример превращения ужасного “винигретного” кода с кучей глобальных переменных и бардаком в главном цикле в понятную программу с отдельными независимыми подпрограммами. В этом примере у нас подключены две кнопки (на пины D2 и D3) и два светодиода (используем бортовой на пине D13 и внешний на D12).

Напишем программу, которая будет:

  • Асинхронно опрашивать кнопки с программным гашением дребезга контактов
  • Мигать светодиодом 1 с периодом, который настраивается кнопками
  • Мигать светодиодом 2 с периодом, который:

Подробно комментировать код не буду, потому что все используемые конструкции мы уже разбирали в уроках.

Плохой код

// пины
const byte btn1 = 2;
const byte btn2 = 3;
const byte led1 = 13;
const byte led2 = 12;
const byte analogPin = 0;

// таймеры дебаунса кнопок
uint32_t btn1Tmr;
uint32_t btn2Tmr;

// флаги опроса кнопок
bool btn1Flag;
bool btn2Flag;

// переменные для светодиода 1
uint32_t ledTmr1;
uint16_t ledPeriod1 = 1000;  // начальный период 1 с
bool ledState1 = false;
const int step = 50;    // шаг изменения

// переменные для светодиода 2
uint32_t ledTmr2;
uint16_t ledPeriod2 = 1000;
bool ledState2 = false;

// таймер для случайного периода
uint32_t rndTmr;
uint16_t rndPeriod = 2000;

// переменная рандома
uint32_t seed = 0;

void setup () {
  // настраиваем пины
  pinMode(btn1, INPUT_PULLUP);
  pinMode(btn2, INPUT_PULLUP);
  pinMode(led1, OUTPUT);
  pinMode(led2, OUTPUT);
}

void loop() {
  // таймер светодиода 1
  if (millis() - ledTmr1 >= ledPeriod1) {
    ledTmr1 = millis();
    ledState1 = !ledState1;
    digitalWrite(led1, ledState1);
  }

  // таймер светодиода 2
  if (millis() - ledTmr2 >= ledPeriod2) {
    ledTmr2 = millis();
    ledState2 = !ledState2;
    digitalWrite(led2, ledState2);
  }

  // таймер рандома
  if (millis() - rndTmr >= rndPeriod) {
    rndTmr = millis();
    for (int i = 0; i < 16; i++) {
      seed *= 4;
      seed += analogRead(analogPin) & 3;
    }
    // ограничим 1000 мс (1 секунда)
    ledPeriod2 = seed % 1000;
  }

  // опрос первой кнопки с дебаунсом 100мс
  bool btn1State = digitalRead(btn1);
  if (!btn1State && !btn1Flag && millis() - btn1Tmr >= 100) {
    btn1Flag = true;
    btn1Tmr = millis();
    ledPeriod1 += step;    // увеличить период
  }
  if (btn1State && btn1Flag && millis() - btn1Tmr >= 100) {
    btn1Flag = false;
    btn1Tmr = millis();
  }

  // опрос второй кнопки с дебаунсом 100мс
  bool btn2State = digitalRead(btn2);
  if (!btn2State && !btn2Flag && millis() - btn2Tmr >= 100) {
    btn2Flag = true;
    btn2Tmr = millis();
    ledPeriod1 -= step;    // уменьшить период
  }
  if (btn2State && btn2Flag && millis() - btn2Tmr >= 100) {
    btn2Flag = false;
    btn2Tmr = millis();
  }
}

Добавление в программу дополнительных кнопок или дополнительной функциональности светодиода приведет к сильному запутыванию и увеличению объема кода, разобраться в нём будет уже гораздо труднее.

Давайте обернём обработку кнопки в класс, ведь у нас уже есть две одинаковые кнопки и в будущем в программу можно будет добавить ещё. Сразу вынесем класс в отдельный файл и оформим как библиотеку. Я делаю это в одном файле, чтобы не дублировать один и тот же код.

button.h
// класс кнопки
#pragma once
#include <Arduino.h>
#define _BTN_DEB_TIME 100  // таймаут антидребезга

class Button {
  public:
    Button (byte pin) : _pin(pin) {
      pinMode(_pin, INPUT_PULLUP);
    }
    bool click() {
      bool btnState = digitalRead(_pin);
      if (!btnState && !_flag && millis() - _tmr >= _BTN_DEB_TIME) {
        _flag = true;
        _tmr = millis();
        return true;
      }
      if (btnState && _flag && millis() - _tmr >= _BTN_DEB_TIME) {
        _flag = false;
        _tmr = millis();
      }
      return false;
    }

  private:
    const byte _pin;
    uint32_t _tmr;
    bool _flag;
};

Обработчик кнопки теперь работает следующим образом: возвращает true, если соответствующая кнопка была нажата. В основной программе мы поместим метод click() в условие и будем изменять период светодиода по нему.

Далее, у нас несколько раз используется одинаковая конструкция таймера на millis(). Давайте также обернём её в класс, саму конструкцию и класс мы разбирали в уроке про многозадачность.

timer.h
#pragma once
#include <Arduino.h>

class Timer {
  public:
    Timer(uint16_t nprd = 0) {
      setPeriod(nprd);
    }

    void setPeriod(uint16_t nprd) {
      prd = nprd;
    }

    uint16_t getPeriod() {
      return prd;
    }

    bool ready() {
      if (millis() - tmr >= prd) {
        tmr = millis();
        return true;
      }
      return false;
    }
  private:
    uint32_t tmr = 0;
    uint16_t prd = 0;
};

Теперь достаточно объявить и настроить таймер, а для проверки – опрашивать метод ready(), который вернёт true при срабатывании. Настроить период можно через setPeriod(), а получить – через getPeriod().

Светодиода у нас два, поэтому тоже будет логично обернуть код мигания в класс. Светодиод мигает по таймеру, поэтому мы можем использовать уже написанную библиотеку таймера внутри класса светодиода, это сократит объём кода и уменьшит итоговый вес программы:

led.h
#pragma once
#include <Arduino.h>
#include "timer.h"

class LED {
  public:
    LED (byte pin, int period) : _pin(pin) {
      pinMode(_pin, OUTPUT);
      tmr.setPeriod(period);
    }
    
    void setPeriod(uint16_t prd) {
      tmr.setPeriod(prd);
    }

    uint16_t getPeriod() {
      return (tmr.getPeriod());
    }

    void blink() {
      if (tmr.ready()) {
        digitalWrite(_pin, flag);
        flag = !flag;
      }
    }
  private:
    const byte _pin;
    bool flag;
    Timer tmr;
};

Теперь достаточно объявить светодиод с указанием пина и периода мигания и просто вызывать в цикле метод blink().

Остался у нас алгоритм получения случайных чисел с аналогового пина. Для него класс не нужен, достаточно будет просто вынести функцию в отдельный файл и сделать так, чтобы она принимала номер пина. Здесь нам придётся создать два файла: заголовочный и файл реализации. Переменную генератора мы “спрячем” от основной программы, объявив её как static внутри файла реализации.

rnd.h
#pragma once
#include <Arduino.h>

uint32_t getRandom(byte pin);
rnd.cpp
#include "rnd.h"

static uint32_t seed = 0;

uint32_t getRandom(byte pin) {  
  for (int i = 0; i < 16; i++) {
    seed *= 4;
    seed += analogRead(pin) & 3;
  }
  return seed;
}

Положим наши библиотеки рядом с файлом скетча, подключим их в код и посмотрим, как теперь выглядит наш проект. Также я обозначил все константы пинов через #define, чтобы не дублировать их в оперативной памяти:

Скетч
#include "timer.h"
#include "button.h"
#include "led.h"
#include "rnd.h"

#define BTN1_PIN 2
#define BTN2_PIN 3
#define LED1_PIN 13
#define LED2_PIN 12
#define LED1_STEP 50

LED led1(LED1_PIN, 1000);
LED led2(LED2_PIN, 1000);

Button btn1(BTN1_PIN);
Button btn2(BTN2_PIN);

Timer rndTimer(2000);

void setup() {
}

void loop() {
  led1.blink();
  led2.blink();
  if (rndTimer.ready()) led2.setPeriod(getRandom(0) % 1000);
  if (btn1.click()) led1.setPeriod(led1.getPeriod() + LED1_STEP);
  if (btn2.click()) led1.setPeriod(led1.getPeriod() - LED1_STEP);
}

Программа стала гораздо компактнее, а также занимает меньше памяти: 1618 байт против 1796. Легче почти на 200 байт!

Давайте добавим функционал: пусть будет ещё две кнопки (D4 и D5), по кликам которых можно будет включать и выключать первый и второй светодиоды соответственно. Для этого добавим в класс светодиода флаг состояния, по которому будем принудительно выключать светодиод. Также понадобится пара методов, чтобы установить или снять этот флаг. Можно сделать один, который будет переключать состояние светодиода, такая реализация будет компактнее:

led.h
#pragma once
#include <Arduino.h>
#include "timer.h"

class LED {
  public:
    LED (byte pin, int period) : _pin(pin) {
      pinMode(_pin, OUTPUT);
      tmr.setPeriod(period);
    }

    void setPeriod(uint16_t prd) {
      tmr.setPeriod(prd);
    }

    uint16_t getPeriod() {
      return (tmr.getPeriod());
    }

    void blink() {
      if (state) {
        if (tmr.ready()) {
          flag = !flag;
          digitalWrite(_pin, flag);
        }
      } else {
        if (flag) digitalWrite(_pin, 0);
      }
    }

    void toggle() {
      state = !state;
    }
    
  private:
    const byte _pin;
    bool flag, state = true;
    Timer tmr;
};

И финальная программа:

Скетч
#include "timer.h"
#include "button.h"
#include "led.h"
#include "rnd.h"

#define BTN1_PIN 2
#define BTN2_PIN 3
#define BTN3_PIN 4
#define BTN4_PIN 5
#define LED1_PIN 13
#define LED2_PIN 12
#define LED1_STEP 50

LED led1(LED1_PIN, 1000);
LED led2(LED2_PIN, 1000);

Button btn1(BTN1_PIN);
Button btn2(BTN2_PIN);
Button btn3(BTN3_PIN);
Button btn4(BTN4_PIN);

Timer rndTimer(2000);

void setup() {
}

void loop() {
  led1.blink();
  led2.blink();
  if (rndTimer.ready()) led2.setPeriod(getRandom(0) % 1000);
  if (btn1.click()) led1.setPeriod(led1.getPeriod() + LED1_STEP);
  if (btn2.click()) led1.setPeriod(led1.getPeriod() - LED1_STEP);
  if (btn3.click()) led1.toggle();
  if (btn4.click()) led2.toggle();
}

Пример 2


Далее давайте вспомним пример с метеостанцией и попробуем его немного “причесать”:

Метеостанция
// НАСТРОЙКИ
#define ONE_WIRE_BUS 2  // пин ds18b20

// БИБЛИОТЕКИ
#include <RTClib.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <OneWire.h>
#include <DallasTemperature.h>

// ОБЪЕКТЫ И ПЕРЕМЕННЫЕ
// адрес может быть 0x27 или 0x3f
LiquidCrystal_I2C lcd(0x3f, 16, 2); // Устанавливаем дисплей
RTC_DS3231 rtc;
OneWire oneWire(ONE_WIRE_BUS);
DallasTemperature sensors(&oneWire);

uint32_t myTimer1, myTimer2, myTimer3;
boolean LEDflag = false;
float tempSum = 0, temp;
byte tempCounter;

void setup() {
  pinMode(13, 1);     // светодиод
  
  // дисплей
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея
  
  // термометр
  sensors.begin();
  sensors.setWaitForConversion(false);  // асинхронное получение данных
  
  // часы
  rtc.begin();
  // установка времени равному времени компиляции
  if (rtc.lostPower()) {
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
}
void loop() {
  // 2 раза в секунду
  if (millis() - myTimer1 >= 500) {
    myTimer1 = millis(); // сбросить таймер
    toggleLED();
  }
  // 5 раз в секунду
  if (millis() - myTimer2 >= 200) {
    myTimer2 = millis(); // сбросить таймер
    getTemp();
  }
  // каждую секунду
  if (millis() - myTimer3 >= 1000) {
    myTimer3 = millis(); // сбросить таймер
    redrawDisplay();
  }
}

void toggleLED() {
  digitalWrite(13, LEDflag); // вкл/выкл
  LEDflag = !LEDflag; // инвертировать флаг
}

void getTemp() {
  // суммируем температуру в общую переменную
  tempSum += sensors.getTempCByIndex(0);
  sensors.requestTemperatures();
  // счётчик измерений
  tempCounter++;
  if (tempCounter >= 5) { // если больше 5
    tempCounter = 0;  // обнулить
    temp = tempSum / 5; // среднее арифметическое
    tempSum = 0;  // обнулить
  }
}

void redrawDisplay() {
  // ВРЕМЯ
  DateTime now = rtc.now(); // получить время
  lcd.setCursor(0, 0);      // курсор в 0,0
  lcd.print(now.hour());    // часы
  lcd.print(':');
  // первый ноль для красоты
  if (now.minute() < 10) lcd.print(0);
  lcd.print(now.minute());
  lcd.print(':');
  // первый ноль для красоты
  if (now.second() < 10) lcd.print(0);
  lcd.print(now.second());
  
  // TEMP
  lcd.setCursor(11, 0);    // курсор в 11,0
  lcd.print("Temp:");
  lcd.setCursor(11, 1);    // курсор в 11,1
  lcd.print(temp);
  
  // ДАТА  
  lcd.setCursor(0, 1);      // курсор в 0,1
  // первый ноль для красоты
  if (now.day() < 10) lcd.print(0);
  lcd.print(now.day());
  lcd.print('.');
  
  // первый ноль для красоты
  if (now.month() < 10) lcd.print(0);
  lcd.print(now.month());
  lcd.print('.');
  lcd.print(now.year());
}

Что тут можно сделать? В проекте используются:

  • Дисплей LCD1602
  • Датчик температуры ds18b20
  • Микросхема реального времени DS3231

Чтобы упростить переписывание проекта под другие модели компонентов с такой же сутью (другой дисплей, другой датчик температуры, другой источник времени), можно разделить программу на независимые файлы, в которых сделать вывод только нужных для работы программы функций и данных. При использовании другой железки нужно будет переписать только небольшую часть программы, в которой участвует только эта железка, лезть в основной код даже не придётся.

Также сделаем класс для таймера, потому что там он используется в трёх местах.

display.h
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>

void disp_init();
void print_time(byte hour, byte minute, byte second);
void print_temp(float temp);
void print_date(byte day, byte month, int year);
display.cpp
#include "display.h"

// адрес может быть 0x27 или 0x3f
static LiquidCrystal_I2C lcd(0x3f, 16, 2); // Устанавливаем дисплей

void disp_init() {
  lcd.init();
  lcd.backlight();  // Включаем подсветку дисплея
}

void print_time(byte hour, byte minute, byte second) {
  lcd.setCursor(0, 0);
  lcd.print(hour);
  lcd.print(':');
  if (minute < 10) lcd.print(0);  // первый ноль для красоты
  lcd.print(minute);
  lcd.print(':');
  if (second < 10) lcd.print(0);
  lcd.print(second);
}

void print_date(byte day, byte month, int year) {
  lcd.setCursor(0, 1);
  // первый ноль для красоты
  if (day < 10) lcd.print(0);
  lcd.print(day);
  lcd.print('.');

  // первый ноль для красоты
  if (month < 10) lcd.print(0);
  lcd.print(month);
  lcd.print('.');
  lcd.print(year);
}

void print_temp(float temp) {
  lcd.setCursor(11, 0);
  lcd.print("Temp:");
  lcd.setCursor(11, 1);
  lcd.print(temp);
}
sensor.h
#pragma once
#include <Arduino.h>
#include <OneWire.h>
#include <DallasTemperature.h>

void sensor_init(byte pin);
void read_temp();
float get_temp();
sensor.cpp
#include "sensor.h"

static OneWire oneWire;
static DallasTemperature sensors(&oneWire);
static float tempSum = 0, temp;
static byte tempCounter;

void sensor_init(byte pin) {
  oneWire.begin(pin);
  sensors.begin();
  sensors.setWaitForConversion(false);
}

void read_temp() {
  tempSum += sensors.getTempCByIndex(0);
  sensors.requestTemperatures();
  // счётчик измерений
  tempCounter++;
  if (tempCounter >= 5) { // если больше 5
    tempCounter = 0;  // обнулить
    temp = tempSum / 5; // среднее арифметическое
    tempSum = 0;  // обнулить
  }
}

float get_temp() {
  return temp;
}
time.h
#pragma once
#include <Arduino.h>
#include <Wire.h>
#include <RTClib.h>

void time_init();
void read_time();
byte time_hour();
byte time_minute();
byte time_second();
byte time_day();
byte time_month();
int time_year();
time.cpp
#include "time.h"

static RTC_DS3231 rtc;
static DateTime now;

void time_init() {
  rtc.begin();
  // установка времени равному времени компиляции
  if (rtc.lostPower()) {
    rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
  }
}

void read_time() {
  now = rtc.now();
}

byte time_hour() {
  return now.hour();
}
byte time_minute() {
  return now.minute();
}
byte time_second() {
  return now.second();
}
byte time_day() {
  return now.day();
}
byte time_month() {
  return now.month();
}
int time_year() {
  return now.year();
}
timer.h
#pragma once
#include <Arduino.h>

class Timer {
  public:
    Timer(uint16_t nprd = 0) {
      setPeriod(nprd);
    }

    void setPeriod(uint16_t nprd) {
      prd = nprd;
    }

    uint16_t getPeriod() {
      return prd;
    }

    bool ready() {
      if (millis() - tmr >= prd) {
        tmr = millis();
        return true;
      }
      return false;
    }
  private:
    uint32_t tmr = 0;
    uint16_t prd = 0;
};

Располагаем файлы рядом с основным скетчем. Финальный код:

Скетч
#define SENSOR_PIN 2
#define LED_PIN 13

#include "display.h"
#include "sensor.h"
#include "time.h"
#include "timer.h"

Timer tmr1(500);
Timer tmr2(200);
Timer tmr3(1000);

void setup() {
  pinMode(13, 1);
  disp_init();
  sensor_init(SENSOR_PIN);
  time_init();
}

void loop() {
  if (tmr1.ready()) toggleLED();
  if (tmr2.ready()) read_temp();
  if (tmr3.ready()) redrawDisplay();
}

void toggleLED() {
  static bool LEDflag;
  digitalWrite(LED_PIN, LEDflag);
  LEDflag = !LEDflag;
}

void redrawDisplay() {
  read_time();
  print_time(time_hour(), time_minute(), time_second());
  print_temp(get_temp());
  print_date(time_day(), time_month(), time_year());
}

Проект занимает чуть больше памяти (на несколько байт), но теперь его проще переделать под другое железо, не трогая основной “скелет” программы в основном скетче.

Полезные страницы


  • Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
  • Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
  • Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
  • Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
  • Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
  • Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
  • Поддержать автора за работу над уроками
  • Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту (alex@alexgyver.ru)
5/5 - (7 голосов)
Назад Разбиваем на вкладки
Подписаться
Уведомить о
guest
4 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии