Как стать автором
Обновить

ESP32 + Arduino Core + FreeRTOS + Blynk = дом с зачатками разума

Время на прочтение 14 мин
Количество просмотров 61K

Цели проекта


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


Прислушавшись к себе, я понял, что это жаба, которой не нравится, что пока меня нет дома (12-16 часов в сутки), отопление работает. А могло бы и не работатать, включаться только перед приходом, благо каркасник обладает небольшой инерционностью и позволяет быстро поднять температуру. Та же ситуация когда куда то надолго уехать из дома. Ну и вообще, бегать, крутить ручку котла при изменениях температуры на улице — как то не кошерно.


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


Собственно о требованиях:


  • управление температурой в доме по уставке
  • управление температурой теплоносителя в зависимости от температуры на улице или вручную
  • временные зоны с разными уставками, днём холоднее, ночью горячее
  • автоматический режим, с авто переходом день-ночь
  • ручной режим, без автопереходов, для выходных
  • режим без автоматики, где можно вручную задать любую температуру теплоносителя и включить/выключить котёл
  • управление отоплением локально, с кнопок и экрана и через сайт/мобильное приложение

Это было в начале, а потом меня понесло и добавились:


  • управление уличным фонарём (LED прожектором)
  • сигнализация на основе датчика движения, сирены и уличного фонаря
  • учёт энергии потреблённой котлом за день/месяц/год + за каждый месяц года
  • режим сигнализация только медленным миганием фонаря
  • режим сигнализации быстрым миганием фонаря и короткими гудками сирены
  • режим сигнализации быстрым миганием фонаря и постоянным воем сирены

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


Что было в начале


Изначально проект был реализован на дикой связке Arduino Nano + ESP8266, но ESP не как шилд, а как отдельное устройство. Почему так? Да потому что это всё у меня уже было, а денег не было от слова совсем, поэтому покупать новое железо не хотелось принципиально. Почему ESP не как шилд? Сейчас уже даже и не вспомню.


Arduino рулила всеми процессами, потому что имела необходимое количество GPIO, а ESP отправляла все данные на сервер Blynk, потому что умела в интернет и не имела достаточно GPIO. Соединялись они собой через UART, и пересылали JSON с данными друг другу. Схема необычная, но проработала год почти без нареканий. Кому интересно могут посмотреть кодок.


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


Железо


Итак, прошёл год, финансы позволили купить ESP32 devkit v1, которая имеет достаточно GPIO, умеет в интернет и вообще супер контроллер. Кроме шуток, по итогам работы она мне сильно понравилась.


Перечень железа:


  • ESP32 devkit v1 noname China
  • 3 датчика температуры DS18B20, температуры внутри дома, снаружи и температуры теплоносителя в трубах
  • блок из 4 реле
  • pir датчик HC-SR501

Схему рисовать не буду, думаю всё будет понятно из макросов с названиями пинов.


Почему FreeRTOS и Arduino Core


На Arduino написаны куча библиотек, в частности тот же самый Blynk, поэтому от Arduino Core особо то и не уйдёшь.


FreeRTOS потому что позволяет организовать работу маленькой железки похожей на работу полноценного промышленного контроллера. Каждую задачу можно вынести в свою таску, останавливать её, запускать, создавать когда надо, удалять — всё это намного гибче, чем написание длинной портянки Arduino кода, когда в итоге всё выполняется по очереди в функции loop.


При использовании FreeRTOS каждый таск выполнится в строго заданное время, лишь бы хватило мощности процессора. Напротив, в Arduino весь код выполняется в одной функции, в одном потоке, если что то притормозило — остальные задачи выполнятся с задержкой. Особенно это заметно при управлении быстрыми процессами, в этом проекте это мигание фонарём и гудки сиреной, будут рассмотрены ниже.


Про логику


Про FreeRTOS таски


→ Ссылка на весь кодок проекта


Итак, при использовании FreeRTOS функция setup играет роль функции main, точки входа в приложение, в ней создаются FreeRTOS tasks (далее таски), функцию loop можно не использовать вообще.


