This commit is contained in:
vadim committed 2026-10-27 15:41:59 +03:00
commit 9662d1278f
8 files changed
+661

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
include/secrets.h
+11
View File
@@ -0,0 +1,11 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack",
"pioarduino.pioarduino-ide"
]
}
+37
View File
@@ -0,0 +1,37 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
+5
View File
@@ -0,0 +1,5 @@
// Скопируй в secrets.h и впиши свои данные. secrets.h не попадает в git.
#pragma once
#define WIFI_SSID "your-ssid"
#define WIFI_PASSWORD "your-password"
+46
View File
@@ -0,0 +1,46 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html
+43
View File
@@ -0,0 +1,43 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps =
bodmer/TFT_eSPI @ ^2.5.43
bblanchon/ArduinoJson @ ^7.2.0
; Настройка TFT_eSPI без правки User_Setup.h внутри библиотеки.
build_flags =
-D USER_SETUP_LOADED=1
-D ILI9341_DRIVER=1
-D TFT_WIDTH=240
-D TFT_HEIGHT=320
-D TFT_MISO=19
-D TFT_MOSI=23
-D TFT_SCLK=18
-D TFT_CS=5
-D TFT_DC=21
-D TFT_RST=15
-D TFT_BL=22
-D TFT_BACKLIGHT_ON=HIGH
-D TOUCH_CS=4
-D USE_HSPI_PORT=1
-D SPI_FREQUENCY=40000000
-D SPI_READ_FREQUENCY=16000000
-D SPI_TOUCH_FREQUENCY=2500000
-D LOAD_GLCD=1
-D LOAD_FONT2=1
-D LOAD_FONT4=1
-D LOAD_GFXFF=1
-D SMOOTH_FONT=1
+502
View File
@@ -0,0 +1,502 @@
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <TFT_eSPI.h>
#include "secrets.h"
TFT_eSPI tft = TFT_eSPI();
// Что показываем. Касание левой половины экрана — следующий тикер, правой — следующий таймфрейм
static const char *SYMBOLS[] = {"PEPEUSDT", "BTCUSDT", "ETHUSDT"};
static const int SYMBOL_COUNT = sizeof(SYMBOLS) / sizeof(SYMBOLS[0]);
static const int DEFAULT_SYMBOL = 0; // PEPEUSDT
struct Interval
{
const char *api; // значение для Bybit: 1,3,5,15,30,60,120,240,360,720,D,W,M
const char *label; // подпись на экране
};
static const Interval INTERVALS[] = {{"1", "1m"}, {"5", "5m"}, {"15", "15m"}, {"60", "1h"}, {"240", "4h"}, {"D", "1D"}};
static const int INTERVAL_COUNT = sizeof(INTERVALS) / sizeof(INTERVALS[0]);
static const int DEFAULT_INTERVAL = 2; // 15m
static const char *CATEGORY = "spot"; // spot | linear | inverse
static const uint32_t REFRESH_MS = 30000;
// Если api.bybit.com недоступен у провайдера — попробуй api.bytick.com
static const char *API_HOST = "https://api.bybit.com";
struct Candle
{
float open, high, low, close;
};
static const int CANDLE_COUNT = 60;
// Снимок данных, который netTask передаёт в displayTask
struct MarketData
{
int symbolIdx;
int intervalIdx;
Candle candles[CANDLE_COUNT];
int candleCount;
float lastPrice;
float change24hPct;
bool okCandles;
bool okTicker;
};
struct StatusMsg
{
char text[48];
uint16_t color;
};
enum TouchCmd : uint8_t
{
CMD_NEXT_SYMBOL,
CMD_NEXT_INTERVAL,
};
QueueHandle_t dataQueue; // длина 1, xQueueOverwrite — всегда самый свежий снимок
QueueHandle_t statusQueue;
QueueHandle_t cmdQueue; // touchTask -> netTask
// Экран и тач сидят на одной шине SPI — любое обращение к tft только под этим мьютексом
SemaphoreHandle_t tftMutex;
// Экран в landscape: 320x240
static const int CHART_X = 0;
static const int CHART_Y = 30;
static const int CHART_W = 270; // справа остаётся место под шкалу цен
static const int CHART_H = 200;
static const int STATUS_Y = 232;
void postStatus(uint16_t color, const char *fmt, ...)
{
StatusMsg msg;
msg.color = color;
va_list args;
va_start(args, fmt);
vsnprintf(msg.text, sizeof(msg.text), fmt, args);
va_end(args);
Serial.println(msg.text);
xQueueSend(statusQueue, &msg, 0); // если очередь полна — сообщение просто теряется
}
// ---------- Сеть (netTask) ----------
void connectWiFi()
{
if (WiFi.status() == WL_CONNECTED)
return;
postStatus(TFT_WHITE, "WiFi: connecting...");
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000)
{
vTaskDelay(pdMS_TO_TICKS(250));
}
if (WiFi.status() == WL_CONNECTED)
{
postStatus(TFT_WHITE, "WiFi: %s", WiFi.localIP().toString().c_str());
}
else
{
postStatus(TFT_RED, "WiFi: connect failed");
}
}
// Причина последней ошибки fetchJson — выводится в строку статуса
static char fetchError[32];
// Выполняет GET и разбирает JSON. Возвращает false при любой ошибке, причина — в fetchError.
bool fetchJson(const String &url, JsonDocument &doc, JsonDocument &filter)
{
WiFiClientSecure client;
// Публичные рыночные данные, без ключей — проверку сертификата пока пропускаем
client.setInsecure();
HTTPClient http;
http.setTimeout(10000);
if (!http.begin(client, url))
{
snprintf(fetchError, sizeof(fetchError), "http.begin failed");
Serial.println(fetchError);
return false;
}
int code = http.GET();
if (code != HTTP_CODE_OK)
{
snprintf(fetchError, sizeof(fetchError), "HTTP %d", code);
Serial.printf("%s for %s\n", fetchError, url.c_str());
http.end();
return false;
}
// Сначала читаем тело целиком (свечи — ~6 КБ): разбор прямо из TLS-потока
// обрывается с IncompleteInput, если данные приходят с паузами
String body = http.getString();
http.end();
DeserializationError err = deserializeJson(doc, body, DeserializationOption::Filter(filter));
if (err)
{
snprintf(fetchError, sizeof(fetchError), "JSON %s (%u B)", err.c_str(), body.length());
Serial.println(fetchError);
return false;
}
if (doc["retCode"].as<int>() != 0)
{
snprintf(fetchError, sizeof(fetchError), "retCode %d", doc["retCode"].as<int>());
Serial.printf("Bybit %s: %s\n", fetchError, doc["retMsg"].as<const char *>());
return false;
}
return true;
}
bool fetchCandles(MarketData &data)
{
String url = String(API_HOST) + "/v5/market/kline?category=" + CATEGORY + "&symbol=" + SYMBOLS[data.symbolIdx] +
"&interval=" + INTERVALS[data.intervalIdx].api + "&limit=" + CANDLE_COUNT;
JsonDocument filter;
filter["retCode"] = true;
filter["retMsg"] = true;
filter["result"]["list"] = true;
JsonDocument doc;
if (!fetchJson(url, doc, filter))
return false;
// Bybit отдаёт свечи от новой к старой:
// [startTime, open, high, low, close, volume, turnover], всё строками
JsonArray list = doc["result"]["list"];
int n = min((int)list.size(), CANDLE_COUNT);
if (n == 0)
snprintf(fetchError, sizeof(fetchError), "empty list");
for (int i = 0; i < n; i++)
{
JsonArray k = list[n - 1 - i];
data.candles[i] = {k[1].as<float>(), k[2].as<float>(), k[3].as<float>(), k[4].as<float>()};
}
data.candleCount = n;
return n > 0;
}
bool fetchTicker(MarketData &data)
{
String url = String(API_HOST) + "/v5/market/tickers?category=" + CATEGORY + "&symbol=" + SYMBOLS[data.symbolIdx];
JsonDocument filter;
filter["retCode"] = true;
filter["retMsg"] = true;
filter["result"]["list"][0]["lastPrice"] = true;
filter["result"]["list"][0]["price24hPcnt"] = true;
JsonDocument doc;
if (!fetchJson(url, doc, filter))
return false;
JsonObject t = doc["result"]["list"][0];
if (t.isNull())
return false;
data.lastPrice = t["lastPrice"].as<float>();
data.change24hPct = t["price24hPcnt"].as<float>() * 100; // Bybit отдаёт долю, 0.0123 = 1.23%
return true;
}
void applyCommand(MarketData &data, TouchCmd cmd)
{
if (cmd == CMD_NEXT_SYMBOL)
data.symbolIdx = (data.symbolIdx + 1) % SYMBOL_COUNT;
if (cmd == CMD_NEXT_INTERVAL)
data.intervalIdx = (data.intervalIdx + 1) % INTERVAL_COUNT;
// Старые свечи относятся к другому тикеру/таймфрейму — не показываем их
data.candleCount = 0;
data.lastPrice = 0;
data.change24hPct = 0;
}
void netTask(void *)
{
// static — чтобы снимок (~1 КБ) не занимал стек задачи
static MarketData data = {};
data.symbolIdx = DEFAULT_SYMBOL;
data.intervalIdx = DEFAULT_INTERVAL;
for (;;)
{
connectWiFi();
if (WiFi.status() == WL_CONNECTED)
{
char candlesError[32] = "";
data.okCandles = fetchCandles(data);
if (!data.okCandles)
strlcpy(candlesError, fetchError, sizeof(candlesError));
data.okTicker = fetchTicker(data);
// Если тикер не пришёл, показываем close последней свечи
if (!data.okTicker && data.candleCount > 0)
data.lastPrice = data.candles[data.candleCount - 1].close;
xQueueOverwrite(dataQueue, &data);
if (data.okCandles && data.okTicker)
{
postStatus(TFT_WHITE, "%s heap %u stack %u", CATEGORY, ESP.getFreeHeap(), uxTaskGetStackHighWaterMark(NULL));
}
else if (!data.okCandles)
{
postStatus(TFT_RED, "candles: %s", candlesError);
}
else
{
postStatus(TFT_RED, "ticker: %s", fetchError);
}
}
// Ждём либо команду с тача, либо время следующего обновления
TouchCmd cmd;
if (xQueueReceive(cmdQueue, &cmd, pdMS_TO_TICKS(REFRESH_MS)) == pdTRUE)
{
applyCommand(data, cmd);
// Несколько быстрых касаний, пока шёл запрос, применяем разом
while (xQueueReceive(cmdQueue, &cmd, 0) == pdTRUE)
applyCommand(data, cmd);
postStatus(TFT_WHITE, "Loading %s %s...", SYMBOLS[data.symbolIdx], INTERVALS[data.intervalIdx].label);
}
}
}
// ---------- Тач (touchTask) ----------
// Калибровка хранится в NVS. Держи палец на экране при включении, чтобы откалибровать заново
void setupTouch()
{
uint16_t cal[5];
Preferences prefs;
prefs.begin("touch", false);
bool haveCal = prefs.getBytes("cal", cal, sizeof(cal)) == sizeof(cal);
bool forceCal = tft.getTouchRawZ() > 600;
if (!haveCal || forceCal)
{
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawCentreString("Touch calibration", tft.width() / 2, tft.height() / 2 - 20, 2);
tft.drawCentreString("Release, then touch the corners", tft.width() / 2, tft.height() / 2, 2);
while (tft.getTouchRawZ() > 300)
delay(10); // ждём, пока отпустят палец после включения
delay(500);
tft.fillScreen(TFT_BLACK);
tft.calibrateTouch(cal, TFT_MAGENTA, TFT_BLACK, 15);
prefs.putBytes("cal", cal, sizeof(cal));
tft.fillScreen(TFT_BLACK);
}
prefs.end();
tft.setTouch(cal);
}
bool readTouch(uint16_t &x, uint16_t &y)
{
xSemaphoreTake(tftMutex, portMAX_DELAY);
bool touched = tft.getTouch(&x, &y);
xSemaphoreGive(tftMutex);
return touched;
}
void touchTask(void *)
{
uint16_t x, y;
for (;;)
{
if (readTouch(x, y))
{
TouchCmd cmd = x < tft.width() / 2 ? CMD_NEXT_SYMBOL : CMD_NEXT_INTERVAL;
xQueueSend(cmdQueue, &cmd, 0);
postStatus(TFT_WHITE, cmd == CMD_NEXT_SYMBOL ? "Next ticker..." : "Next timeframe...");
// Одно касание — одна команда: ждём, пока отпустят палец
while (readTouch(x, y))
vTaskDelay(pdMS_TO_TICKS(50));
vTaskDelay(pdMS_TO_TICKS(200));
}
vTaskDelay(pdMS_TO_TICKS(30));
}
}
// ---------- Экран (displayTask) ----------
void drawStatus(const StatusMsg &msg)
{
tft.fillRect(0, STATUS_Y, tft.width(), tft.height() - STATUS_Y, TFT_BLACK);
tft.setTextColor(msg.color, TFT_BLACK);
tft.drawString(msg.text, 2, STATUS_Y, 1);
}
// Знаков после запятой, чтобы показать ~5 значащих цифр (не меньше 2):
// 79911.4 -> 2, 0.5123 -> 4, 0.0000098123 -> 10
int priceDecimals(float p)
{
if (p <= 0)
return 2;
return constrain(4 - (int)floorf(log10f(p)), 2, 10);
}
void drawHeader(const char *symbol, const char *interval, float price, float changePct)
{
tft.fillRect(0, 0, tft.width(), CHART_Y - 2, TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.drawString(symbol, 4, 4, 4);
tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
tft.drawString(interval, 4 + tft.textWidth(symbol, 4) + 6, 9, 2);
if (price <= 0)
return; // данных ещё нет (например, сразу после переключения)
char buf[32];
snprintf(buf, sizeof(buf), "%.*f", priceDecimals(price), price);
uint16_t color = changePct >= 0 ? TFT_GREEN : TFT_RED;
tft.setTextColor(color, TFT_BLACK);
tft.setTextDatum(TR_DATUM);
tft.setFreeFont(&FreeSansBold9pt7b);
tft.drawString(buf, tft.width() - 4, 1);
// setTextFont сбрасывает free font, иначе drawString(..., 1) рисовал бы им
tft.setTextFont(1);
snprintf(buf, sizeof(buf), "%+.2f%% 24h", changePct);
// Встроенного жирного нет — рисуем дважды со сдвигом на пиксель
tft.drawString(buf, tft.width() - 5, 18, 1);
tft.drawString(buf, tft.width() - 4, 18, 1);
tft.setTextDatum(TL_DATUM);
}
void drawCandles(const Candle *data, int count)
{
tft.fillRect(CHART_X, CHART_Y, tft.width(), CHART_H, TFT_BLACK);
if (count == 0)
return;
float minP = data[0].low, maxP = data[0].high;
for (int i = 1; i < count; i++)
{
minP = min(minP, data[i].low);
maxP = max(maxP, data[i].high);
}
// Сравнение относительное: у монет вроде PEPE весь диапазон меньше 1e-6
if (maxP - minP < maxP * 1e-6f)
maxP = minP * 1.001f + 1e-12f;
auto priceToY = [&](float p)
{
return CHART_Y + (int)((maxP - p) / (maxP - minP) * (CHART_H - 1));
};
// Мелкие цены (PEPE ~0.00001) не влезают в колонку шкалы шириной ~8 символов,
// поэтому показываем мантиссу, а множитель (x1e-6) пишем отдельно
int exp10 = 0;
float scale = 1;
if (maxP < 0.01f)
{
exp10 = (int)floorf(log10f(maxP));
scale = powf(10, -exp10);
}
float labelStep = (maxP - minP) * scale / 4;
int labelDecimals = constrain((int)ceilf(-log10f(labelStep)), 0, 6);
// Сетка и шкала цен
tft.setTextColor(TFT_DARKGREY, TFT_BLACK);
char buf[16];
for (int i = 0; i <= 4; i++)
{
int y = CHART_Y + i * (CHART_H - 1) / 4;
tft.drawFastHLine(CHART_X, y, CHART_W, 0x2104);
float p = maxP - (maxP - minP) * i / 4;
snprintf(buf, sizeof(buf), "%.*f", labelDecimals, p * scale);
tft.drawString(buf, CHART_X + CHART_W + 3, y - 3, 1);
}
if (exp10 != 0)
{
snprintf(buf, sizeof(buf), "x1e%d", exp10);
tft.drawString(buf, CHART_X + CHART_W + 3, CHART_Y + 12, 1);
}
int step = CHART_W / CANDLE_COUNT;
int bodyW = max(1, step - 1);
for (int i = 0; i < count; i++)
{
const Candle &c = data[i];
int x = CHART_X + i * step;
uint16_t color = c.close >= c.open ? TFT_GREEN : TFT_RED;
tft.drawFastVLine(x + bodyW / 2, priceToY(c.high), priceToY(c.low) - priceToY(c.high) + 1, color);
int yTop = priceToY(max(c.open, c.close));
int yBot = priceToY(min(c.open, c.close));
tft.fillRect(x, yTop, bodyW, max(1, yBot - yTop + 1), color);
}
}
void displayTask(void *)
{
static MarketData data;
StatusMsg msg;
for (;;)
{
// Ждём новые данные; таймаут нужен, чтобы успевать выводить статусы
if (xQueueReceive(dataQueue, &data, pdMS_TO_TICKS(50)) == pdTRUE)
{
xSemaphoreTake(tftMutex, portMAX_DELAY);
drawHeader(SYMBOLS[data.symbolIdx], INTERVALS[data.intervalIdx].label, data.lastPrice, data.change24hPct);
drawCandles(data.candles, data.candleCount);
xSemaphoreGive(tftMutex);
}
while (xQueueReceive(statusQueue, &msg, 0) == pdTRUE)
{
xSemaphoreTake(tftMutex, portMAX_DELAY);
drawStatus(msg);
xSemaphoreGive(tftMutex);
}
}
}
void setup()
{
Serial.begin(115200);
// Экран и калибровку тача делаем до запуска задач, пока к tft обращается только setup()
tft.init();
tft.setRotation(1); // landscape
tft.fillScreen(TFT_BLACK);
#ifdef TFT_BL
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, TFT_BACKLIGHT_ON);
#endif
setupTouch();
drawHeader(SYMBOLS[DEFAULT_SYMBOL], INTERVALS[DEFAULT_INTERVAL].label, 0, 0);
dataQueue = xQueueCreate(1, sizeof(MarketData));
statusQueue = xQueueCreate(4, sizeof(StatusMsg));
cmdQueue = xQueueCreate(8, sizeof(TouchCmd));
tftMutex = xSemaphoreCreateMutex();
// Сеть — на ядре 0 вместе со стеком WiFi; TLS требует большой стек
xTaskCreatePinnedToCore(netTask, "net", 12288, NULL, 1, NULL, 0);
// Отрисовка и тач — на ядре 1, отрисовка с более высоким приоритетом, чтобы UI не подвисал
xTaskCreatePinnedToCore(displayTask, "display", 4096, NULL, 2, NULL, 1);
xTaskCreatePinnedToCore(touchTask, "touch", 4096, NULL, 1, NULL, 1);
}
void loop()
{
// Вся работа в задачах FreeRTOS, стандартный loop не нужен
vTaskDelete(NULL);
}
+11
View File
@@ -0,0 +1,11 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html