commit 9662d1278f5d2727db95339237f4d60f82da2839 Author: Vadim Date: Tue Oct 27 15:41:59 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38c0a02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch +include/secrets.h diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a91ca8f --- /dev/null +++ b/.vscode/extensions.json @@ -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" + ] +} diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -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 diff --git a/include/secrets.h.example b/include/secrets.h.example new file mode 100644 index 0000000..6e32eb6 --- /dev/null +++ b/include/secrets.h.example @@ -0,0 +1,5 @@ +// Скопируй в secrets.h и впиши свои данные. secrets.h не попадает в git. +#pragma once + +#define WIFI_SSID "your-ssid" +#define WIFI_PASSWORD "your-password" diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -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 +#include + +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 diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..94e2fca --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..7fbf767 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,502 @@ +#include +#include +#include +#include +#include +#include +#include + +#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() != 0) + { + snprintf(fetchError, sizeof(fetchError), "retCode %d", doc["retCode"].as()); + Serial.printf("Bybit %s: %s\n", fetchError, doc["retMsg"].as()); + 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(), k[2].as(), k[3].as(), k[4].as()}; + } + 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(); + data.change24hPct = t["price24hPcnt"].as() * 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); +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -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