Рассмотрим небольшой таск по вычислению температуры теплоносителя:


void calculate_water_temp(void *pvParameters)
{
   while (true)
        {
        if (heating_mode == 3) {}
        else
        {
            if (temp_outside > -20)
                max_water_temp = 60;
            if (temp_outside <= -20 && temp_outside > -25)
                max_water_temp = 65;
            if (temp_outside <= -25 && temp_outside > -30)
                max_water_temp = 70;
            if (temp_outside <= -30)
                max_water_temp = 85;
        }
        vTaskDelay(1000 / portTICK_RATE_MS);
    }
}

Объявляется как функция, которая должна принимать в себя _void pvParameters, внутри функции организуется бесконечный цикл, я использовал while (true).


Производится нехитрый расчёт температуры (если позволяет режим работы) и затем таск усыпляется vTaskDelay(1000 / portTICK_RATE_MS) на 1 секунду. В этом режиме он не потребляет процессорное время, переменные, с которыми работал таск, другими словами контекст, сохраняется в стек, что бы достать их оттуда, когда придёт время.


Далее таск необходимо создать в setup. Делается это вызовом метода xTaskCreate:


xTaskCreate(calculate_water_temp, "calculate_water_temp", 2048, NULL, 1, NULL);


Много аргументов, но для нас являются значимыми calculate_water_temp — имя функции, содержащей код таска и 2048 — размер стека в байтах.


Размер стека изначально ставил всем по 1024 байт, далее вычислял нужный методом тыка, если контроллер начинал падать с переполнением стека (что видно из вывод в uart), я просто увеличивал размер стека в 2 раза, если не помогало — ещё в 2 раза и так пока не заработает. Конечно это не слишком экономит память, но ESP32 имеет её достаточно, в моём случае можно было не заморачиваться с этим.


Также можно задать хендл для таска — ручка, с помощью которой таском можно управлять после создания, например — удалить. Это последний NULL в примере. Создаётся хендл так:


TaskHandle_t slow_blink_handle;


Далее, при создании таска в параметр xTaskCreate передаётся указатель на хендл:


xTaskCreate(outside_lamp_blinks, "outside_lamp_blynk", 10000, (void *)1000, 1, &slow_blink_handle);


И если мы хотим удалить таск, делаем так:


vTaskDelete(slow_blink_handle);


Как это используется можно можно посмотреть в коде таски panic_control.


Про FreeRTOS мьютексы


Мьютекс используется для исключения конфликтов между тасками при доступе к ресурсам типа uart, wifi и т.п. В моём случае понадобились мьютексы для wifi и доступа к флеш памяти.


Создаём ссылку на мьютекс:


SemaphoreHandle_t wifi_mutex;


В setup создаём мьютекс:


wifi_mutex = xSemaphoreCreateMutex();


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


xSemaphoreTake(wifi_mutex, portMAX_DELAY);


portMAX_DELAY — ждать бесконечно пока ресурс и мьютекс не освободятся другими тасками, всё это время таска будет спать.


После работы с ресурсом отдаём мьютекс что бы им могли пользоваться другие:


xSemaphoreGive(wifi_mutex);


Подробнее код можно поглядеть в таске send_data_to_blynk.


На практике неиспользование мьютексов не было заметно при работе контроллера, но при JTAG дебаге постоянно сыпались ошибки, которые исчезли после использования мьютексов.


Краткое описание тасок


