APRS-IS display for ESP32 + SH1106
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.ino.backup
|
||||
134
BUILD.md
Normal file
134
BUILD.md
Normal file
@@ -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)` |
|
||||
83
README.md
Normal file
83
README.md
Normal file
@@ -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, парсинг координат, отображение на дисплее, определение метеостанций, давление в мм рт. ст.
|
||||
498
pr133.ino
Normal file
498
pr133.ino
Normal file
@@ -0,0 +1,498 @@
|
||||
#include <WiFi.h>
|
||||
#include <Wire.h>
|
||||
#include <APRS-IS.h>
|
||||
#include <APRS-Decoder.h>
|
||||
#include <U8g2lib.h>
|
||||
#include <memory>
|
||||
|
||||
// ============== 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<APRSMessage> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user