commit ad5ce9715250b830ffb9e6ce8a2d1f47de0277bb Author: UA1ZBE Date: Fri May 22 13:22:39 2026 +0300 APRS-IS display for ESP32 + SH1106 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1bae7c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.ino.backup diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..5577718 --- /dev/null +++ b/BUILD.md @@ -0,0 +1,134 @@ +# Сборка и прошивка ESP32 APRS-ретранслятора + +## Требования + +- Установленный Arduino CLI +- ESP32 (чип ESP32-D0WD-V3) +- USB-кабель, порт `/dev/ttyUSB0` + +## 1. Установка ядра ESP32 + +```bash +arduino-cli config init +arduino-cli core update-index +arduino-cli core install esp32:esp32@3.3.8 +``` + +Если `arduino-cli core install` падает с ошибкой 403 — скачайте архив вручную и распакуйте в `~/.arduino15/packages/esp32/hardware/esp32/3.3.8/`. + +Также может потребоваться dfu-util: + +```bash +sudo ln -s /usr/bin/dfu-util /home/ua1zbe/.arduino15/packages/esp32/tools/dfu-util/0.11.0-1/dfu-util 2>/dev/null +``` + +## 2. Установка библиотек (из GitHub) + +Arduino Library Index недоступен (403), поэтому библиотеки ставятся вручную. + +```bash +cd ~/Arduino/libraries +git clone https://github.com/olikraus/U8g2.git +git clone https://github.com/lora-aprs/APRS-IS-Lib.git +mv APRS-IS-Lib APRS-IS +git clone https://github.com/lora-aprs/APRS-Decoder-Lib.git +cd APRS-Decoder-Lib +# Нужна версия 0.0.1, где есть класс APRSMessage +git checkout v0.0.1 +cd ~/Arduino/libraries +git clone https://github.com/ropg/esp-logger.git +``` + +## 3. Патчи библиотек + +### 3.1 APRS-IS + +Создать файл `~/Arduino/libraries/APRS-IS/logger_compat.h`: + +```cpp +#pragma once +#define logPrintlnE(msg) +#define logPrintlnE_FN(fn, msg) +#define logPrintlnD(msg) +``` + +В начале `APRS-IS.cpp` перед последним `#include` добавить: + +```cpp +#include "logger_compat.h" +``` + +Также заменить в `APRS-IS.cpp` строчку: + +```cpp +this->_ssid = ""; +this->_pass = ""; +``` + +на: + +```cpp +this->_ssid = ""; +``` + +### 3.2 APRS-Decoder-Lib (v0.0.1) + +В `APRSMessage.h` добавить метод: + +```cpp +String encode() { return toString(); } +``` + +## 4. Подготовка скетча + +Скетч лежит в файле `pr133.ino`. Перед первой заливкой: + +1. Убедитесь, что переменная `server` содержит актуальный адрес APRS-IS сервера: + - `server = "rotate.radiorubka.org";` + - И в EEPROM по адресу 181 должно быть то же значение. + +2. При необходимости сброса EEPROM — измените чек-байт в `setup()`: + +```cpp +// Было: +if(((readByteFromEEPROM(0, 0, 0x0))) != 164) +// Стало (любое другое число): +if(((readByteFromEEPROM(0, 0, 0x0))) != 165) +``` + +## 5. Компиляция и заливка + +```bash +arduino-cli compile --fqbn esp32:esp32:esp32:PartitionScheme=huge_app \ + -u -p /dev/ttyUSB0 "/home/ua1zbe/Рабочий стол/pr133" +``` + +Ключ `-u` означает «залить после компиляции». Размер скетча ~1.7 МБ, поэтому обязателен `huge_app`. + +Параметры fqbn: +- `esp32:esp32:esp32` — плата ESP32 +- `PartitionScheme=huge_app` — 3 МБ под приложение (по умолчанию 1.2 МБ) + +## 6. Монитор порта + +```bash +python3 -c " +import serial +s = serial.Serial('/dev/ttyUSB0', 115200) +while True: + line = s.readline() + if line: + print(line.decode('utf-8', errors='replace').strip()) +" +``` + +## 7. Типовые проблемы + +| Проблема | Решение | +|----------|---------| +| Ошибки компоновки — partition scheme | Используйте `PartitionScheme=huge_app` | +| Не находится `APRSMessage` | Проверьте версию APRS-Decoder-Lib — нужна v0.0.1 | +| Ошибка `logger/logger.h` not found | Подключите `logger_compat.h` (см. п. 3.1) | +| Нет WiFi из-за `getAutoConnect()` | Закомментируйте или удалите вызовы `WiFi.getAutoConnect()` и `setAutoConnect()` | +| При загрузке виден старый сервер (ua1zbe.ml) | Сбросьте EEPROM, сменив чек-байт (п. 4) | +| Скетч зависает на подключении к APRS-IS | Патч в `APRS-IS.cpp`: добавлен таймаут 10 с в `_connect()` вместо `while(true)` | diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fc9e1f --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# APRS-IS Display для ESP32 + SH1106 OLED + +Приём и отображение APRS-пакетов через WiFi на OLED-дисплее 128×64. + +## Возможности + +- Подключение к APRS-IS серверу через WiFi (основная + резервная сеть) +- Фильтр по радиусу 300 км от дома (67.37°N, 32.49°E) +- Отображение станций на дисплее + - **Обычные станции**: позывной (крупно), направление/расстояние, комментарий + - **Метеостанции**: бейдж `[WX]`, позывной, температура (°C), влажность (%), давление (мм рт. ст.) +- Автоматическое обновление экрана — всегда виден последний принятый пакет +- Сплэш-экран «APRS» при старте +- Счётчик принятых пакетов в статус-баре +- Переподключение при потере связи (WiFi и APRS-IS) + +## Аппаратное обеспечение + +- ESP32 (ESP32-D0WD-V3) +- OLED-дисплей SH1106 128×64, I2C +- Питание через USB + +## Схема подключения + +| ESP32 | SH1106 | +|-------|--------| +| 3.3V | VCC | +| GND | GND | +| GPIO21| SDA | +| GPIO22| SCL | + +## Прошивка + +### Требования + +- Arduino CLI +- Ядро ESP32 3.3.8 +- Библиотеки: U8g2, APRS-IS-Lib, APRS-Decoder-Lib v0.0.1 + +### Компиляция и загрузка + +``` +arduino-cli compile --fqbn esp32:esp32:esp32:PartitionScheme=huge_app \ + -u -p /dev/ttyUSB0 "/home/ua1zbe/Рабочий стол/pr133" +``` + +Обязателен `PartitionScheme=huge_app` (3 МБ под приложение). + +### Настройка + +Параметры заданы константами в начале скетча `pr133.ino`: + +- **WiFi** — `WIFI_SSID1`, `WIFI_PASS1` (основная сеть), `WIFI_SSID2`, `WIFI_PASS2` (резервная) +- **APRS-IS** — `APRS_USER`, `APRS_PASS`, `APRS_SERVER`, `APRS_PORT`, `APRS_FILTER` +- **Домашние координаты** — `HOME_LAT`, `HOME_LON` (для расчёта расстояния и направления) + +## Формат пакетов + +### Метеостанции + +Распознаются по наличию полей `tNNN` (температура в °F), `hNN` (влажность %), `bNNNNN` (давление в десятых долях гПа). + +На дисплей выводятся: +- Температура в °C (автоконвертация) +- Влажность (если есть) +- Давление в мм рт. ст. (×0.75 от гПа) + +### Обычные станции + +Показываются позывной, направление/дистанция от дома, оставшийся текст комментария. + +## Структура проекта + +``` +pr133.ino — основной скетч +BUILD.md — инструкция по сборке с нуля +README.md — данное описание +pr133.ino.backup — оригинальный автосгенерированный скетч (на всякий случай) +``` + +## История версий + +- **v1.0** — чистый rewrite с нуля: сплэш APRS, приём пакетов через APRS-IS, парсинг координат, отображение на дисплее, определение метеостанций, давление в мм рт. ст. diff --git a/pr133.ino b/pr133.ino new file mode 100644 index 0000000..04d1eda --- /dev/null +++ b/pr133.ino @@ -0,0 +1,498 @@ +#include +#include +#include +#include +#include +#include + +// ============== DISPLAY ============== +U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); + +// ============== WIFI ============== +const char* WIFI_SSID1 = "Keenetic-3222"; +const char* WIFI_PASS1 = "28021980"; +const char* WIFI_SSID2 = "TP-Link_45EC"; +const char* WIFI_PASS2 = "16314733"; + +// ============== APRS-IS ============== +const char* APRS_USER = "UA1ZBE-5"; +const char* APRS_PASS = "21948"; +const char* APRS_SERVER = "rotate.radiorubka.org"; +const int APRS_PORT = 14580; +const char* APRS_FILTER = "r/67.22/032.29/300"; + +APRS_IS aprs(APRS_USER, APRS_PASS, "ESP32-APRS", "1.0"); + +// ============== HOME QTH ============== +const float HOME_LAT = 67.3709; +const float HOME_LON = 32.4896; + +// ============== STATION STORAGE ============== +#define MAX_STATIONS 15 + +struct Station { + String callsign; + float lat, lon; + bool hasPos; + float distance; + int heading; + String comment; + unsigned long rxTime; + bool isWx; + float wxTemp; + int wxHumidity; + float wxPressure; +}; + +Station stations[MAX_STATIONS]; +int stationCount = 0; +int displayIdx = 0; + +// ============== STATS ============== +unsigned long totalPackets = 0; +unsigned long lastPacketTime = 0; + +// ============== FSM ============== +enum State { STATE_SPLASH, STATE_RUNNING }; +State state = STATE_SPLASH; +unsigned long splashStart = 0; + +// ============== GEO HELPERS ============== +float toRad(float d) { return d * DEG_TO_RAD; } + +float haversine(float lat1, float lon1, float lat2, float lon2) { + double dlat = toRad(lat2 - lat1); + double dlon = toRad(lon2 - lon1); + double a = sin(dlat / 2) * sin(dlat / 2) + + cos(toRad(lat1)) * cos(toRad(lat2)) * + sin(dlon / 2) * sin(dlon / 2); + return 6371.0 * 2.0 * atan2(sqrt(a), sqrt(1 - a)); +} + +int bearing(float lat1, float lon1, float lat2, float lon2) { + double dlon = toRad(lon2 - lon1); + double y = sin(dlon) * cos(toRad(lat2)); + double x = cos(toRad(lat1)) * sin(toRad(lat2)) - + sin(toRad(lat1)) * cos(toRad(lat2)) * cos(dlon); + return ((int)(atan2(y, x) * RAD_TO_DEG + 0.5) + 360) % 360; +} + +const char* dirSymbol(int deg) { + if (deg >= 337 || deg < 23) return "N"; + if (deg < 68) return "NE"; + if (deg < 113) return "E"; + if (deg < 158) return "SE"; + if (deg < 203) return "S"; + if (deg < 248) return "SW"; + if (deg < 293) return "W"; + return "NW"; +} + +// ============== WX PARSER ============== +bool parseWxData(const String& body, float& tempC, int& humidity, float& pressure) { + bool found = false; + tempC = -999; + humidity = -1; + pressure = -1; + + if (body.length() > 0 && body[0] == '_') found = true; + + int tIdx = body.indexOf('t'); + if (tIdx >= 0 && tIdx + 4 <= body.length()) { + if (isDigit(body[tIdx + 1]) && isDigit(body[tIdx + 2]) && isDigit(body[tIdx + 3])) { + int raw = body.substring(tIdx + 1, tIdx + 4).toInt(); + if (raw > -50 && raw < 200) { + tempC = (raw - 32) * 5.0 / 9.0; + found = true; + } + } + } + + int hIdx = body.indexOf('h'); + if (hIdx >= 0 && hIdx + 3 <= body.length()) { + if (isDigit(body[hIdx + 1]) && isDigit(body[hIdx + 2])) { + humidity = body.substring(hIdx + 1, hIdx + 3).toInt(); + } + } + + int bIdx = body.indexOf('b'); + if (bIdx >= 0 && bIdx + 6 <= body.length()) { + bool ok = true; + for (int i = 1; i <= 5; i++) { + if (!isDigit(body[bIdx + i])) { ok = false; break; } + } + if (ok) { + pressure = body.substring(bIdx + 1, bIdx + 6).toInt() / 10.0; + } + } + + return found; +} + +// ============== APRS PARSER ============== +bool parsePosition(const String& data, float& lat, float& lon) { + int nsIdx = -1; + for (int i = 0; i < data.length() - 7; i++) { + if ((data[i] == 'N' || data[i] == 'S') && i >= 7) { + if (isDigit(data[i - 1]) && isDigit(data[i - 2]) && + data[i - 3] == '.' && + isDigit(data[i - 4]) && isDigit(data[i - 5]) && + isDigit(data[i - 6]) && isDigit(data[i - 7])) { + nsIdx = i; + break; + } + } + } + if (nsIdx < 0) return false; + + lat = data.substring(nsIdx - 7, nsIdx - 5).toFloat() + + data.substring(nsIdx - 5, nsIdx + 1).toFloat() / 60.0; + if (data[nsIdx] == 'S') lat = -lat; + + int lonStart = nsIdx + 1; + if (lonStart + 8 > data.length()) return false; + if (!isDigit(data[lonStart]) || !isDigit(data[lonStart + 1]) || + !isDigit(data[lonStart + 2]) || !isDigit(data[lonStart + 3]) || + !isDigit(data[lonStart + 4]) || data[lonStart + 5] != '.' || + !isDigit(data[lonStart + 6]) || !isDigit(data[lonStart + 7])) + return false; + + lon = data.substring(lonStart, lonStart + 3).toFloat() + + data.substring(lonStart + 3, lonStart + 8).toFloat() / 60.0; + + int ewPos = lonStart + 8; + if (ewPos < data.length() && data[ewPos] == 'W') lon = -lon; + return ewPos < data.length() && (data[ewPos] == 'E' || data[ewPos] == 'W'); +} + +String stripPosition(const String& body) { + String r = body; + if (r.length() == 0) return r; + char c = r[0]; + if (c == '!' || c == '/' || c == '@' || c == '=' || c == '>' || + c == '_' || c == '\'' || c == '`' || c == ':' || c == ';') { + r = r.substring(1); + } + + int nsIdx = -1, ewEnd = -1; + for (int i = 0; i < r.length() - 7; i++) { + if ((r[i] == 'N' || r[i] == 'S') && i >= 7) { + if (isDigit(r[i - 1]) && isDigit(r[i - 2]) && r[i - 3] == '.' && + isDigit(r[i - 4]) && isDigit(r[i - 5]) && + isDigit(r[i - 6]) && isDigit(r[i - 7])) { + nsIdx = i; + break; + } + } + } + if (nsIdx >= 0) { + for (int j = nsIdx + 9; j < r.length(); j++) { + if (r[j] == 'E' || r[j] == 'W') { ewEnd = j + 1; break; } + } + if (ewEnd > 0) { + r = r.substring(ewEnd); + while (r.length() > 0 && r[0] < 0x20) r = r.substring(1); + if (r.length() > 0 && r[0] > 0x1f && r[0] < 0x40) r = r.substring(1); + if (r.length() > 0 && r[0] > 0x1f && r[0] < 0x60) r = r.substring(1); + } + } + r.trim(); + return r; +} + +// ============== STATION MANAGEMENT ============== +int findStation(const String& call) { + for (int i = 0; i < stationCount; i++) + if (stations[i].callsign == call) return i; + return -1; +} + +int addStation(const String& call) { + int idx = findStation(call); + if (idx >= 0) return idx; + if (stationCount < MAX_STATIONS) return stationCount++; + idx = displayIdx; + return idx; +} + +void handleMessage(std::shared_ptr msg) { + totalPackets++; + lastPacketTime = millis(); + + String src = msg->getSource(); + String body = msg->getAPRSBody()->getData(); + + int idx = addStation(src); + Station& s = stations[idx]; + s.callsign = src; + s.rxTime = millis(); + s.hasPos = parsePosition(body, s.lat, s.lon); + s.comment = stripPosition(body); + s.isWx = parseWxData(body, s.wxTemp, s.wxHumidity, s.wxPressure); + + if (s.hasPos) { + s.distance = haversine(HOME_LAT, HOME_LON, s.lat, s.lon); + s.heading = bearing(HOME_LAT, HOME_LON, s.lat, s.lon); + } + + displayIdx = idx; + + Serial.print("RX: "); + Serial.print(src); + if (s.hasPos) { + Serial.print(" "); + Serial.print(s.distance, 1); + Serial.print("km "); + Serial.print(s.heading); + Serial.print("\xB0"); + } + if (s.comment.length() > 0) { + Serial.print(" \""); + Serial.print(s.comment.substring(0, 40)); + Serial.print("\""); + } + Serial.println(); +} + +// ============== DISPLAY ============== +void drawSplash() { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_10x20_t_cyrillic); + int w = u8g2.getUTF8Width("APRS"); + u8g2.drawUTF8((128 - w) / 2, 28, "APRS"); + + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + u8g2.drawUTF8(0, 44, APRS_USER); + + String ver = "v1.0"; + int vw = u8g2.getUTF8Width(ver.c_str()); + u8g2.drawUTF8(128 - vw, 44, ver.c_str()); + u8g2.sendBuffer(); +} + +void drawStatusBar() { + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + + String left = "APRS-IS"; + if (!aprs.connected()) left += " ..."; + u8g2.drawUTF8(2, 10, left.c_str()); + + String right = String(totalPackets) + "pkt"; + int rw = u8g2.getUTF8Width(right.c_str()); + u8g2.drawUTF8(126 - rw, 10, right.c_str()); +} + +void drawWxStation(int idx) { + drawStatusBar(); + u8g2.drawLine(0, 14, 127, 14); + + Station& s = stations[idx]; + + u8g2.setDrawColor(1); + u8g2.drawRBox(2, 16, 18, 14, 2); + u8g2.setDrawColor(0); + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + u8g2.drawUTF8(5, 28, "WX"); + u8g2.setDrawColor(1); + + u8g2.setFont(u8g2_font_10x20_t_cyrillic); + u8g2.drawUTF8(24, 34, s.callsign.c_str()); + + if (s.hasPos) { + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + char line[24]; + snprintf(line, sizeof(line), "%s %d\xB0 %.0fkm", + dirSymbol(s.heading), s.heading, s.distance); + int w = u8g2.getUTF8Width(line); + u8g2.drawUTF8(126 - w, 34, line); + } + + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + int y = 50; + + if (s.wxTemp > -100 && s.wxTemp < 150) { + String t = "Temp: " + String(s.wxTemp, 1) + "\xB0" "C"; + int tw = u8g2.getUTF8Width(t.c_str()); + int tx = (128 - tw) / 2; + if (tx < 2) tx = 2; + u8g2.drawUTF8(tx, y, t.c_str()); + } + + y += 14; + + String wl; + float pressMmHg = s.wxPressure * 0.75; + if (s.wxHumidity >= 0 && s.wxPressure > 0) { + wl = "Hum: " + String(s.wxHumidity) + "% " + String(pressMmHg, 0) + "mm"; + } else if (s.wxHumidity >= 0) { + wl = "Humidity: " + String(s.wxHumidity) + "%"; + } else if (s.wxPressure > 0) { + wl = "Press: " + String(pressMmHg, 0) + "mm"; + } else { + unsigned long sec = (millis() - s.rxTime) / 1000; + wl = sec < 120 ? String(sec) + "s" : String(sec / 60) + "m"; + wl = "RX: " + wl + " ago"; + } + int ww = u8g2.getUTF8Width(wl.c_str()); + int wx = (128 - ww) / 2; + if (wx < 2) wx = 2; + u8g2.drawUTF8(wx, y, wl.c_str()); +} + +void drawStationScreen(int idx) { + Station& s = stations[idx]; + if (s.isWx) { + drawWxStation(idx); + return; + } + + drawStatusBar(); + u8g2.drawLine(0, 14, 127, 14); + + if (stationCount == 0) { + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + u8g2.drawUTF8(2, 50, "Waiting for packets..."); + return; + } + + u8g2.setFont(u8g2_font_10x20_t_cyrillic); + u8g2.drawUTF8(2, 34, s.callsign.c_str()); + + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + int y = 50; + + if (s.hasPos) { + char line[32]; + snprintf(line, sizeof(line), "%s %d\xB0 %.1f km", + dirSymbol(s.heading), s.heading, s.distance); + u8g2.drawUTF8(2, y, line); + + unsigned long sec = (millis() - s.rxTime) / 1000; + String age; + if (sec < 120) age = String(sec) + "s"; + else age = String(sec / 60) + "m"; + int aw = u8g2.getUTF8Width(age.c_str()); + u8g2.drawUTF8(126 - aw, y, age.c_str()); + + y += 14; + } + + if (s.comment.length() > 0) { + String c = s.comment; + if (u8g2.getUTF8Width(c.c_str()) > 124) { + while (u8g2.getUTF8Width(c.c_str()) > 120 && c.length() > 0) + c = c.substring(0, c.length() - 1); + c += "..."; + } + u8g2.drawUTF8(2, y, c.c_str()); + } +} + +void showConnecting() { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_10x20_t_cyrillic); + u8g2.drawUTF8(2, 28, "Connecting..."); + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + if (WiFi.status() != WL_CONNECTED) { + u8g2.drawUTF8(2, 48, "WiFi"); + } else { + u8g2.drawUTF8(2, 48, "APRS-IS"); + u8g2.drawUTF8(80, 48, APRS_SERVER); + } + u8g2.sendBuffer(); +} + +// ============== CONNECTION ============== +bool connectWiFi() { + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_SSID1, WIFI_PASS1); + for (int i = 0; i < 30 && WiFi.status() != WL_CONNECTED; i++) { + showConnecting(); + delay(500); + } + if (WiFi.status() != WL_CONNECTED) { + WiFi.disconnect(true); + delay(100); + WiFi.begin(WIFI_SSID2, WIFI_PASS2); + for (int i = 0; i < 30 && WiFi.status() != WL_CONNECTED; i++) { + showConnecting(); + delay(500); + } + } + if (WiFi.status() == WL_CONNECTED) { + Serial.println("WiFi OK: " + WiFi.localIP().toString()); + return true; + } + Serial.println("WiFi FAIL"); + return false; +} + +void connectAPRS() { + showConnecting(); + if (aprs.connect(APRS_SERVER, APRS_PORT, APRS_FILTER)) { + Serial.println("APRS-IS connected to " + String(APRS_SERVER)); + } else { + Serial.println("APRS-IS connection failed"); + } +} + +// ============== SETUP ============== +void setup() { + Serial.begin(115200); + Wire.begin(); + + u8g2.begin(); + u8g2.enableUTF8Print(); + + drawSplash(); + splashStart = millis(); + state = STATE_SPLASH; + + WiFi.persistent(false); + lastPacketTime = millis(); + + if (connectWiFi()) { + connectAPRS(); + } else { + // will retry in loop() + } +} + +// ============== LOOP ============== +void loop() { + if (state == STATE_SPLASH && millis() - splashStart > 3000) { + state = STATE_RUNNING; + } + + if (aprs.connected() && aprs.available() > 0) { + auto msg = aprs.getAPRSMessage(); + if (msg != nullptr) handleMessage(msg); + } + + { + static unsigned long lastWifiTry = 0; + if (WiFi.status() != WL_CONNECTED && millis() - lastWifiTry > 30000) { + lastWifiTry = millis(); + connectWiFi(); + } + } + { + static unsigned long lastAprsTry = 0; + if (WiFi.status() == WL_CONNECTED && !aprs.connected() && millis() - lastAprsTry > 30000) { + lastAprsTry = millis(); + connectAPRS(); + } + } + + u8g2.clearBuffer(); + if (state == STATE_SPLASH) { + u8g2.setFont(u8g2_font_6x12_t_cyrillic); + u8g2.drawUTF8(2, 60, "Connecting..."); + u8g2.sendBuffer(); + } else { + drawStationScreen(displayIdx); + u8g2.sendBuffer(); + } + + delay(30); +}