get_temps — получение температуры с датчиков, раз в 30 сек, чаще не надо.
get_time_task — получение времени, с NTP серверов. Раньше время получалось из RTC модуля DS3231, но он стал глючить после года работы, поэтому я решил избавиться от него вовсе. Я решил что для меня это не несёт особых последствий, в основном время влияет на переключение временной зоны обогрева — день или ночь. Если интернет пропадёт во время работы контроллера, время просто застынет, временная зона просто останется прежней. Если контроллер выключиться и после включения не будет интернета, то время постоянно будет 0:00:00 — режим обогрева ночью.
calculate_water_temp — рассматривался выше.
detect_pir_move — получение сигнала движения с датчика HC-SR501. Датчик формирует логическую единицу +3.3В при обнаружении движения, что и обнаруживается с помощью digitalRead, кстати, пин для обнаружения для данного датчика должен быть подтянут к GND — pinMode(pir_pin, INPUT_PULLDOWN);
heating_control — переключение режимов отопления.
out_lamp_control — управление уличным фонарём.
panic_control — управление сиреной и прожектором при обнаружении движения. Для создания эффекта гудков сирены и мигания фонарём используются отдельные таски, outside_lamp_blinks и siren_beeps. При использовании FreeRTOS мигание и гудки работают просто идеально, ровно с теми интервалами что задано, на их работу не влияют другие таски, т.к. они живут в отдельных потоках. FreeRTOS гарантирует что код в таске выполнится в заданное время. При реализации этих функций в loop всё работало не так гладко, т.к. влияло выполнение другого кода.
guard_control — управление режимами охраны.
send_data_to_blynk — отправка данных в приложение Blynk.
run_blynk — таск для запуска Blynk.run() как того требует методичка по использованию Blynk. Насколько я понял это требуется для получения данных из приложения в контроллер. Вообще Blynk.run()должно быть в loop, но я принципиально не хотел туда ничего класть и сделал отдельным таском.
write_setting_to_pref — запись уставок и режимов работы для того что бы подхватить их после перезагрузки. О pref будет рассказано ниже.
count_heated_hours — подсчёт времени работы котла. Я сделал просто, если котёл включен в момента запуска таска(раз в 30 секунд), во флеш памяти значение по нужному ключу инкрементируется на единицу.
send_heated_hours_to_app — в этом таске значения извлекаются и умножаются на 0.00833 (1/120 часа), полученные часы работы котла отсылаются в приложение Blynk.
feed_watchdog — покормить Watchdog. Пришлось написать watchdog, т.к. раз в несколько дней контроллер мог зависнуть. С чем это связано — непонятно, может быть какие то помехи по питанию, но использование watchdog решает эту проблему. Таймер срабатывания watchdog 10 секунд, ничего страшного если контроллер не будет доступен в течение 10 секунд.
heart_beat — таск с пульсом. Когда я прохожу мимо контроллера, мне хочется знать что он работает нормально. Т.к. на моей плате нет встроенного светодиода, пришлось использовать светодиод UART — установить Serial.begin(9600); и писать в UART длинную строку. Работает неплохо.


ESP32 NVS wear leveling


Следующие описания довольно грубые, буквально на пальцах, только что бы передать суть вопроса. Более подробно


Для хранения данных в энергонезависимой памяти в Arduino используется EEPROM память. Это память небольшого объёма, в которой можно записывать и стирать каждый байт отдельно, в то время как flash память стирается только секторами.


В ESP32 нет EEPROM, но зато есть как правило 4 Mb flash памяти, в которой можно создавать разделы — для прошивки контроллера или для хранения пользовательских данных. Разделы для пользовательских данных бывают нескольких типов — NVS, FATFS, SPIFFS. Выбирать следует исходя из предполагаемого к записи типа данных.


Т.к. все записываемые данные в этом проекте типа Int, я выбрал NVS — Non-Volitile Storage. Этот тип разделов хорошо подходит для сохранения небольших по размеру, часто перезаписываемых данных. Что бы понять почему так, следует немного углубиться в организацию NVS.


Как и EEPROM, так и FLASH имеют ограничение на перезапись данных, байт в EEPROM может быть перезаписан от 100000 до 1 000 000 раз, сектор FLASH — так же. Если записывать данные раз в секунду, то получим 60сек х 60 мин х 24ч = 86 400 раз/сутки. То есть в таком режиме байт продержится 11 дней, что как бы немного. После чего байт станет недоступен для записи и чтения.


