- О проекте
- Архитектура
- Функционал
- Технические особенности
- Нам понадобится
- Настройка и запуск
- Важно о запуске
- Статус проекта
- Примечание
-
Проект разрабатывался в течение года как реальный инструмент для старосты университетской группы: удобный, быстрый и всегда под рукой - прямо в Telegram. Идея на первый взгляд простая, но неожиданно глубокая в реализации: ESP32 подключается к WiFi, общается с ботом в Telegram и пишет данные в Google Sheets. Всё хранилище - обычная таблица, никаких серверов и баз данных.
-
К моменту вынужденной остановки проект был в версии v0.7 и находился на завершающей стадии: дорабатывалась система подсчёта пропусков, доводился до ума пользовательский опыт, внедрялась новая, более нативная структура таблицы Google Sheet. До заморозки система несколько месяцев проработала в реальном учебном процессе.
-
Причина заморозки: жёсткая блокировка Telegram в России, в том числе в WiFi-сетях, сделала проект полностью нерабочим. Весь функционал был принципиально завязан на Telegram Bot API, прямых аналогов с таким же удобством не нашлось, а переписывать всё с нуля в условиях нехватки времени оказалось невозможным. Надеяться на разблокировку особо не приходится, поэтому развитие проекта, скорее всего, окончено.
┌─────────────────────────────────────────┐
│ ESP32 │
│ │
┌─────────────┐ │ setup() │
│ Telegram │◄─────►│ WiFi_Connect() │
│ Bot API │ │ ArduinoOTA.begin() │
│ (FastBot) │ │ FFat.begin() │
└─────────────┘ │ week/settings/chat file.read() │
▲ │ menu.start_page() │
│ │ list.begin() -- парсинг расписания │
│ │ │
┌─────────────┐ │ loop() (кооперативная многозадачность) │
│ Google │◄─────►│ bot.tick() │
│ Sheets │ │ *.tick() x3 (FileData) │
│ API v4 │ │ timer.tick() │
└─────────────┘ │ serviceMess.tick() │
│ ArduinoOTA.handle() │
│ [смена дня -> checkTableWeek()] │
│ [таймер -> проверка кучи] │
│ │
│ Входящее сообщение -> newMsg() │
│ ├── Админ -> menu / briefInput │
│ ├── Группа -> команды группы │
│ └── Неизвестный -> отказ + лог │
│ │
│ ┌──────────┐ ┌──────────────────────┐│
│ │ FFat │ │ Куча (heap) ││
│ │ /week │ │ WeekInfo[2] ││
│ │ /data │ │ расписание текущей ││
│ │ /settings│ │ и смежной недели ││
│ └──────────┘ └──────────────────────┘│
└─────────────────────────────────────────┘
-
Основной режим. Через инлайн-меню Telegram-бота старосте предлагается выбрать фамилию студента из списка группы, затем дату (текущая по умолчанию, или произвольная в прошлом вплоть до начала семестра), а затем тип отметки для каждой пары выбранного дня: УП (уважительная причина), неУП (неуважительная) или присутствие.
-
Учитывается разделение на две подгруппы: в меню отображаются только те пары, которые реально есть у подгруппы этого студента. Доступны быстрые действия - «Все УП», «Все неУП», «Нет пропусков» - для мгновенного выставления статуса на все пары дня.
-
Текущий статус ячеек считывается из таблицы перед показом меню, так что редактирование уже выставленных отметок - без лишних усилий.
Дерево меню:
[Главная]
├── Редактировать
│ └── [Фамилия]
│ ├── Дата: DD.MM.YY (по умолчанию - сегодня)
│ │ └── [Месяц] -> [День в сетке календаря]
│ ├── (1) УП / неУП / Присутствие
│ ├── (2) УП / неУП / Присутствие
│ ├── ...
│ ├── Все УП | Все неУП | Нет пропусков
│ └── Поставить -> запись в Google Sheets
│
├── Подсчитать
│ └── [Фамилия]
│ ├── Общее УП ──────────────────────────────┐
│ ├── Общее неУП ────────────────────────────┤-> выбор диапазона недель -> результат
│ └── По предметам (неУП) │
│ └── [Предмет] ─────────────────────────┘
│
├── Статистика
│ └── Общее неУП -> диапазон недель -> таблица по всем студентам
│
└── Настройки
└── Сроки промежуточной аттестации -> выбор диапазона недель
- Пожалуй, самая нетривиальная фича в плане реализации. Позволяет отмечать пропуски сразу списку студентов обычным сообщением без навигации по меню для каждого:
1 пара 02.03
Иванов
Петрова
Сидоров уп
- Система разбирает сообщение: находит нужный день и пару в расписании, вычитывает уже существующие отметки из таблицы, обновляет нужные ячейки.
- Проверяет валидность введенных данных для каждого элемента ввода - вплоть до присутствия введенной пары для конкретного студента в конкретный день в неделю нужной четности.
Поддерживаемые форматы условия:
| Формат | Пример | Описание |
|---|---|---|
| Без условия | Иванов |
Дата и пара определяются автоматически по текущему времени |
| Ключевое слово | 1 пара сегодня / вчера / позавчера |
Дата вычисляется от текущего дня |
| Явная дата | 1 пара 02.03 |
Конкретная дата; если день введен без месяца - принимается текущий месяц |
| Несколько пар | 1,3 пара 15.04 |
Несколько пар за один ввод |
Поддерживаемые суффиксы к фамилии:
| Суффикс | Результат (тип отметки в таблице) |
|---|---|
| (без суффикса) | неУП (если в таблице ещё не стоит УП. Если стоит - считается приоритетным и не изменяется) |
уп / УП |
УП |
неуп / неУП |
неУП |
тут / ТУТ |
Присутствие |
Специальные режимы:
- Режим присутствия - строка
Пперед списком фамилий инвертирует логику: указанные люди присутствовали, а всем остальным ставится пропуск - Нечёткое распознавание фамилий - допускается до N опечаток (настраивается через
SURNAME_ERRORS_NUM), система сама определит, кого имел в виду пользователь, и при желании уведомит об этом
Определение текущей пары (ввод без условия):
- Система сравнивает
realTimeс массивомlessons[](время начала и конца каждой пары) с допуском±MINUTES_OFFSETминут с обеих сторон. Если текущее время не попадает ни в одну пару - запрос отклоняется с сообщением об ошибке.
- Позволяет подсчитать пропуски конкретного студента за произвольный диапазон недель.
Три режима:
| Режим | Что считает | Формула |
|---|---|---|
| Общее УП | Пропуски по уважительной причине | COUNTIF(FILTER(...); "R") |
| Общее неУП | Неуважительные пропуски | COUNTIF(FILTER(...); "D") |
| По предмету | неУП по конкретной дисциплине | COUNTIFS(FILTER(...); "предмет"; FILTER(...); "D") |
- Под капотом - формулы Google Sheets (
COUNTIF+FILTER), которые записываются в ячейку таблицы через Sheets API, вычисляются прямо на стороне Google и считываются обратно. Никакой локальной обработки сотен ячеек - всё на стороне облака.
Пример итоговых формул:
// Режим "Общее неУП" за диапазон недель:
=COUNTIF(FILTER(C581:U617;MOD(ROW(C581:U617)-588;23)=0);"D")
// Режим "По предмету":
=COUNTIFS(FILTER(C244:U284;MOD(ROW(C244:U284)-244;24)=0);"Физ практикум (лб)";
FILTER(C244:U284;MOD(ROW(C244:U284)-244-2;24)=0);"D")
- Групповой режим: запускает подсчёт последовательно для каждого студента из списка и возвращает итоговую таблицу в Telegram в форматированном виде (моноширинный шрифт через markdown):
Фио: Нки:
Иванов --------- 3
Петрова --------- 1
Сидоров --------- 7
- При каждом включении ESP32 сравнивает дату последней записанной в таблице недели с реальной датой. Если прошло несколько недель - система автоматически достраивает все пропущенные через batchUpdate API: копирует структуру предыдущей недели той же чётности (нечётная/чётная - расписание у них может различаться), очищает ячейки пропусков и проставляет актуальные даты в заголовки дней. Всё это без единого касания таблицы вручную.
Один цикл достройки одной недели - три запроса batchUpdate:
copyPaste- скопировать блок строк предыдущей недели той же чётности в позицию новой недели (копируется всё: форматирование, структура, номера пар)repeatCell- очистить только ячейки с пропусками студентов в скопированном блокеupdateCells- записать актуальные даты в заголовки учебных дней (формат:Вторник, 22.02.26)
- Перед добавлением нового учебного дня в заголовок вычисляется его дата через
sumDate()с учётом пропуска выходных дней посреди недели.
Команды в чате с администраторами и в группе:
| Команда | Доступ | Описание |
|---|---|---|
/start |
Администраторы | Приветствие; остальным администраторам - уведомление о входе |
/res |
Администраторы | Немедленная перезагрузка ESP32 |
/comms |
Все | Список доступных команд |
/список |
Все | Общий список группы с нумерацией |
/список1 |
Все | Список первой подгруппы |
/список2 |
Все | Список второй подгруппы |
Кинуть кубик |
Все | Бросает виртуальный кубик (1-6) |
- Контроль доступа: сообщения от неизвестных пользователей (не в
Admins[]и не вGroups[]) обрабатываются отдельно - в первый раз отправляется вежливое перенаправление к старосте,chatIDзапоминается и все повторные сообщения от этого пользователя игнорируются без ответа. Об обращении всегда уведомляетсяerror_chat.
-
malloc/realloc/freeдля расписания. Расписание двух недель хранится в куче. В конструктореWeekInfoпод каждый день выделяется буфер наMAX_LESSONS_IN_DAYэлементовLessInfo. После парсинга расписания из Sheets для каждого дня выполняется shrink-to-fit черезrealloc- буфер сжимается до фактического числа пар. Дни без пар -free()+nullptr. -
Своп указателей при смене чётности. Объекты
WeekInfoхранятся в массивеweek_object[2], аweek[2]- это массив указателей на них. Когда нечётная неделя сменяется чётной (или наоборот), вместо копирования данных просто меняются местами два указателя -week[0]иweek[1]. Расписание при этом остаётся в памяти нетронутым. -
MemoryControl. Класс для мониторинга свободной RAM: в конструкторе фиксирует текущий объём кучи (_start_heap,_keep_heap),check()проверяет не упала ли куча ниже порогаMIN_FREE_HEAP(50 КБ) и обновляет_keep_heapдо минимума,getDiff()возвращает, сколько байт было занято с момента создания объекта,resetKeep()обновляет базовую точку. Используется на протяжении всех "тяжёлых" операций с прерыванием при нехватке. Периодически вызывается и вloop()для фонового мониторинга. -
PROGMEM. Строковые константы, обращение к которым идёт только на чтение - названия месяцев (
months[]), дней недели (DaysOfWeek[]), количество дней в каждом месяце (day_month[]) - хранятся в PROGMEM (flash), а не в RAM. Критично для платформы с ограниченным heap. -
DeleteTimer. Класс для отложенного удаления сообщений через Telegram Bot API. Хранит динамический массив структурtimer_data(message_id, chatID, период, стартовый millis).tick()опрашивается вloop()каждые 200 мс, при срабатывании помечает элементmessage_id = -1.MyRealloc()пересобирает массив, выбрасывая помеченные элементы - без лишнихfree/malloc, только одинcallocна новый блок.
-
Три вида запросов:
GSheet.values.get()- чтение значений диапазонаGSheet.values.update()- запись значений в диапазон (выставление отметок, запись формулы)GSheet.batchUpdate()- структурные операции (copyPaste, repeatCell, updateCells) - только при достройке недель
-
Логика повторных попыток. Все API-вызовы обёрнуты в
while (!api_call() && tries < TryNum) tries++. При исчерпании попыток - сообщение об ошибке вerror_chat. ЗначенияGetTryNumиSetTryNumзадаются вsettings.h. -
Pre-refresh токена.
GSheet.setPrerefreshSeconds(10 * 60)- токен сервисного аккаунта обновляется за 10 минут до истечения, чтобы не получить ошибку авторизации в середине операции. -
Кэширование ширины таблицы. После первого чтения новой таблицы реальная ширина блока недели (количество столбцов) записывается в
settings.table_width[2]и сохраняется в FFat. Это гарантирует попадание в нужный диапазон за один запрос при последующих чтениях, без дочитывания лишних данных. -
Обработка пустых ячеек. Google Sheets API v4 не включает в JSON-ответ ячейки с пустым значением - они просто отсутствуют в поле
values. Код явно достраивает недостающие пути черезFirebaseJson::set(), чтобы не получитьinvalidPathпри последующем обращении к "несуществующей" ячейке диапазона. -
charOffset(col, n). Функция для вычисления "адреса" конкретного столбца, позволяет сдвигать буквенный адрес столбцаcolнаnпозиций вправо. Возвращает букву/-ы столбца, сдвинутую/-ые наnпозиций от заданной. Поддерживает однобуквенные (A-Z) и двухбуквенные (AA-ZZ) адреса: при переполнении последней буквы заZавтоматически инкрементируется префиксная буква. -
columnLetterToIndex(col). Конвертирует буквенное обозначение столбца в 0-based числовой индекс, который требуетbatchUpdate- например,A->0,C->2,AA->26. -
Координаты таблицы параметризованы. Все опорные точки структуры Google Sheet - имя листа (
SheetName), столбец и строка заглавной ячейки недели (weekInfo_c,weekInfo_i), начало списка студентов (people_list_c,people_list_i), шаг между неделями в строках (offset) - вынесены вsettings.h. Логика адресации не содержит жёстко закодированных координат. В этом одна из ключевых уникальностей проекта - без жестко заданных границ размещения таблицы, с помощью нескольких опорных точек и математики алгоритм корректно работает с любым расписанием любой группы без перенастройки. -
Подсчёт через формулы. Строка формулы собирается динамически в зависимости от режима (
COUNTIFилиCOUNTIFS), диапазона недель и строки конкретного студента. Формула записывается в ячейку за пределами рабочей области таблицы, после чего та же ячейка читается обратно - Google вычислил формулу и вернул числовой результат. Никакой локальной обработки.
-
#define ATOMIC_FS_UPDATEв начале главного файла включает поддержку сжатых прошивок - это позволяет доставлять OTA-обновления через Telegram даже по медленному каналу. -
OTA в режиме
CriticalError(). Логика использования функции - плохо! Должна была быть исправлена при выходе финальной версии. При критической ошибке устройство уходит в бесконечныйfor(;;)с единственной активной операциейArduinoOTA.handle(). Прошивка с фиксом может быть накатана без физического доступа к железу. -
Синхронизация времени через Telegram.
bot.getTime(3)возвращает структуруFB_Timeс реальным временем (UTC+3). Используется: для определения текущей пары в сокращённом вводе, при вычислении дат вcheckTableWeek(), для валидации дат в конструктореDate. Синхронизация происходит при старте и обновляется в каждой итерацииloop(). -
Многоадминный режим. Массив
Admins[]содержит Telegram ID всех чатов администраторов. Статусное сообщение и сообщение-меню рассылаются и редактируются сразу во всех чатах - старосты и их замы видят одно и то же в реальном времени. Поддерживается одновременная работа нескольких администраторов (не многопоточность! Она планировалась в финальной версии). -
Автоочистка сервисных сообщений.
bot.clearServiceMessages(true)вsetup()- автоматическое удаление служебных уведомлений Telegram вида "... закрепил сообщение" и аналогичных, чтобы они не засоряли чат. -
Период опроса бота -
bot.setPeriod(50)- 50 мс. Кооперативная многозадачность: вloop()последовательно вызываютсяbot.tick(), триFileData::tick(),timer.tick(),serviceMess.tick(),ArduinoOTA.handle(). Блокирующихdelay()в основном цикле нет.
- Три файла в FFat:
| Файл | Переменная | Содержит |
|---|---|---|
/weekdata.dat |
week_off |
Номер текущей недели от начала семестра |
/data.dat |
chat_settings |
ID статусного сообщения и меню в каждом чате администратора |
/settings.dat |
settings |
Диапазон промежуточной аттестации, ширина таблиц по чётности |
-
week_off- критическая переменная. Счётчик числа недель с начала семестра - основа для вычисления координат любой строки в таблице. Неверное значение означает запись пропусков не в ту строку. Комментарий в коде прямо предупреждает: "НЕ ЗНАЕШЬ - НЕ МЕНЯЙ!" Сохраняется в FFat при каждом изменении черезFileData::updateNow()илиtick(). -
Сохранение ID сообщений. ID статусного сообщения и сообщения с меню для каждого чата администратора сохраняются в FFat. После перезагрузки ESP32 не создаёт новые сообщения, а редактирует уже существующие - чат остаётся чистым. Логика в
start_page()определяет по результатуfile.read()(FDstat_t), нужно ли отправить сообщение заново или достаточно его отредактировать. -
WiFi. Подключение с
WiFi.setAutoReconnect(true)- при потере связи модуль переподключается сам.WiFi.setMinSecurity(WIFI_AUTH_OPEN)- совместимость с открытыми сетями. Если при старте WiFi не поднялся заWIFI_RES_PERIODмиллисекунд - ESP32 перезагружается.
-
Date. Структура день/месяц/год с конструктором-валидатором: проверяет диапазоны дня, месяца и года, с учётом корректного числа дней в каждом месяце черезgetDayInMonth(). Февраль учитывает високосность черезStampUtils::isLeap(year). Перегруженыoperator=иoperator<.sumDate(Date*, int)прибавляет или вычитает произвольное число дней с корректным переходом месяца и года в обе стороны. -
Time. Класс для хранения времени (часы + минуты). Перегружены арифметические операторы:+и-с другимTimeили с количеством минут (uint8_t), с корректным переходом через полночь. Все операторы сравнения:<,>,==,!=,<=,>=. Используется для определения, какая пара сейчас идёт в сокращённом вводе без условия. -
SetInfo(nka) иCountInfo(count). Структуры-контексты для текущей операции.SetInfoхранит: фамилию, строку Н-ок (nki), дату, день недели, координаты ячейки в таблице (posC,posI), подгруппу, чётность недели.CountInfoхранит: фамилию, индекс в списке, подгруппу, предмет, режим подсчёта, итоговый результат. -
WeekInfo/DailySchedule/LessInfo. Иерархия структур расписания.WeekInfo- одна неделя: дата понедельника, чётность, массивDailyScheduleна 7 дней.DailySchedule- один день: количество пар, динамический массивLessInfo.LessInfo- одна пара: номер, принадлежность подгруппе (0- только первая,1- только вторая,2- обе). -
getNIndex(). Вычисляет координаты ячейки в таблице (posC,posI) для заданной фамилии и даты изnka. ЧерезStampUtils::dateToDays2000()вычисляет разницу с текущей неделей в днях, из неё получаетweeks_agoиdays_ago, из них - чётность нужной недели и строку. Столбец вычисляется суммированием числа пар всех предшествующих дней - алгоритм содержит "магические числа", смысл которых раскрыт в ранних коммитах (до февраля 2026). -
CheckSurnameMatch(). Побайтовое сравнение двух строк с допустимым числом несовпаденийmax_errors. Возвращает1при точном совпадении,2при совпадении с допустимыми ошибками,0при несовпадении. Корректно работает с кириллицей в UTF-8 - сравнение идёт по 2 байта (один кириллический символ).
-
ServiceMess. Класс для управления "статусным сообщением" в чатах администраторов. Вместо рассылки новых сообщений при каждом действии одно и то же сообщение редактируется -bot.editMessage()во всех чатах сразу.edit(text, del_period)- обновляет текст и опционально запускает таймер авто-сброса черезdel_periodмс.tick()опрашивается вloop()и при срабатывании таймера очищает сообщение, убирая временный статус. -
Конечный автомат меню. Класс
Menuс переменнойwayтипаString, хранящей текущий путь в дереве состояний (например,"011"- ветка редактирования, уровень выбора пары). Разветвление поway.startsWith("01"),way.startsWith("02")и т.д. Кнопка «Назад» удаляет последний символ (way.remove(way.length()-1)), «На главную» - сбрасывает в"0". Флагret_commandразделяет "вернуться на эту же страницу" и "перейти назад". -
Выбор диапазона недель. В режимах подсчёта и статистики реализован интерактивный пикер диапазона: список всех прошедших недель отображается кнопками, пользователь нажимает нужную и затем отмечает её как "Начало", "Конец" или "Начало и конец". Текущие границы визуально выделяются символами
START_SYMBOL/END_SYMBOL/STARTEND_SYMBOLпрямо в тексте кнопок. Этот же пикер используется в настройках промежуточной аттестации. -
Отображение календаря при выборе даты. При выборе произвольной даты в меню редактирования бот отображает месяц в виде сетки: заголовок
пн-вт-ср-чт-пт-сб-вс, числа расставлены по столбцам с учётом смещения первого дня месяца (pre_offsetпустых ячеек). Это создает визуально красивый и правильный с точке зрения визуального отображения дат и соответствия их реальным дням недели интерфейс. Отображаются числа только от начала семестра или начала месяца до последнего дня текущей недели. Исключает выставление пропусков на некорректные даты. Пустые ячейки для выравнивания - пробел.
Железо:
- ESP32 (с достаточным объёмом RAM - желательно >= 320 КБ heap). Автор использовал ESP32-S3 N16R8.
Сервисы:
- Telegram-бот (токен от @BotFather)
- Аккаунт Google с таблицей в Google Sheets специфичной структуры
- Сервисный аккаунт Google Cloud с доступом к Sheets API и приватным ключом
Библиотеки для Arduino IDE:
FastBotESP_Google_Sheet_ClientFileDataStamp/StampUtilsStringUtils
Caution
Проект не готов к запуску в режиме «скачал -> прошил -> работает». Ниже объясняю, почему это так, и что для этого нужно.
-
Файл
settings.hсодержит всё: WiFi-реквизиты, токен бота, ключ сервисного аккаунта Google, ID таблицы и листа, список студентов с подгруппами, расписание пар по времени, список предметов. В репозитории вместо него лежитsettings-example.h- заглушка без реальных значений. Переименуйте его вsettings.hи заполните своими данными перед компиляцией. -
Проект работает с таблицей конкретной структуры в Google Sheets. Шаблона в репозитории нет - воссоздать её без углублённого чтения кода не получится. Но это и не нужно, см. ниже. Поля
weekInfo_c,less_num_c,people_list_c,offsetвsettings-example.hописывают координаты данных в таблице. -
Для работы с Google Sheets API необходим сервисный аккаунт в Google Cloud Console с разрешёнными API: Sheets API и Drive API. Приватный ключ аккаунта прописывается в
PRIVATE_KEY.
Important
Даже при полной настройке проект не будет работать в России без проксирования Telegram-трафика на стороне ESP32 - именно блокировка Telegram и послужила причиной заморозки разработки. При наличии VPN/прокси в сети, к которой подключена ESP32, ограничений нет.
- Версия на момент заморозки: v0.7
- Проект находится в стадии незавершенной неопределенности, чтобы оживить его даже с обходами блокировок могут потребоваться значительные ресурсы доработки.
- Статус: разработка остановлена, баги не исправляются
- Часть задач и известных проблем задокументирована в
Tasks.ino - Pull Request'ы и Issues приветствуются, хотя реакция на них может быть не оперативной.
Note
Проект написан одним автором в свободное время, и занял суммарно около года работы (только ручной кодинг в целях обучения, только самостоятельные идеи и из воплощение). Большая часть нетривиальных решений продиктована реальными ограничениями: скромным объёмом RAM на ESP32, необходимостью поддерживать живой пользовательский опыт параллельно с разработкой.
Проект не претендует на идеальность архитектуры - зато честно отражает реальный инженерный процесс: парсинг внешнего API на микроконтроллере, управление динамической памятью в условиях её дефицита, реализация конечного автомата пользовательского интерфейса и нечёткий поиск по строкам с поддержкой кириллицы. Всё это работало в реальной группе и решало реальную задачу.
Проект является вызовом самому себе, автор преследовал мысль проверить себя в решении столь объемной задачи ресурсами относительно слабого МК. Цель была достигнута, но жизнь - жестокая штука, и проект так и не был запущен.