Для сглаживания этой проблемы, функции update() put() библиотеки EEPROM Arduino записывают данные только при изменении. То есть можно писать каждую секунду какие то уставки и коды режимов, которые изменяются довольно редко.


В NVS используется другой способ управления степенью износа (wear leveling). Как упоминалось выше, данные в сектор flash можно писать частями, но стереть можно только весь сектор. Поэтому запись данных в NVS осуществляется в своего рода журнал, этот журнал разделяется на страницы, которая помещается в одном секторе flash памяти. Запись данных осуществляется парами ключ: значение. По сути, это даже проще, чем с EEPROM, т.к. работать со значащим названием проще, чем с адресом в памяти. Upd: длина ключа не более 15 символов!


Если сначала записать значение 1 по ключу somekey, а затем записать значение 2 по тому же ключу, то первое значение не удалится, только пометится как удалённое (Erased), а в журнал добавиться новая запись:



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


У страницы есть статус, Empty — пустая, без записей, Active — в неё в данный момент пишутся данные, Full — заполнена, писать в неё нельзя. Как только на странице кончается место, она из
Active переходит в Full, а следующая Empty страница становится Active.



Насколько я понял из документации на сайте Espressif и различных форумов, очищение страниц начинается когда свободные страницы подходят к концу. Если быть точнее, то согласно вот этому, стирание произойдёт когда останется только 1 свободная страница.


Если страницу нужно очистить, то актуальные записи (Non-Erased), перемещаются на другую страницу, и страница затирается.


Таким образом, операция запись-стирание для каждой конкретной страницы происходит довольно таки редко, чем больше страниц — тем реже. Исходя из этого, я увеличил размер NVS раздела до 1 MB, при моих темпах записи этого хватит лет на 170, чего в общем то достаточно. Об изменении размера NVS раздела будет далее.


Для удобной работы с NVS в ESP32 для Arduino Core написана удобная библиотечка Preferences, как с ней работать написано здесь.


Немного о VisualGDB


Как только я начал работать с Arduino IDE, меня сразу удивил убогий функционал по сравнению с Visual Studio. Говорят, что VS тоже не фонтан, хотя меня устраивает, но писать что то более 50 строк в Arduino IDE мучительно больно и мучительно долго. Таким образом встал вопрос о выборе IDE для разработки. Т.к. я знаком с VS, я остановился на VisualGDB.


После Arduino IDE, разработка для ESP32 становится просто раем. Чего только стоят переход к определению, поиск вызовов в проекте и возможность переименовать переменную.


Изменение таблицы разделов ESP32 при работе с VisualGDB


Как говорилось выше, таблицу разделом ESP32 можно менять, рассмотрим как это можно сделать.
Таблица редактируется в виде csv файла, по умолчанию VisualGDB пишет следующую таблицу:


Name,   Type, SubType, Offset,  Size, Flags  
nvs,      data, nvs,     0x9000,  0x5000,  
otadata,  data, ota,     0xe000,  0x2000,  
app0,     app,  ota_0,   0x10000, 0x140000,  
app1,     app,  ota_1,   0x150000,0x140000,  
spiffs,   data, spiffs,  0x290000,0x170000,

Здесь мы видим раздел под NVS, два раздела под приложения и ещё несколько разделов. Из нюансов можно отметить, что app0(ваше приложение), всегда должно быть записано по смещению 0x10000, начиная от нулевого адреса, иначе загрузчик его не обнаружит. Также, смещения должны быть подобраны так, что бы разделы не "налезали" друг на друга. Сама таблица разделов пишется по смещению 0x8000. Как видно, размер NVS в данном случае 0x5000 — 20KB, что не очень много.


Я модифицировал таблицу разделов следующим образом:


Name,   Type, SubType, Offset,  Size, Flags  
app0,     app,  ota_0,   0x10000, 0x140000,  
nvs,      data, nvs,     ,  1M,  
otadata,  data, ota,     ,  0x2000,  
spiffs,   data, spiffs,  ,  0x170000,

Не забудьте добавить решётку перед Name, если будете пользоваться этой таблицей, это нужно что бы эта строка считалась комментарием.


Как видно, размер NVS увеличен до 1 MB. Если не указывать смещения, то раздел будет начинаться сразу за предыдущим, таким образом, достаточно указать смещение только для app0. CSV файлы можно редактировать в блокноте как txt и потом у сохранённого файла поменять разрешение на csv.


Далее, таблицу разделов надо преобразовать в бинарник, т.к. в контроллер она попадает именно в таком виде. Для этого запускаем конвертер:
c:\Users\userName\Documents\ArduinoData\packages\esp32\hardware\esp32\1.0.3\tools\gen_esp32part.exe part_table_name.csv part_table_name.bin. Первый параметр — ваш CSV, второй параметр — выходной бинарник.


Полученный бинарник следует положить в c:\Users\userName\Documents\ArduinoData\packages\esp32\hardware\esp32\1.0.3\tools\partitions\part_table_name.csv, после чего необходимо указать что бы при сборке решения брался именно он, а не таблица разделов по умолчанию. Сделать это можно, прописав название вашей таблицы в файле c:\Users\userName\Documents\ArduinoData\packages\esp32\hardware\esp32\1.0.3\boards.txt. В моём случае это esp32doit-devkit-v1.build.partitions=part_table_name
После этих манипуляций, VisualGDB при сборке приложения будет брать именно вашу таблицу разделов и класть её в
~project_folder_path\Output\board_name\Debug\project_name.ino.partitions.bin, откуда она уже зальётся в плату.


JTAG отладчик CJMC-FT232H


Насколько я знаю, это самый дешёвый отладчик, которым можно работать с ESP32, мне обошёлся примерно в 600р, на Aliexpress их полно.



При подключении отладчика Windows устанавливает на него неподходящие драйвера, которые необходимо поменять с помощью программы Zadig, там всё просто, не буду описывать.


Подключается к ESP32 devkit-v1 по следующей схеме:
FT232H — ESP32
AD0 — GPIO13
AD1 — GPIO12
AD2 — GPIO15
AD3 — GPIO14
После чего в Project -> VisualGDB Project Properties необходимо сделать следующие настройки:



После чего нажать на Test. Иногда бывает что соединение устанавливается не с первого раза, процесс как бы подвисает, тогда надо прерваться и повторить Test. Если всё в порядке, процесс тестирования соединения занимает около 5 секунд.


Я обычно собирал проект и заливал его через USB самой ESP32(не через отладчик), после чего начинал отладку по Debug -> Attach to Running Embedded Firmware. В коде можно ставить точки останова, смотреть значения переменных в момент останова и в окне Debug -> Windows -> Threads можно смотреть в каком FreeRTOS таске остановился код, что бывает полезно, если во время отладки произошла ошибка. Этих функций отладчика мне хватило для комфортной работы.
Когда я начал работать с NVS, отладка стала постоянно прерываться непонятными ошибками. Насколько я понял, так происходит потому что отладчику необходимо создавать что то типа дампа в разделе NVS по умолчанию, но в это время NVS уже используется контроллером. Конечно, это можно было бы обойти, создав 2 раздела NVS, один с дефолтным именем — для отладки, а другой для своих нужд. Но там уже не было ничего сложного, в добавляемом коде, он заработал с первого раза, так что я не стал это проверять.


Глюки ESP32


Как всякое устройство с Aliexpress, моя ESP32 плата имела свой, нигде не описанный глюк. Когда она пришла, я запитывал от платы кое какую периферию, работавшую по I2C, но по прошествии какого то времени, плата стала перезагружаться, если к ноге +5V было прицеплено любое потребляющее оборудование или даже просто кондесатор. Почему так — совершенно непонятно.


Сейчас я запитываю плату от китайской зярядки 0.7A, датчики ds18b20 от ноги 3.3V платы, а реле и датчик движения — от другой зарядки на 2А. GND нога платы разумеется соединена с GND контактами остального железа. Дёшево и сердито — наш вариант.


О результатах проекта


Я получил возможность гибко управлять отоплением в доме, экономя заработанные потом и кровью деньги. На данный момент если отопление поддерживает весь день 23 градуса при -5 — -7 снаружи, это где то 11 часов работа котла. Если днём поддерживать 20 градусов и греть до 23 только вечером, то это уже 9 часов работы котла. Мощность котла 6КВт, с текущей ценой киловатта 2,2р, это около 26,4р в сутки. Продолжительность отопительного сезона в наших краях 200 суток, средняя температура в отопительный сезон как раз около -5 градусов. Таким образом получается около 5000р экономии за отопительный сезон.


Стоимость оборудования не превышает 2000р, то есть затраты отобьются за несколько месяцев, не говоря уже о том, готовая система подобной автоматики стоила бы не меньше 20000р. Другое дело, что я потратил около недели чистого рабочего времени на написание прошивки и отладку, но в ходе работы я, например, наконец то понял что такое указатели в C++ и получил много другого опыта (например опыт многочасового дебага непонятных глюков). А опыт, как известно, сложно переоценить.


Скриншоты мобильного приложения Blynk:





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


Если моя писанина кому то поможет, буду искренне рад. Буду рад любым замечаниям и предложениям.


UPD1:


У меня почему то не работала функция esp_restart(), наверняка я что то не так подключил к плате (а может с платой что то не то). То есть иногда она срабатывала, а иногда нет. У меня не получилось понять что именно я сделал не так и я реализовал reboot по другому. Но плате ESP32 devkit v1 есть пин EN, который управляет источником 3,3 В, от которого собственно и питается ESP32.Этот пин подтянут к +5 В, вроде бы и если подать на него 0, то источник 3,3В перестанет запитывать ESP32, то есть получится самый настоящий хард резет.
Так вот, я просто добавил вместо esp_restart() pinMode(reset_pin, OUTPUT), reset_pin в данном случае это GPIO2. Смысл в том, что по умолчанию все пины в INPUT mode, то есть в высокоимпедансном состоянии, а если перевести пин в UOTPUT, то он по умолчанию имеет состояние LOW. Таким образом на EN подаётся LOW и плата перезагружается.
Ну и конечно надо соединить GPIO2 и пин EN перемычкой.
Тестил на проде пару дней — работает отлично :)


UPD2:


Оказалось, что Blynk.run() на ESP32 не умеет поднимать WiFi после его исчезновения. Поправил это недоразумение в функции run_blynk.
Так же, оказалось, что код не пойдёт дальше метода Blynk.begin(), если нет WiFi соединения. Для поморгать лампочкой через телефон это пойдёт, но для более менее серьёзного проекта так не годится. Поправил методы begin и connectWiFi в файле BlynkSimpleEsp32.h с бесконечного ожидания на ожидание в течении нескольких секунд. То есть, плата теперь может стартовать без WiFi и выполнять свои задачи, просто без доступа в инет, а когда WiFi появится, соединится с серверами Blynk.
Весь кодок запушил в репзиторий на GitHub.


Тестил:


  1. Загрузка платы без WiFi — загружается, работает.
  2. Загрузка платы без WiFi, затем WiFi появляется — загружается, работает, после появления WiFi сети подключается к ней и к Blynk.
  3. Загрузка платы с WiFi, но без выхода в инет — загружается, работает.
  4. Загрузка платы с WiFi, но без выхода в инет, затем инет появляется — загружается, работает, после появления инета подключается к Blynk.
  5. Загрузка платы с WiFi + инет, затем WiFi сеть исчезает и появляется — после восстановления WiFi сети подключается к ней и к серверам Blynk.
  6. Загрузка платы с WiFi + инет, затем инет исчезает и появляется — после восстановления инета подключается к серверам Blynk.
    Всё ок :)

Теги:
Хабы:
+35
Комментарии 65
Комментарии Комментарии 65

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн