From 6e5975dd4b65f70337661101d995092b76c47fdf Mon Sep 17 00:00:00 2001 From: Chad Mercer Date: Sat, 30 May 2026 20:26:01 -0500 Subject: [PATCH] Initial import --- docs/README.md | 125 +++ include/lgfx_config.hpp | 108 ++ include/lv_conf.h | 145 +++ lv_conf.h | 145 +++ platformio.ini | 30 + src/lv_conf.h | 145 +++ src/main.cpp | 2251 +++++++++++++++++++++++++++++++++++++++ src/mqtt_module.cpp | 132 +++ src/mqtt_module.h | 46 + 9 files changed, 3127 insertions(+) create mode 100644 docs/README.md create mode 100644 include/lgfx_config.hpp create mode 100644 include/lv_conf.h create mode 100644 lv_conf.h create mode 100644 platformio.ini create mode 100644 src/lv_conf.h create mode 100644 src/main.cpp create mode 100644 src/mqtt_module.cpp create mode 100644 src/mqtt_module.h diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b722d7f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,125 @@ +# Bitcoin Dashboard for Elecrow 7-inch HMI Display + +A real-time financial dashboard displaying cryptocurrency, precious metals, stocks, and market indices on the Elecrow 7.0-inch ESP32 HMI Display. + +## Hardware + +- **Display**: Elecrow 7.0-inch ESP32 HMI Display (Advance series) +- **Resolution**: 800x480 RGB parallel interface +- **Touch**: GT911 capacitive touch controller +- **MCU**: ESP32-S3 with N4R8 module (4MB Flash, 8MB PSRAM) + +## Features + +- Real-time price data from Yahoo Finance API +- Auto-refresh every 5 minutes +- Touch to manual refresh +- WiFi credentials stored in persistent memory +- Antialiased fonts (DejaVu family) + +## Displayed Assets + +| Column | Assets | +|--------|--------| +| Hard Money | Bitcoin (BTC/USD), Gold, Silver | +| Stocks | MSTR, STRC | +| Indices | NASDAQ, DOW, S&P 500 | + +## Color Scheme + +| Asset | Color | Hex | +|-------|-------|-----| +| Bitcoin | Orange | `#F7931A` | +| Gold | Gold | `#FFD700` | +| Silver | Silver | `#C0C0C0` | +| Stocks/Indices | Dollar Green | `#00A86B` | +| Status Bar | Bitcoin Orange | `#F7931A` | + +## Software Stack + +- **Framework**: Arduino (PlatformIO) +- **Display Library**: LovyanGFX v1.2.19 +- **Fonts**: DejaVu (antialiased) + +## Building + +```bash +cd /home/chad/Documents/PlatformIO/Projects/bitcoin_dashboard +pio run -t upload +``` + +## PlatformIO Configuration + +- **Platform**: espressif32@6.8.1 +- **Board**: esp32-s3-devkitc-1 +- **Partition**: huge_app.csv (3MB app, no OTA) +- **Flash Mode**: QIO @ 80MHz +- **PSRAM**: OPI (8MB) + +## Data Source + +Yahoo Finance API: +``` +https://query1.finance.yahoo.com/v8/finance/chart/{SYMBOL}?interval=1d&range=1d +``` + +### Symbol Mapping + +| Display | Yahoo Symbol | +|---------|--------------| +| Bitcoin | BTC-USD | +| Gold | GC=F | +| Silver | SI=F | +| MSTR | MSTR | +| STRC | STRC | +| NASDAQ | ^IXIC | +| DOW | ^DJI | +| S&P 500 | ^GSPC | + +## Pin Configuration + +### RGB Display Interface + +| Signal | GPIO | +|--------|------| +| D0-D15 | 15, 7, 6, 5, 4, 9, 46, 3, 8, 16, 1, 14, 21, 47, 48, 45 | +| HSYNC | GPIO 39 | +| VSYNC | GPIO 40 | +| DE | GPIO 41 | +| PCLK | GPIO 0 | +| Backlight | GPIO 2 | + +### Touch Controller (GT911) + +| Signal | GPIO | +|--------|------| +| SDA | GPIO 19 | +| SCL | GPIO 20 | +| I2C Address | 0x14 | + +## WiFi Setup + +WiFi credentials are stored in ESP32 preferences (persistent storage): + +```cpp +preferences.begin("wifi", true); +preferences.putString("ssid", "your_ssid"); +preferences.putString("password", "your_password"); +preferences.end(); +``` + +## Project Structure + +``` +bitcoin_dashboard/ +├── src/ +│ └── main.cpp # Main application +├── docs/ +│ └── README.md # This file +├── platformio.ini # PlatformIO configuration +└── .gitignore +``` + +## License + +MIT diff --git a/include/lgfx_config.hpp b/include/lgfx_config.hpp new file mode 100644 index 0000000..8ace796 --- /dev/null +++ b/include/lgfx_config.hpp @@ -0,0 +1,108 @@ +/** + * LGFX Display Configuration for Elecrow 7-inch HMI Display + * Based on working Arduino example + */ + +#ifndef LGFX_CONFIG_HPP +#define LGFX_CONFIG_HPP + +#define LGFX_USE_V1 +#include +#include +#include +#include + +class LGFX : public lgfx::LGFX_Device { +public: + lgfx::Bus_RGB _bus_instance; + lgfx::Panel_RGB _panel_instance; + lgfx::Light_PWM _light_instance; + lgfx::Touch_GT911 _touch_instance; + + LGFX(void) { + { + auto cfg = _panel_instance.config(); + cfg.memory_width = 800; + cfg.memory_height = 480; + cfg.panel_width = 800; + cfg.panel_height = 480; + cfg.offset_x = 0; + cfg.offset_y = 0; + _panel_instance.config(cfg); + } + + { + auto cfg = _bus_instance.config(); + cfg.panel = &_panel_instance; + + cfg.pin_d0 = GPIO_NUM_15; + cfg.pin_d1 = GPIO_NUM_7; + cfg.pin_d2 = GPIO_NUM_6; + cfg.pin_d3 = GPIO_NUM_5; + cfg.pin_d4 = GPIO_NUM_4; + cfg.pin_d5 = GPIO_NUM_9; + cfg.pin_d6 = GPIO_NUM_46; + cfg.pin_d7 = GPIO_NUM_3; + cfg.pin_d8 = GPIO_NUM_8; + cfg.pin_d9 = GPIO_NUM_16; + cfg.pin_d10 = GPIO_NUM_1; + cfg.pin_d11 = GPIO_NUM_14; + cfg.pin_d12 = GPIO_NUM_21; + cfg.pin_d13 = GPIO_NUM_47; + cfg.pin_d14 = GPIO_NUM_48; + cfg.pin_d15 = GPIO_NUM_45; + + cfg.pin_henable = GPIO_NUM_41; + cfg.pin_vsync = GPIO_NUM_40; + cfg.pin_hsync = GPIO_NUM_39; + cfg.pin_pclk = GPIO_NUM_0; + cfg.freq_write = 12000000; + + cfg.hsync_polarity = 0; + cfg.hsync_front_porch = 40; + cfg.hsync_pulse_width = 48; + cfg.hsync_back_porch = 40; + + cfg.vsync_polarity = 0; + cfg.vsync_front_porch = 1; + cfg.vsync_pulse_width = 31; + cfg.vsync_back_porch = 13; + + cfg.pclk_active_neg = 1; + cfg.de_idle_high = 0; + cfg.pclk_idle_high = 0; + + _bus_instance.config(cfg); + } + _panel_instance.setBus(&_bus_instance); + + { + auto cfg = _light_instance.config(); + cfg.pin_bl = GPIO_NUM_2; + _light_instance.config(cfg); + } + _panel_instance.light(&_light_instance); + + { + auto cfg = _touch_instance.config(); + cfg.x_min = 0; + cfg.x_max = 799; + cfg.y_min = 0; + cfg.y_max = 479; + cfg.pin_int = -1; + cfg.pin_rst = -1; + cfg.bus_shared = false; + cfg.offset_rotation = 0; + cfg.i2c_port = I2C_NUM_1; + cfg.pin_sda = GPIO_NUM_19; + cfg.pin_scl = GPIO_NUM_20; + cfg.freq = 400000; + cfg.i2c_addr = 0x14; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + setPanel(&_panel_instance); + } +}; + +#endif // LGFX_CONFIG_HPP diff --git a/include/lv_conf.h b/include/lv_conf.h new file mode 100644 index 0000000..1eac01b --- /dev/null +++ b/include/lv_conf.h @@ -0,0 +1,145 @@ +/** + * LVGL Configuration for Bitcoin Dashboard + * Target: Elecrow 7-inch HMI Display (800x480) + */ + +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +#define LV_COLOR_DEPTH 16 +#define LV_COLOR_16_SWAP 0 + +#define LV_HOR_RES_MAX 800 +#define LV_VER_RES_MAX 480 + +#define LV_DPI_DEF 100 + +#define LV_DISP_DEF_REFR_PERIOD 16 + +#define LV_TICK_CUSTOM 1 +#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" +#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) + +#define LV_MEM_CUSTOM 1 +#define LV_MEM_CUSTOM_INCLUDE "Arduino.h" +#define LV_MEM_CUSTOM_ALLOC malloc +#define LV_MEM_CUSTOM_FREE free + +#define LV_MEM_SIZE (64 * 1024U) +#define LV_MEM_ADR 0 + +#define LV_DRAW_CPU_ARM_ALIGN 4 + +#define LV_USE_DRAW_MASKS 1 +#define LV_USE_DRAW_SW 1 +#define LV_DRAW_SW_DRAW_UNIT_CNT 2 +#define LV_USE_DRAW_SW_COMPLEX_GRADIENTS 1 +#define LV_USE_DRAW_SW_SHADOW 1 +#define LV_USE_DRAW_SW_BLUR 1 +#define LV_USE_DRAW_SW_ARGB_TO_RGB565 1 + +#define LV_USE_ASSERT_NULL 1 +#define LV_USE_ASSERT_MALLOC 1 + +#define LV_USE_MSG 1 +#define LV_USE_FS_STDIO 0 +#define LV_USE_FS_FATFS 0 +#define LV_USE_FS_LITTLEFS 0 +#define LV_USE_LODEPNG 0 +#define LV_USE_LIBPNG 0 +#define LV_USE_BMP 0 +#define LV_USE_RLE 0 +#define LV_USE_QRCODE 0 +#define LV_USE_BARCODE 0 +#define LV_USE_TJPGD 0 +#define LV_USE_GIF 0 + +#define LV_USE_FLEX 1 +#define LV_USE_GRID 1 + +#define LV_USE_SNAPSHOT 1 +#define LV_USE_SYSMON 0 +#define LV_USE_PERF_MONITOR 0 +#define LV_USE_MEM_MONITOR 0 + +#define LV_THEME_DEFAULT_DARK 0 +#define LV_THEME_DEFAULT_GROW 1 + +#define LV_USE_THEME_DEFAULT 1 +#define LV_USE_THEME_BASIC 1 +#define LV_USE_THEME_MONO 1 + +#define LV_USE_ARC 1 +#define LV_USE_BAR 1 +#define LV_USE_BTN 1 +#define LV_USE_BTNMATRIX 1 +#define LV_USE_CALENDAR 1 +#define LV_USE_CANVAS 1 +#define LV_USE_CHART 1 +#define LV_USE_CHECKBOX 1 +#define LV_USE_DROPDOWN 1 +#define LV_USE_IMG 1 +#define LV_USE_IMGBTN 1 +#define LV_USE_KEYBOARD 1 +#define LV_USE_LABEL 1 +#define LV_LABEL_TEXT_SELECTION 1 +#define LV_LABEL_LONG_TXT_HINT 1 +#define LV_USE_LED 1 +#define LV_USE_LINE 1 +#define LV_USE_LIST 1 +#define LV_USE_MENU 1 +#define LV_USE_METER 1 +#define LV_USE_MSGBOX 1 +#define LV_USE_ROLLER 1 +#define LV_USE_SCALE 1 +#define LV_USE_SLIDER 1 +#define LV_USE_SPAN 1 +#define LV_USE_SPINBOX 1 +#define LV_USE_SPINNER 1 +#define LV_USE_SWITCH 1 +#define LV_USE_TABVIEW 1 +#define LV_USE_TABLE 1 +#define LV_USE_TEXTAREA 1 +#define LV_USE_TILEVIEW 1 +#define LV_USE_WIN 1 + +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_22 1 +#define LV_FONT_MONTSERRAT_24 1 +#define LV_FONT_MONTSERRAT_26 1 +#define LV_FONT_MONTSERRAT_28 1 +#define LV_FONT_MONTSERRAT_30 1 +#define LV_FONT_MONTSERRAT_32 1 +#define LV_FONT_MONTSERRAT_34 1 +#define LV_FONT_MONTSERRAT_36 1 + +#define LV_FONT_FMT_TXT_LARGE 1 +#define LV_USE_FONT_COMPRESSED 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +#define LV_TXT_ENC_UTF8 1 +#define LV_TXT_BREAK_CHARS " ,.;:-_)]}" +#define LV_TXT_LINE_BREAK_LONG_LEN 12 +#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3 +#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3 +#define LV_TXT_COLOR_CMD "#" + +#define LV_USE_INDEV_SCROLLBAR 1 +#define LV_USE_INDEV_BUTTON 1 +#define LV_USE_INDEV_KEYPAD 1 +#define LV_USE_INDEV_ENCODER 1 + +#define LV_USE_GROUP 1 + +#define LV_USE_ANIM 1 +#define LV_ANIM_HOLD_COUNT 1 + +#define LV_GRADIENT_MAX_STOPS 2 + +#endif // LV_CONF_H diff --git a/lv_conf.h b/lv_conf.h new file mode 100644 index 0000000..1eac01b --- /dev/null +++ b/lv_conf.h @@ -0,0 +1,145 @@ +/** + * LVGL Configuration for Bitcoin Dashboard + * Target: Elecrow 7-inch HMI Display (800x480) + */ + +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +#define LV_COLOR_DEPTH 16 +#define LV_COLOR_16_SWAP 0 + +#define LV_HOR_RES_MAX 800 +#define LV_VER_RES_MAX 480 + +#define LV_DPI_DEF 100 + +#define LV_DISP_DEF_REFR_PERIOD 16 + +#define LV_TICK_CUSTOM 1 +#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" +#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) + +#define LV_MEM_CUSTOM 1 +#define LV_MEM_CUSTOM_INCLUDE "Arduino.h" +#define LV_MEM_CUSTOM_ALLOC malloc +#define LV_MEM_CUSTOM_FREE free + +#define LV_MEM_SIZE (64 * 1024U) +#define LV_MEM_ADR 0 + +#define LV_DRAW_CPU_ARM_ALIGN 4 + +#define LV_USE_DRAW_MASKS 1 +#define LV_USE_DRAW_SW 1 +#define LV_DRAW_SW_DRAW_UNIT_CNT 2 +#define LV_USE_DRAW_SW_COMPLEX_GRADIENTS 1 +#define LV_USE_DRAW_SW_SHADOW 1 +#define LV_USE_DRAW_SW_BLUR 1 +#define LV_USE_DRAW_SW_ARGB_TO_RGB565 1 + +#define LV_USE_ASSERT_NULL 1 +#define LV_USE_ASSERT_MALLOC 1 + +#define LV_USE_MSG 1 +#define LV_USE_FS_STDIO 0 +#define LV_USE_FS_FATFS 0 +#define LV_USE_FS_LITTLEFS 0 +#define LV_USE_LODEPNG 0 +#define LV_USE_LIBPNG 0 +#define LV_USE_BMP 0 +#define LV_USE_RLE 0 +#define LV_USE_QRCODE 0 +#define LV_USE_BARCODE 0 +#define LV_USE_TJPGD 0 +#define LV_USE_GIF 0 + +#define LV_USE_FLEX 1 +#define LV_USE_GRID 1 + +#define LV_USE_SNAPSHOT 1 +#define LV_USE_SYSMON 0 +#define LV_USE_PERF_MONITOR 0 +#define LV_USE_MEM_MONITOR 0 + +#define LV_THEME_DEFAULT_DARK 0 +#define LV_THEME_DEFAULT_GROW 1 + +#define LV_USE_THEME_DEFAULT 1 +#define LV_USE_THEME_BASIC 1 +#define LV_USE_THEME_MONO 1 + +#define LV_USE_ARC 1 +#define LV_USE_BAR 1 +#define LV_USE_BTN 1 +#define LV_USE_BTNMATRIX 1 +#define LV_USE_CALENDAR 1 +#define LV_USE_CANVAS 1 +#define LV_USE_CHART 1 +#define LV_USE_CHECKBOX 1 +#define LV_USE_DROPDOWN 1 +#define LV_USE_IMG 1 +#define LV_USE_IMGBTN 1 +#define LV_USE_KEYBOARD 1 +#define LV_USE_LABEL 1 +#define LV_LABEL_TEXT_SELECTION 1 +#define LV_LABEL_LONG_TXT_HINT 1 +#define LV_USE_LED 1 +#define LV_USE_LINE 1 +#define LV_USE_LIST 1 +#define LV_USE_MENU 1 +#define LV_USE_METER 1 +#define LV_USE_MSGBOX 1 +#define LV_USE_ROLLER 1 +#define LV_USE_SCALE 1 +#define LV_USE_SLIDER 1 +#define LV_USE_SPAN 1 +#define LV_USE_SPINBOX 1 +#define LV_USE_SPINNER 1 +#define LV_USE_SWITCH 1 +#define LV_USE_TABVIEW 1 +#define LV_USE_TABLE 1 +#define LV_USE_TEXTAREA 1 +#define LV_USE_TILEVIEW 1 +#define LV_USE_WIN 1 + +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_22 1 +#define LV_FONT_MONTSERRAT_24 1 +#define LV_FONT_MONTSERRAT_26 1 +#define LV_FONT_MONTSERRAT_28 1 +#define LV_FONT_MONTSERRAT_30 1 +#define LV_FONT_MONTSERRAT_32 1 +#define LV_FONT_MONTSERRAT_34 1 +#define LV_FONT_MONTSERRAT_36 1 + +#define LV_FONT_FMT_TXT_LARGE 1 +#define LV_USE_FONT_COMPRESSED 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +#define LV_TXT_ENC_UTF8 1 +#define LV_TXT_BREAK_CHARS " ,.;:-_)]}" +#define LV_TXT_LINE_BREAK_LONG_LEN 12 +#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3 +#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3 +#define LV_TXT_COLOR_CMD "#" + +#define LV_USE_INDEV_SCROLLBAR 1 +#define LV_USE_INDEV_BUTTON 1 +#define LV_USE_INDEV_KEYPAD 1 +#define LV_USE_INDEV_ENCODER 1 + +#define LV_USE_GROUP 1 + +#define LV_USE_ANIM 1 +#define LV_ANIM_HOLD_COUNT 1 + +#define LV_GRADIENT_MAX_STOPS 2 + +#endif // LV_CONF_H diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..b3cd5b8 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,30 @@ +; PlatformIO Project Configuration File +; Bitcoin Dashboard for Elecrow 7-inch HMI Display +; Based on working Arduino example + +[env:elecrow_7inch_hmi] +platform = espressif32@6.8.1 +board = esp32-s3-devkitc-1 +framework = arduino + +; Libraries +lib_deps = + lovyan03/LovyanGFX@^1.1.8 + +monitor_speed = 115200 + +; Critical settings for the N4R8 module (from working example) +board_upload.flash_size = 4MB +board_build.arduino.memory_type = qio_opi +board_build.f_flash = 80000000L +board_build.flash_mode = qio + +; Use Huge APP partition (3MB No OTA/1MB SPIFFS) +board_build.partitions = huge_app.csv + +; Required build flags for S3 display stability +build_flags = + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue diff --git a/src/lv_conf.h b/src/lv_conf.h new file mode 100644 index 0000000..1eac01b --- /dev/null +++ b/src/lv_conf.h @@ -0,0 +1,145 @@ +/** + * LVGL Configuration for Bitcoin Dashboard + * Target: Elecrow 7-inch HMI Display (800x480) + */ + +#ifndef LV_CONF_H +#define LV_CONF_H + +#include + +#define LV_COLOR_DEPTH 16 +#define LV_COLOR_16_SWAP 0 + +#define LV_HOR_RES_MAX 800 +#define LV_VER_RES_MAX 480 + +#define LV_DPI_DEF 100 + +#define LV_DISP_DEF_REFR_PERIOD 16 + +#define LV_TICK_CUSTOM 1 +#define LV_TICK_CUSTOM_INCLUDE "Arduino.h" +#define LV_TICK_CUSTOM_SYS_TIME_EXPR (millis()) + +#define LV_MEM_CUSTOM 1 +#define LV_MEM_CUSTOM_INCLUDE "Arduino.h" +#define LV_MEM_CUSTOM_ALLOC malloc +#define LV_MEM_CUSTOM_FREE free + +#define LV_MEM_SIZE (64 * 1024U) +#define LV_MEM_ADR 0 + +#define LV_DRAW_CPU_ARM_ALIGN 4 + +#define LV_USE_DRAW_MASKS 1 +#define LV_USE_DRAW_SW 1 +#define LV_DRAW_SW_DRAW_UNIT_CNT 2 +#define LV_USE_DRAW_SW_COMPLEX_GRADIENTS 1 +#define LV_USE_DRAW_SW_SHADOW 1 +#define LV_USE_DRAW_SW_BLUR 1 +#define LV_USE_DRAW_SW_ARGB_TO_RGB565 1 + +#define LV_USE_ASSERT_NULL 1 +#define LV_USE_ASSERT_MALLOC 1 + +#define LV_USE_MSG 1 +#define LV_USE_FS_STDIO 0 +#define LV_USE_FS_FATFS 0 +#define LV_USE_FS_LITTLEFS 0 +#define LV_USE_LODEPNG 0 +#define LV_USE_LIBPNG 0 +#define LV_USE_BMP 0 +#define LV_USE_RLE 0 +#define LV_USE_QRCODE 0 +#define LV_USE_BARCODE 0 +#define LV_USE_TJPGD 0 +#define LV_USE_GIF 0 + +#define LV_USE_FLEX 1 +#define LV_USE_GRID 1 + +#define LV_USE_SNAPSHOT 1 +#define LV_USE_SYSMON 0 +#define LV_USE_PERF_MONITOR 0 +#define LV_USE_MEM_MONITOR 0 + +#define LV_THEME_DEFAULT_DARK 0 +#define LV_THEME_DEFAULT_GROW 1 + +#define LV_USE_THEME_DEFAULT 1 +#define LV_USE_THEME_BASIC 1 +#define LV_USE_THEME_MONO 1 + +#define LV_USE_ARC 1 +#define LV_USE_BAR 1 +#define LV_USE_BTN 1 +#define LV_USE_BTNMATRIX 1 +#define LV_USE_CALENDAR 1 +#define LV_USE_CANVAS 1 +#define LV_USE_CHART 1 +#define LV_USE_CHECKBOX 1 +#define LV_USE_DROPDOWN 1 +#define LV_USE_IMG 1 +#define LV_USE_IMGBTN 1 +#define LV_USE_KEYBOARD 1 +#define LV_USE_LABEL 1 +#define LV_LABEL_TEXT_SELECTION 1 +#define LV_LABEL_LONG_TXT_HINT 1 +#define LV_USE_LED 1 +#define LV_USE_LINE 1 +#define LV_USE_LIST 1 +#define LV_USE_MENU 1 +#define LV_USE_METER 1 +#define LV_USE_MSGBOX 1 +#define LV_USE_ROLLER 1 +#define LV_USE_SCALE 1 +#define LV_USE_SLIDER 1 +#define LV_USE_SPAN 1 +#define LV_USE_SPINBOX 1 +#define LV_USE_SPINNER 1 +#define LV_USE_SWITCH 1 +#define LV_USE_TABVIEW 1 +#define LV_USE_TABLE 1 +#define LV_USE_TEXTAREA 1 +#define LV_USE_TILEVIEW 1 +#define LV_USE_WIN 1 + +#define LV_FONT_MONTSERRAT_12 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_16 1 +#define LV_FONT_MONTSERRAT_18 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_22 1 +#define LV_FONT_MONTSERRAT_24 1 +#define LV_FONT_MONTSERRAT_26 1 +#define LV_FONT_MONTSERRAT_28 1 +#define LV_FONT_MONTSERRAT_30 1 +#define LV_FONT_MONTSERRAT_32 1 +#define LV_FONT_MONTSERRAT_34 1 +#define LV_FONT_MONTSERRAT_36 1 + +#define LV_FONT_FMT_TXT_LARGE 1 +#define LV_USE_FONT_COMPRESSED 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +#define LV_TXT_ENC_UTF8 1 +#define LV_TXT_BREAK_CHARS " ,.;:-_)]}" +#define LV_TXT_LINE_BREAK_LONG_LEN 12 +#define LV_TXT_LINE_BREAK_LONG_PRE_MIN_LEN 3 +#define LV_TXT_LINE_BREAK_LONG_POST_MIN_LEN 3 +#define LV_TXT_COLOR_CMD "#" + +#define LV_USE_INDEV_SCROLLBAR 1 +#define LV_USE_INDEV_BUTTON 1 +#define LV_USE_INDEV_KEYPAD 1 +#define LV_USE_INDEV_ENCODER 1 + +#define LV_USE_GROUP 1 + +#define LV_USE_ANIM 1 +#define LV_ANIM_HOLD_COUNT 1 + +#define LV_GRADIENT_MAX_STOPS 2 + +#endif // LV_CONF_H diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..5a7ef1f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,2251 @@ +/** +* Bitcoin Dashboard for Elecrow 7-inch HMI Display +* Three-column layout with antialiased fonts +* Customization screen for stocks and indices +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "mqtt_module.h" + +// Global MQTT module instance +MQTTModule mqttModule; + +#define SCREEN_WIDTH 800 +#define SCREEN_HEIGHT 480 + +#define TFT_WHITE 0xFFFF +#define TFT_BLACK 0x0000 +#define TFT_ORANGE 0xFD20 +#define TFT_GOLD 0xFEA0 +#define TFT_SILVER 0xC618 +#define TFT_GREEN 0x07E0 +#define TFT_DARKGRAY 0x2104 +#define TFT_GRAY 0x8410 + +#define MAX_STOCKS 5 +#define MAX_INDICES 5 +#define MAX_TICKER_LEN 8 + +#define FINNHUB_API_KEY "d74t3bpr01qg1eo6pln0d74t3bpr01qg1eo6plng" + +class LGFX : public lgfx::LGFX_Device { +public: + lgfx::Bus_RGB _bus_instance; + lgfx::Panel_RGB _panel_instance; + lgfx::Light_PWM _light_instance; + lgfx::Touch_GT911 _touch_instance; + + LGFX(void) { + { + auto cfg = _panel_instance.config(); + cfg.memory_width = 800; + cfg.memory_height = 480; + cfg.panel_width = 800; + cfg.panel_height = 480; + cfg.offset_x = 0; + cfg.offset_y = 0; + _panel_instance.config(cfg); + } + + { + auto cfg = _bus_instance.config(); + cfg.panel = &_panel_instance; + cfg.pin_d0 = GPIO_NUM_15; + cfg.pin_d1 = GPIO_NUM_7; + cfg.pin_d2 = GPIO_NUM_6; + cfg.pin_d3 = GPIO_NUM_5; + cfg.pin_d4 = GPIO_NUM_4; + cfg.pin_d5 = GPIO_NUM_9; + cfg.pin_d6 = GPIO_NUM_46; + cfg.pin_d7 = GPIO_NUM_3; + cfg.pin_d8 = GPIO_NUM_8; + cfg.pin_d9 = GPIO_NUM_16; + cfg.pin_d10 = GPIO_NUM_1; + cfg.pin_d11 = GPIO_NUM_14; + cfg.pin_d12 = GPIO_NUM_21; + cfg.pin_d13 = GPIO_NUM_47; + cfg.pin_d14 = GPIO_NUM_48; + cfg.pin_d15 = GPIO_NUM_45; + cfg.pin_henable = GPIO_NUM_41; + cfg.pin_vsync = GPIO_NUM_40; + cfg.pin_hsync = GPIO_NUM_39; + cfg.pin_pclk = GPIO_NUM_0; + cfg.freq_write = 12000000; + cfg.hsync_polarity = 0; + cfg.hsync_front_porch = 40; + cfg.hsync_pulse_width = 48; + cfg.hsync_back_porch = 40; + cfg.vsync_polarity = 0; + cfg.vsync_front_porch = 1; + cfg.vsync_pulse_width = 31; + cfg.vsync_back_porch = 13; + cfg.pclk_active_neg = 1; + cfg.de_idle_high = 0; + cfg.pclk_idle_high = 0; + _bus_instance.config(cfg); + } + _panel_instance.setBus(&_bus_instance); + + { + auto cfg = _light_instance.config(); + cfg.pin_bl = GPIO_NUM_2; + _light_instance.config(cfg); + } + _panel_instance.light(&_light_instance); + + { + auto cfg = _touch_instance.config(); + cfg.x_min = 0; + cfg.x_max = 799; + cfg.y_min = 0; + cfg.y_max = 479; + cfg.pin_int = -1; + cfg.pin_rst = -1; + cfg.bus_shared = false; + cfg.offset_rotation = 0; + cfg.i2c_port = I2C_NUM_1; + cfg.pin_sda = GPIO_NUM_19; + cfg.pin_scl = GPIO_NUM_20; + cfg.freq = 400000; + cfg.i2c_addr = 0x14; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + setPanel(&_panel_instance); + } +}; + +static LGFX lcd; +static Preferences preferences; + +double btcPrice = 0, goldPrice = 0, silverPrice = 0; +double stockPrices[MAX_STOCKS] = {0}; +double indexPrices[MAX_INDICES] = {0}; + +char stockTickers[MAX_STOCKS][MAX_TICKER_LEN + 1] = {"MSTR", "STRC", "", "", ""}; +char indexTickers[MAX_INDICES][MAX_TICKER_LEN + 1] = {"^IXIC", "^DJI", "^GSPC", "", ""}; +bool indexSelected[MAX_INDICES] = {true, true, true, false, false}; + +const char* availableIndices[] = { + "^GSPC", "^DJI", "^IXIC", "^RUT", "^VIX", + "^FTSE", "^GDAXI", "^FCHI", "^N225", "^HSI", + "^AXJO", "^KS11", "^TWII", "^STI", "^MXX" +}; +const char* indexNames[] = { + "S&P 500", "Dow Jones", "NASDAQ", "Russell 2000", "VIX", + "FTSE 100", "DAX", "CAC 40", "Nikkei 225", "Hang Seng", + "ASX 200", "KOSPI", "Taiwan Weighted", "Straits Times", "IPC Mexico" +}; +const int numAvailableIndices = 15; + +const char* getIndexFriendlyName(const char* ticker) { + for (int i = 0; i < numAvailableIndices; i++) { + if (strcmp(ticker, availableIndices[i]) == 0) { + return indexNames[i]; + } + } + return ticker; +} + +unsigned long lastRefresh = 0; +const unsigned long REFRESH_INTERVAL = 180000; +const unsigned long HISTORY_REFRESH_INTERVAL = 3600000; +const unsigned long WIFI_CONNECT_TIMEOUT = 15000; + +bool wifiConnecting = false; +unsigned long wifiConnectStartTime = 0; + +bool timeSynced = false; +bool firstBoot = true; +const char* ntpServer = "pool.ntp.org"; +bool displayFlipped = false; + +#define HISTORY_DAYS 30 +double btcHistory[HISTORY_DAYS] = {0}; +double goldHistory[HISTORY_DAYS] = {0}; +double silverHistory[HISTORY_DAYS] = {0}; +bool btcHistoryLoaded = false; +bool goldHistoryLoaded = false; +bool silverHistoryLoaded = false; +unsigned long lastHistoryRefresh = 0; + +#define HM_HISTORY_MAX 100 +double btcHmHistory[HM_HISTORY_MAX] = {0}; +double goldHmHistory[HM_HISTORY_MAX] = {0}; +double silverHmHistory[HM_HISTORY_MAX] = {0}; +int btcHmCount = 0; +int goldHmCount = 0; +int silverHmCount = 0; +bool btcHmLoaded = false; +bool goldHmLoaded = false; +bool silverHmLoaded = false; +double btcHighDay = 0, btcLowDay = 0, btcHighMonth = 0, btcLowMonth = 0, btcHighYear = 0, btcLowYear = 0; +double goldHighDay = 0, goldLowDay = 0, goldHighMonth = 0, goldLowMonth = 0, goldHighYear = 0, goldLowYear = 0; +double silverHighDay = 0, silverLowDay = 0, silverHighMonth = 0, silverLowMonth = 0, silverHighYear = 0, silverLowYear = 0; +int hmSparklinePeriod = 2; +bool hmNeedsRefresh = false; +unsigned long hmLastFetch = 0; +const unsigned long HM_FETCH_INTERVAL_DAILY = 180000; +const unsigned long HM_FETCH_INTERVAL_HISTORICAL = 86400000; +bool hmHighLowInitialized = false; +bool hmFirstLoad = true; + +enum ScreenState { + SCREEN_DASHBOARD, + SCREEN_CUSTOMIZE_STOCKS, + SCREEN_CUSTOMIZE_INDICES, + SCREEN_WIFI_CONFIG, + SCREEN_WIFI_PASSWORD, + SCREEN_HARD_MONEY_DETAIL +}; +ScreenState currentScreen = SCREEN_DASHBOARD; + +int selectedStockSlot = -1; +int selectedIndexSlot = -1; +char keyboardBuffer[64] = ""; +int keyboardPos = 0; +int indexScrollOffset = 0; +bool needsRefresh = false; + +#define MAX_SCAN_NETWORKS 15 +String scannedSSIDs[MAX_SCAN_NETWORKS]; +int scannedCount = 0; +int wifiScrollOffset = 0; +bool wifiScanDone = false; +String selectedSSID = ""; +String wifiPassword = ""; +bool keyboardShift = false; +bool keyboardCaps = false; +bool keyboardSymbols = false; +bool showPasswordPlain = false; + +String formatPriceUS(double price, int decimals = 2, bool includeDollar = true) { + if (price <= 0) return "..."; + + String result = ""; + long longPrice = (long)price; + String intStr = String(longPrice); + int len = intStr.length(); + + for (int i = 0; i < len; i++) { + if (i > 0 && (len - i) % 3 == 0) { + result += ","; + } + result += intStr[i]; + } + + if (decimals > 0) { + result += "."; + double frac = price - longPrice; + for (int d = 0; d < decimals; d++) frac *= 10; + long fracLong = (long)(frac + 0.5); + String fracStr = String(fracLong); + while (fracStr.length() < decimals) fracStr = "0" + fracStr; + result += fracStr; + } + + return includeDollar ? "$" + result : result; +} + +bool fetchPriceYahoo(const char* symbol, double* result) { + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + String url = "https://query1.finance.yahoo.com/v8/finance/chart/" + String(symbol) + "?interval=1d&range=1d"; + + if (!http.begin(client, url)) return false; + + http.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + http.setTimeout(15000); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + http.end(); + return false; + } + + String payload = http.getString(); + http.end(); + + String searchStr = "\"regularMarketPrice\":"; + int idx = payload.indexOf(searchStr); + if (idx == -1) return false; + + int start = idx + searchStr.length(); + int end = payload.indexOf(",", start); + if (end == -1) end = payload.indexOf("}", start); + if (end <= start) return false; + + String priceStr = payload.substring(start, end); + *result = priceStr.toDouble(); + return true; +} + +bool fetchPriceFinnhub(const char* symbol, double* result) { + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + String url = "https://finnhub.io/api/v1/quote?symbol=" + String(symbol) + "&token=" + String(FINNHUB_API_KEY); + + if (!http.begin(client, url)) return false; + + http.setTimeout(15000); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + http.end(); + return false; + } + + String payload = http.getString(); + http.end(); + + int idx = payload.indexOf("\"c\":"); + if (idx == -1) return false; + + int start = idx + 4; + int end = payload.indexOf(",", start); + if (end == -1) end = payload.indexOf("}", start); + if (end <= start) return false; + + String priceStr = payload.substring(start, end); + *result = priceStr.toDouble(); + return true; +} + +bool fetchHistory(const char* symbol, double* history, bool* loaded) { + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + String url = "https://query1.finance.yahoo.com/v8/finance/chart/" + String(symbol) + "?interval=1d&range=1mo"; + + if (!http.begin(client, url)) return false; + + http.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + http.setTimeout(15000); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + http.end(); + return false; + } + + String payload = http.getString(); + http.end(); + + String arraySearch = "\"close\":["; + int arrayIdx = payload.indexOf(arraySearch); + if (arrayIdx == -1) return false; + + int arrayStart = arrayIdx + arraySearch.length(); + int arrayEnd = payload.indexOf("]", arrayStart); + if (arrayEnd == -1) return false; + + String closeArray = payload.substring(arrayStart, arrayEnd); + + int dayIndex = 0; + int pos = 0; + + while (pos < closeArray.length() && dayIndex < HISTORY_DAYS) { + while (pos < closeArray.length() && (closeArray[pos] == ' ' || closeArray[pos] == ',')) pos++; + if (pos >= closeArray.length()) break; + + int valueStart = pos; + while (pos < closeArray.length() && closeArray[pos] != ',' && closeArray[pos] != ' ') pos++; + + String valueStr = closeArray.substring(valueStart, pos); + + if (valueStr.equals("null") || valueStr.length() == 0) { + pos++; + continue; + } + + double value = valueStr.toDouble(); + if (value > 0) { + history[dayIndex++] = value; + } + pos++; + } + + if (dayIndex > 0) { + *loaded = true; + return true; + } + return false; +} + +void fetchAllHistory() { + fetchHistory("BTC-USD", btcHistory, &btcHistoryLoaded); + fetchHistory("GC=F", goldHistory, &goldHistoryLoaded); + fetchHistory("SI=F", silverHistory, &silverHistoryLoaded); +} + +bool fetchHmHistoryForSymbol(const char* symbol, double* history, int* count, int period) { + const char* interval; + const char* range; + + switch (period) { + case 0: interval = "5m"; range = "5d"; break; + case 1: interval = "1h"; range = "7d"; break; + case 2: interval = "1d"; range = "1mo"; break; + case 3: interval = "1wk"; range = "1y"; break; + case 4: interval = "1mo"; range = "max"; break; + default: interval = "1d"; range = "1mo"; break; + } + + WiFiClientSecure client; + client.setInsecure(); + + HTTPClient http; + String url = "https://query1.finance.yahoo.com/v8/finance/chart/" + String(symbol) + "?interval=" + String(interval) + "&range=" + String(range); + + if (!http.begin(client, url)) return false; + + http.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + http.setTimeout(15000); + + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + http.end(); + return false; + } + + String payload = http.getString(); + http.end(); + + String arraySearch = "\"close\":["; + int arrayIdx = payload.indexOf(arraySearch); + if (arrayIdx == -1) return false; + + int arrayStart = arrayIdx + arraySearch.length(); + int arrayEnd = payload.indexOf("]", arrayStart); + if (arrayEnd == -1) return false; + + String closeArray = payload.substring(arrayStart, arrayEnd); + + *count = 0; + int pos = 0; + + while (pos < closeArray.length() && *count < HM_HISTORY_MAX) { + while (pos < closeArray.length() && (closeArray[pos] == ' ' || closeArray[pos] == ',')) pos++; + if (pos >= closeArray.length()) break; + + int valueStart = pos; + while (pos < closeArray.length() && closeArray[pos] != ',' && closeArray[pos] != ' ') pos++; + + String valueStr = closeArray.substring(valueStart, pos); + + if (!valueStr.equals("null") && valueStr.length() > 0) { + double value = valueStr.toDouble(); + if (value > 0) { + history[*count] = value; + (*count)++; + } + } + pos++; + } + + return *count > 0; +} + +void fetchHmHighLowForSymbol(const char* symbol, double* highDay, double* lowDay, double* highMonth, double* lowMonth, double* highYear, double* lowYear) { + if (WiFi.status() != WL_CONNECTED) return; + + WiFiClientSecure client; + client.setInsecure(); + HTTPClient http; + + // First API call: hourly data for today's intraday high/low + String url = "https://query1.finance.yahoo.com/v8/finance/chart/" + String(symbol) + "?interval=1h&range=1d"; + if (http.begin(client, url)) { + http.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + http.setTimeout(15000); + int httpCode = http.GET(); + if (httpCode == HTTP_CODE_OK) { + String payload = http.getString(); + http.end(); + + String arraySearch = "\"close\":["; + int arrayIdx = payload.indexOf(arraySearch); + if (arrayIdx != -1) { + int arrayStart = arrayIdx + arraySearch.length(); + int arrayEnd = payload.indexOf("]", arrayStart); + if (arrayEnd != -1) { + String closeArray = payload.substring(arrayStart, arrayEnd); + + double minDay = 1e18, maxDay = 0; + int pos = 0; + while (pos < closeArray.length()) { + while (pos < closeArray.length() && (closeArray[pos] == ' ' || closeArray[pos] == ',')) pos++; + if (pos >= closeArray.length()) break; + int valueStart = pos; + while (pos < closeArray.length() && closeArray[pos] != ',' && closeArray[pos] != ' ') pos++; + String valueStr = closeArray.substring(valueStart, pos); + if (!valueStr.equals("null") && valueStr.length() > 0) { + double value = valueStr.toDouble(); + if (value > 0) { + if (value < minDay) minDay = value; + if (value > maxDay) maxDay = value; + } + } + } + if (maxDay > 0) { + *highDay = maxDay; + *lowDay = minDay; + } + } + } + } else { + http.end(); + } + } + + // Second API call: daily data for month and year high/low + url = "https://query1.finance.yahoo.com/v8/finance/chart/" + String(symbol) + "?interval=1d&range=1y"; + if (!http.begin(client, url)) return; + + http.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + http.setTimeout(15000); + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + http.end(); + return; + } + + String payload = http.getString(); + http.end(); + + String arraySearch = "\"close\":["; + int arrayIdx = payload.indexOf(arraySearch); + if (arrayIdx == -1) return; + + int arrayStart = arrayIdx + arraySearch.length(); + int arrayEnd = payload.indexOf("]", arrayStart); + if (arrayEnd == -1) return; + + String closeArray = payload.substring(arrayStart, arrayEnd); + + // First pass: count values and find year high/low + double minYear = 1e18, maxYear = 0; + int valueCount = 0; + int pos = 0; + + while (pos < closeArray.length()) { + while (pos < closeArray.length() && (closeArray[pos] == ' ' || closeArray[pos] == ',')) pos++; + if (pos >= closeArray.length()) break; + int valueStart = pos; + while (pos < closeArray.length() && closeArray[pos] != ',' && closeArray[pos] != ' ') pos++; + String valueStr = closeArray.substring(valueStart, pos); + if (!valueStr.equals("null") && valueStr.length() > 0) { + double value = valueStr.toDouble(); + if (value > 0) { + valueCount++; + if (value < minYear) minYear = value; + if (value > maxYear) maxYear = value; + } + } + } + + if (valueCount > 0) { + *highYear = maxYear; + *lowYear = minYear; + + // Second pass: find month high/low (last 22 trading days) + int monthStart = valueCount > 22 ? valueCount - 22 : 0; + double minMonth = 1e18, maxMonth = 0; + int idx = 0; + pos = 0; + while (pos < closeArray.length()) { + while (pos < closeArray.length() && (closeArray[pos] == ' ' || closeArray[pos] == ',')) pos++; + if (pos >= closeArray.length()) break; + int valueStart = pos; + while (pos < closeArray.length() && closeArray[pos] != ',' && closeArray[pos] != ' ') pos++; + String valueStr = closeArray.substring(valueStart, pos); + if (!valueStr.equals("null") && valueStr.length() > 0) { + double value = valueStr.toDouble(); + if (value > 0) { + if (idx >= monthStart) { + if (value < minMonth) minMonth = value; + if (value > maxMonth) maxMonth = value; + } + idx++; + } + } + } + *highMonth = maxMonth; + *lowMonth = minMonth; + } +} + +bool checkPriceBreaksLimits(double price, double highMonth, double lowMonth) { + if (price <= 0 || highMonth <= 0 || lowMonth <= 0) return false; + return (price > highMonth || price < lowMonth); +} + +void fetchAllHmData(int period) { + btcHmCount = goldHmCount = silverHmCount = 0; + btcHmLoaded = goldHmLoaded = silverHmLoaded = false; + + fetchHmHistoryForSymbol("BTC-USD", btcHmHistory, &btcHmCount, period); + btcHmLoaded = btcHmCount > 0; + + fetchHmHistoryForSymbol("GC=F", goldHmHistory, &goldHmCount, period); + goldHmLoaded = goldHmCount > 0; + + fetchHmHistoryForSymbol("SI=F", silverHmHistory, &silverHmCount, period); + silverHmLoaded = silverHmCount > 0; + + if (!hmHighLowInitialized || hmFirstLoad) { + fetchHmHighLowForSymbol("BTC-USD", &btcHighDay, &btcLowDay, &btcHighMonth, &btcLowMonth, &btcHighYear, &btcLowYear); + fetchHmHighLowForSymbol("GC=F", &goldHighDay, &goldLowDay, &goldHighMonth, &goldLowMonth, &goldHighYear, &goldLowYear); + fetchHmHighLowForSymbol("SI=F", &silverHighDay, &silverLowDay, &silverHighMonth, &silverLowMonth, &silverHighYear, &silverLowYear); + hmHighLowInitialized = true; + hmFirstLoad = false; + } else { + bool btcBreaks = checkPriceBreaksLimits(btcPrice, btcHighMonth, btcLowMonth); + bool goldBreaks = checkPriceBreaksLimits(goldPrice, goldHighMonth, goldLowMonth); + bool silverBreaks = checkPriceBreaksLimits(silverPrice, silverHighMonth, silverLowMonth); + + if (btcBreaks) { + fetchHmHighLowForSymbol("BTC-USD", &btcHighDay, &btcLowDay, &btcHighMonth, &btcLowMonth, &btcHighYear, &btcLowYear); + } + if (goldBreaks) { + fetchHmHighLowForSymbol("GC=F", &goldHighDay, &goldLowDay, &goldHighMonth, &goldLowMonth, &goldHighYear, &goldLowYear); + } + if (silverBreaks) { + fetchHmHighLowForSymbol("SI=F", &silverHighDay, &silverLowDay, &silverHighMonth, &silverLowMonth, &silverHighYear, &silverLowYear); + } + } + + hmLastFetch = millis(); +} + +void drawSparkline(int x, int y, int width, int height, double* history, bool loaded, uint16_t color) { + if (!loaded) return; + + double minVal = history[0]; + double maxVal = history[0]; + int validDays = 0; + + for (int i = 0; i < HISTORY_DAYS; i++) { + if (history[i] > 0) { + if (history[i] < minVal) minVal = history[i]; + if (history[i] > maxVal) maxVal = history[i]; + validDays++; + } + } + + if (validDays < 2 || maxVal <= minVal) return; + + double range = maxVal - minVal; + if (range < 1) range = 1; + + for (int i = 0; i < validDays - 1; i++) { + int x1 = x + (i * width) / (validDays - 1); + int x2 = x + ((i + 1) * width) / (validDays - 1); + int y1 = y + height - (int)((history[i] - minVal) * height / range); + int y2 = y + height - (int)((history[i + 1] - minVal) * height / range); + + lcd.drawLine(x1, y1, x2, y2, color); + } + + int lastX = x + ((validDays - 1) * width) / (validDays - 1); + int lastY = y + height - (int)((history[validDays - 1] - minVal) * height / range); + lcd.drawPixel(lastX, lastY, TFT_WHITE); +} + +void loadConfig() { + preferences.begin("dashboard", true); + + for (int i = 0; i < MAX_STOCKS; i++) { + String key = "stock" + String(i); + String val = preferences.getString(key.c_str(), ""); + if (val.length() > 0 && val.length() <= MAX_TICKER_LEN) { + strcpy(stockTickers[i], val.c_str()); + } + } + + for (int i = 0; i < MAX_INDICES; i++) { + String key = "index" + String(i); + String val = preferences.getString(key.c_str(), ""); + if (val.length() > 0 && val.length() <= MAX_TICKER_LEN) { + strcpy(indexTickers[i], val.c_str()); + indexSelected[i] = true; + } + } + + preferences.end(); +} + +void saveConfig() { + preferences.begin("dashboard", false); + + for (int i = 0; i < MAX_STOCKS; i++) { + String key = "stock" + String(i); + preferences.putString(key.c_str(), stockTickers[i]); + } + + for (int i = 0; i < MAX_INDICES; i++) { + String key = "index" + String(i); + if (indexSelected[i]) { + preferences.putString(key.c_str(), indexTickers[i]); + } else { + preferences.putString(key.c_str(), ""); + } + } + + preferences.end(); +} + +int dayOfWeek(int year, int month, int day) { + if (month < 3) { + month += 12; + year--; + } + return (day + 13 * (month + 1) / 5 + year + year / 4 - year / 100 + year / 400) % 7; +} + +bool isUSDST(int year, int month, int day, int hour) { + int springDOW = dayOfWeek(year, 3, 8); + int springStart = 8 + ((14 - springDOW) % 7); + + int fallDOW = dayOfWeek(year, 11, 1); + int fallEnd = 1 + ((14 - fallDOW) % 7); + + if (month < 3 || month > 11) return false; + if (month > 3 && month < 11) return true; + + if (month == 3) { + if (day < springStart) return false; + if (day > springStart) return true; + return hour >= 2; + } + + if (month == 11) { + if (day < fallEnd) return true; + if (day > fallEnd) return false; + return hour < 2; + } + + return false; +} + +bool isUSMarketOpen() { + if (!timeSynced) return firstBoot; + + time_t now = time(nullptr); + struct tm* utc = gmtime(&now); + + int utcHour = utc->tm_hour; + int utcMinute = utc->tm_min; + int year = utc->tm_year + 1900; + int month = utc->tm_mon + 1; + int day = utc->tm_mday; + int utcWday = utc->tm_wday; + + bool dst = isUSDST(year, month, day, utcHour); + int etOffset = dst ? -4 : -5; + + int etHour = utcHour + etOffset; + int etWday = utcWday; + + if (etHour < 0) { + etHour += 24; + etWday = (etWday + 6) % 7; + } else if (etHour >= 24) { + etHour -= 24; + etWday = (etWday + 1) % 7; + } + + if (etWday == 0 || etWday == 6) return false; + + if (etHour < 9 || etHour >= 16) return false; + if (etHour == 9 && utcMinute < 30) return false; + + return true; +} + +void syncTime() { + configTime(0, 0, ntpServer); + + int retries = 0; + while (time(nullptr) < 100000 && retries < 20) { + delay(500); + retries++; + } + + if (time(nullptr) >= 100000) { + timeSynced = true; + } +} + +void updatePrices() { + if (WiFi.status() != WL_CONNECTED) return; + + fetchPriceFinnhub("BINANCE:BTCUSDT", &btcPrice); + fetchPriceYahoo("GC=F", &goldPrice); + fetchPriceYahoo("SI=F", &silverPrice); + + if (isUSMarketOpen()) { + for (int i = 0; i < MAX_STOCKS; i++) { + if (strlen(stockTickers[i]) > 0) { + fetchPriceFinnhub(stockTickers[i], &stockPrices[i]); + } + } + + for (int i = 0; i < MAX_INDICES; i++) { + if (indexSelected[i] && strlen(indexTickers[i]) > 0) { + fetchPriceYahoo(indexTickers[i], &indexPrices[i]); + } + } + } + + if (firstBoot) { + firstBoot = false; + } +} + +void drawKeyboard(int y) { + const char* rows[] = { + "QWERTYUIOP", + "ASDFGHJKL", + "ZXCVBNM", + "1234567890" + }; + const int numRows = 4; + const int keySize = 32; + const int keyGap = 2; + + for (int r = 0; r < numRows; r++) { + int rowLen = strlen(rows[r]); + int rowWidth = rowLen * (keySize + keyGap) - keyGap; + int startX = (SCREEN_WIDTH - rowWidth) / 2; + int keyY = y + r * (keySize + keyGap); + + for (int k = 0; k < rowLen; k++) { + int keyX = startX + k * (keySize + keyGap); + lcd.fillSmoothRoundRect(keyX, keyY, keySize, keySize, 4, TFT_DARKGRAY); + lcd.setTextColor(TFT_WHITE, TFT_DARKGRAY); + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawChar(rows[r][k], keyX + keySize/2, keyY + keySize/2); + } + } + + int btnY = y + numRows * (keySize + keyGap) + 4; + int delX = SCREEN_WIDTH / 2 - 125; + int enterX = SCREEN_WIDTH / 2 + 5; + + lcd.fillSmoothRoundRect(delX, btnY, 120, 32, 6, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu18); + lcd.drawString("DEL", delX + 60, btnY + 16); + + lcd.fillSmoothRoundRect(enterX, btnY, 120, 32, 6, TFT_GREEN); + lcd.setTextColor(TFT_WHITE, TFT_GREEN); + lcd.drawString("SAVE", enterX + 60, btnY + 16); +} + +void drawStockCustomizeScreen() { + lcd.fillScreen(TFT_BLACK); + + lcd.fillSmoothRoundRect(0, 0, SCREEN_WIDTH, 50, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("CUSTOMIZE STOCKS (up to 5)", SCREEN_WIDTH / 2, 25); + + int slotY = 60; + int slotHeight = 36; + + for (int i = 0; i < MAX_STOCKS; i++) { + int y = slotY + i * (slotHeight + 8); + uint16_t bgColor = (selectedStockSlot == i) ? TFT_ORANGE : TFT_DARKGRAY; + + lcd.fillSmoothRoundRect(200, y, 400, slotHeight, 6, bgColor); + lcd.setTextColor(TFT_WHITE, bgColor); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString(String(i + 1) + ":", 210, y + slotHeight/2); + lcd.setTextDatum(textdatum_t::middle_center); + + String displayText; + if (selectedStockSlot == i) { + displayText = String(keyboardBuffer); + if (displayText.length() == 0) displayText = "_"; + } else { + displayText = strlen(stockTickers[i]) > 0 ? String(stockTickers[i]) : "(tap to edit)"; + } + lcd.drawString(displayText, 400, y + slotHeight/2); + } + + drawKeyboard(slotY + MAX_STOCKS * (slotHeight + 8) + 20); + + lcd.fillSmoothRoundRect(20, SCREEN_HEIGHT - 50, 150, 40, 8, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("BACK", 95, SCREEN_HEIGHT - 30); + + lcd.fillSmoothRoundRect(SCREEN_WIDTH - 170, SCREEN_HEIGHT - 50, 150, 40, 8, TFT_GREEN); + lcd.setTextColor(TFT_WHITE, TFT_GREEN); + lcd.drawString("INDICES >", SCREEN_WIDTH - 95, SCREEN_HEIGHT - 30); +} + +void drawIndexCustomizeScreen() { + lcd.fillScreen(TFT_BLACK); + + lcd.fillSmoothRoundRect(0, 0, SCREEN_WIDTH, 50, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("CUSTOMIZE INDICES (select up to 5)", SCREEN_WIDTH / 2, 25); + + int listY = 60; + int itemHeight = 40; + int visibleItems = 8; + + for (int i = 0; i < visibleItems; i++) { + int idx = indexScrollOffset + i; + if (idx >= numAvailableIndices) break; + + int y = listY + i * (itemHeight + 4); + bool isSelected = false; + for (int j = 0; j < MAX_INDICES; j++) { + if (indexSelected[j] && strcmp(indexTickers[j], availableIndices[idx]) == 0) { + isSelected = true; + break; + } + } + + uint16_t bgColor = isSelected ? TFT_GREEN : TFT_DARKGRAY; + lcd.fillSmoothRoundRect(150, y, 500, itemHeight, 6, bgColor); + lcd.setTextColor(TFT_WHITE, bgColor); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString(indexNames[idx], 170, y + itemHeight/2); + + if (isSelected) { + lcd.setTextDatum(textdatum_t::middle_right); + lcd.drawString("SELECTED", 630, y + itemHeight/2); + } + } + + if (indexScrollOffset > 0) { + lcd.fillSmoothRoundRect(380, listY - 30, 40, 24, 4, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("^", 400, listY - 18); + } + + if (indexScrollOffset + visibleItems < numAvailableIndices) { + lcd.fillSmoothRoundRect(380, listY + visibleItems * (itemHeight + 4) + 8, 40, 24, 4, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.drawString("v", 400, listY + visibleItems * (itemHeight + 4) + 20); + } + + lcd.fillSmoothRoundRect(20, SCREEN_HEIGHT - 50, 150, 40, 8, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("SAVE", 95, SCREEN_HEIGHT - 30); + + lcd.fillSmoothRoundRect(SCREEN_WIDTH - 170, SCREEN_HEIGHT - 50, 150, 40, 8, TFT_GRAY); + lcd.drawString("CANCEL", SCREEN_WIDTH - 95, SCREEN_HEIGHT - 30); +} + +void scanWifiNetworks() { + WiFi.mode(WIFI_STA); + + int n = WiFi.scanNetworks(); + scannedCount = 0; + + if (n > 0) { + for (int i = 0; i < n && scannedCount < MAX_SCAN_NETWORKS; i++) { + String ssid = WiFi.SSID(i); + bool duplicate = false; + for (int j = 0; j < scannedCount; j++) { + if (scannedSSIDs[j] == ssid) { + duplicate = true; + break; + } + } + if (!duplicate && ssid.length() > 0) { + scannedSSIDs[scannedCount++] = ssid; + } + } + } + wifiScanDone = true; + wifiScrollOffset = 0; +} + +void drawWifiConfigScreen() { + lcd.fillScreen(TFT_BLACK); + + lcd.fillSmoothRoundRect(0, 0, SCREEN_WIDTH, 50, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("WIFI CONFIGURATION", SCREEN_WIDTH / 2, 25); + + if (!wifiScanDone) { + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("Scanning for networks...", SCREEN_WIDTH / 2, 150); + return; + } + + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("Select Network:", 50, 60); + + int listY = 85; + int itemHeight = 36; + int visibleItems = 5; + + for (int i = 0; i < visibleItems; i++) { + int idx = wifiScrollOffset + i; + if (idx >= scannedCount) break; + + int y = listY + i * (itemHeight + 4); + bool isSelected = (scannedSSIDs[idx] == selectedSSID); + uint16_t bgColor = isSelected ? TFT_ORANGE : TFT_DARKGRAY; + + lcd.fillSmoothRoundRect(50, y, 700, itemHeight, 6, bgColor); + lcd.setTextColor(TFT_WHITE, bgColor); + lcd.setTextDatum(textdatum_t::middle_left); + + String displaySSID = scannedSSIDs[idx]; + if (displaySSID.length() > 25) { + displaySSID = displaySSID.substring(0, 23) + "..."; + } + lcd.drawString(displaySSID, 70, y + itemHeight / 2); + } + + if (wifiScrollOffset > 0) { + lcd.fillSmoothRoundRect(380, listY - 28, 40, 22, 4, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("^", 400, listY - 17); + } + + if (wifiScrollOffset + visibleItems < scannedCount) { + lcd.fillSmoothRoundRect(380, listY + visibleItems * (itemHeight + 4) + 6, 40, 22, 4, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("v", 400, listY + visibleItems * (itemHeight + 4) + 17); + } + + if (selectedSSID.length() > 0) { + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("Password:", 50, 285); + + lcd.fillSmoothRoundRect(50, 305, 700, 40, 6, TFT_ORANGE); +lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("(tap to enter password)", 70, 325); + } + + lcd.fillSmoothRoundRect(50, SCREEN_HEIGHT - 50, 150, 40, 8, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("BACK", 125, SCREEN_HEIGHT - 30); +} + +void drawGearIcon(int cx, int cy, int radius, uint16_t color) { + for (int i = 0; i < 8; i++) { + float angle = i * 45.0 * DEG_TO_RAD; + float halfWidth = 0.35; + float cosA = cos(angle); + float sinA = sin(angle); + int x0 = cx + cosA * radius; + int y0 = cy + sinA * radius; + int x1 = cx + cosA * (radius + 6); + int y1 = cy + sinA * (radius + 6); + int dx = halfWidth * (-sinA) * radius; + int dy = halfWidth * cosA * radius; + lcd.fillTriangle(x0 - dx, y0 - dy, x1 - dx, y1 - dy, x1 + dx, y1 + dy, color); + lcd.fillTriangle(x0 - dx, y0 - dy, x1 + dx, y1 + dy, x0 + dx, y0 + dy, color); + } + lcd.fillCircle(cx, cy, radius, color); + lcd.fillCircle(cx, cy, radius - 3, TFT_ORANGE); +} + +void drawFlipIcon(int cx, int cy, int radius, uint16_t color) { + lcd.setColor(color); + lcd.drawArc(cx, cy, radius, radius - 3, 60, 300); + float arrowAngle = 60.0 * DEG_TO_RAD; + int arrowX = cx + cos(arrowAngle) * radius; + int arrowY = cy - sin(arrowAngle) * radius; + float tangentAngle = arrowAngle - PI / 2; + int tipX = arrowX + cos(tangentAngle) * 6; + int tipY = arrowY - sin(tangentAngle) * 6; + lcd.fillTriangle( + arrowX + cos(tangentAngle) * 2 - sin(tangentAngle) * 4, + arrowY - sin(tangentAngle) * 2 - cos(tangentAngle) * 4, + arrowX + cos(tangentAngle) * 2 + sin(tangentAngle) * 4, + arrowY - sin(tangentAngle) * 2 + cos(tangentAngle) * 4, + tipX, tipY, color); +} + +void drawDashboard() { + lcd.fillScreen(TFT_BLACK); + + lcd.fillSmoothRoundRect(0, 0, SCREEN_WIDTH, 45, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("HARD MONEY", 20, 22); + lcd.drawString("STOCKS", 280, 22); + lcd.drawString("INDICES", 540, 22); + drawFlipIcon(SCREEN_WIDTH - 32, 22, 12, TFT_WHITE); + + int col1X = 20; + int col2X = 280; + int col3X = 540; + int labelY = 70; + int priceY = labelY + 28; + + lcd.setTextColor(TFT_ORANGE, TFT_BLACK); + lcd.setFont(&fonts::Roboto_Thin_24); + lcd.drawString("Bitcoin:", col1X, labelY); + lcd.drawString(formatPriceUS(btcPrice, 0), col1X, priceY); + + drawSparkline(col1X, labelY + 53, 240, 35, btcHistory, btcHistoryLoaded, TFT_ORANGE); + + lcd.setTextColor(TFT_GOLD, TFT_BLACK); + lcd.drawString("Gold:", col1X, labelY + 113); + lcd.drawString(formatPriceUS(goldPrice, 2), col1X, priceY + 113); + + drawSparkline(col1X, labelY + 178, 240, 35, goldHistory, goldHistoryLoaded, TFT_GOLD); + + lcd.setTextColor(TFT_SILVER, TFT_BLACK); + lcd.drawString("Silver:", col1X, labelY + 233); + lcd.drawString(formatPriceUS(silverPrice, 2), col1X, priceY + 233); + + drawSparkline(col1X, labelY + 298, 240, 35, silverHistory, silverHistoryLoaded, TFT_SILVER); + + lcd.setTextColor(TFT_GREEN, TFT_BLACK); + int stockY = labelY; + for (int i = 0; i < MAX_STOCKS; i++) { + if (strlen(stockTickers[i]) > 0) { + lcd.drawString(String(stockTickers[i]) + ":", col2X, stockY); + lcd.drawString(formatPriceUS(stockPrices[i], 2), col2X, stockY + 28); + stockY += 65; + } + } + + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + int indexY = labelY; + for (int i = 0; i < MAX_INDICES; i++) { + if (indexSelected[i] && strlen(indexTickers[i]) > 0) { + lcd.drawString(String(getIndexFriendlyName(indexTickers[i])) + ":", col3X, indexY); + lcd.drawString(formatPriceUS(indexPrices[i], 2), col3X, indexY + 28); +indexY += 65; + } + } + + lcd.fillSmoothRoundRect(0, SCREEN_HEIGHT - 40, SCREEN_WIDTH, 40, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + String wifiStatus; + if (wifiConnecting) { + wifiStatus = "Connecting..."; + } else if (WiFi.status() == WL_CONNECTED) { + wifiStatus = WiFi.SSID(); + if (wifiStatus.length() > 20) { + wifiStatus = wifiStatus.substring(0, 17) + "..."; + } + } else { + wifiStatus = "OFFLINE"; + } + lcd.drawString("WiFi: " + wifiStatus, 20, SCREEN_HEIGHT - 20); + + unsigned long elapsed = millis() - lastRefresh; + int secondsLeft; + if (lastRefresh == 0) { + secondsLeft = 0; + } else { + secondsLeft = (REFRESH_INTERVAL - (elapsed % REFRESH_INTERVAL)) / 1000; + } + int mins = secondsLeft / 60; + int secs = secondsLeft % 60; + char timeStr[8]; + sprintf(timeStr, "%d:%02d", mins, secs); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString(String(timeStr), SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20); + +drawGearIcon(SCREEN_WIDTH - 32, SCREEN_HEIGHT - 20, 9, TFT_WHITE); +} + +void drawFullKeyboard(int y, bool showSpecials) { + const char* letterRows[] = { + "QWERTYUIOP", + "ASDFGHJKL", + "ZXCVBNM" + }; + const char* symbolRows[] = { + "1234567890", + "-=_+[]{}\\", + "!@#$%^&*()" + }; + + const int keySize = 36; + const int keyGap = 3; + + if (!showSpecials) { + for (int r = 0; r < 3; r++) { + int rowLen = strlen(letterRows[r]); + int rowWidth = rowLen * (keySize + keyGap) - keyGap; + int startX = (SCREEN_WIDTH - rowWidth) / 2; + int keyY = y + r * (keySize + keyGap); + + for (int k = 0; k < rowLen; k++) { + int keyX = startX + k * (keySize + keyGap); + lcd.fillSmoothRoundRect(keyX, keyY, keySize, keySize, 4, TFT_DARKGRAY); + lcd.setTextColor(TFT_WHITE, TFT_DARKGRAY); + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_center); + char c = letterRows[r][k]; + if (!(keyboardCaps || keyboardShift)) { + c = tolower(letterRows[r][k]); + } + lcd.drawChar(c, keyX + keySize/2, keyY + keySize/2); + } + } + } else { + for (int r = 0; r < 3; r++) { + int rowLen = strlen(symbolRows[r]); + int rowWidth = rowLen * (keySize + keyGap) - keyGap; + int startX = (SCREEN_WIDTH - rowWidth) / 2; + int keyY = y + r * (keySize + keyGap); + + for (int k = 0; k < rowLen; k++) { + int keyX = startX + k * (keySize + keyGap); + lcd.fillSmoothRoundRect(keyX, keyY, keySize, keySize, 4, TFT_DARKGRAY); + lcd.setTextColor(TFT_WHITE, TFT_DARKGRAY); + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawChar(symbolRows[r][k], keyX + keySize/2, keyY + keySize/2); + } + } + } + + int btnY = y + 3 * (keySize + keyGap) + 4; + + int capsX = 50; + lcd.fillSmoothRoundRect(capsX, btnY, 80, 36, 6, keyboardCaps ? TFT_ORANGE : TFT_GRAY); + lcd.setTextColor(TFT_WHITE, keyboardCaps ? TFT_ORANGE : TFT_GRAY); + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("CAPS", capsX + 40, btnY + 18); + + int shiftX = 140; + lcd.fillSmoothRoundRect(shiftX, btnY, 80, 36, 6, keyboardShift ? TFT_ORANGE : TFT_GRAY); + lcd.setTextColor(TFT_WHITE, keyboardShift ? TFT_ORANGE : TFT_GRAY); + lcd.drawString("SHIFT", shiftX + 40, btnY + 18); + + int symX = 230; + lcd.fillSmoothRoundRect(symX, btnY, 80, 36, 6, keyboardSymbols ? TFT_ORANGE : TFT_GRAY); + lcd.setTextColor(TFT_WHITE, keyboardSymbols ? TFT_ORANGE : TFT_GRAY); + lcd.drawString("123", symX + 40, btnY + 18); + + int delX = SCREEN_WIDTH / 2 - 40; + lcd.fillSmoothRoundRect(delX, btnY, 80, 36, 6, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.drawString("DEL", delX + 40, btnY + 18); + + int enterX = SCREEN_WIDTH / 2 + 50; + lcd.fillSmoothRoundRect(enterX, btnY, 100, 36, 6, TFT_GREEN); + lcd.setTextColor(TFT_WHITE, TFT_GREEN); + lcd.drawString("SAVE", enterX + 50, btnY + 18); +} + +void drawWifiPasswordScreen() { + lcd.fillScreen(TFT_BLACK); + + lcd.fillSmoothRoundRect(0, 0, SCREEN_WIDTH, 50, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("ENTER PASSWORD", SCREEN_WIDTH / 2, 25); + + lcd.setTextColor(TFT_GRAY, TFT_BLACK); + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("Network: " + selectedSSID, 50, 70); + + lcd.fillSmoothRoundRect(50, 100, SCREEN_WIDTH - 160, 50, 8, TFT_DARKGRAY); + lcd.setTextColor(TFT_WHITE, TFT_DARKGRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + + String displayPwd = ""; + if (showPasswordPlain) { + displayPwd = String(keyboardBuffer); + } else { + for (int i = 0; i < keyboardPos; i++) displayPwd += "*"; + } + if (keyboardPos == 0) { + lcd.setTextColor(TFT_GRAY, TFT_DARKGRAY); + displayPwd = "_"; + } + lcd.drawString(displayPwd, 70, 125); + + lcd.fillSmoothRoundRect(SCREEN_WIDTH - 100, 100, 50, 50, 8, showPasswordPlain ? TFT_ORANGE : TFT_GRAY); + lcd.setTextColor(TFT_WHITE, showPasswordPlain ? TFT_ORANGE : TFT_GRAY); + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("SHOW", SCREEN_WIDTH - 75, 125); + + drawFullKeyboard(170, keyboardSymbols); + +lcd.fillSmoothRoundRect(50, SCREEN_HEIGHT - 50, 150, 40, 8, TFT_GRAY); + lcd.setTextColor(TFT_WHITE, TFT_GRAY); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("CANCEL", 125, SCREEN_HEIGHT - 30); +} + +bool handleKeyboardTouch(int32_t x, int32_t y, int keyboardY) { + const char* rows[] = { + "QWERTYUIOP", + "ASDFGHJKL", + "ZXCVBNM", + "1234567890" + }; + const int numRows = 4; + const int keySize = 32; + const int keyGap = 2; + + for (int r = 0; r < numRows; r++) { + int rowLen = strlen(rows[r]); + int rowWidth = rowLen * (keySize + keyGap) - keyGap; + int startX = (SCREEN_WIDTH - rowWidth) / 2; + int keyY = keyboardY + r * (keySize + keyGap); + + for (int k = 0; k < rowLen; k++) { + int keyX = startX + k * (keySize + keyGap); + if (x >= keyX && x < keyX + keySize && y >= keyY && y < keyY + keySize) { + if (keyboardPos < MAX_TICKER_LEN) { + keyboardBuffer[keyboardPos++] = rows[r][k]; + keyboardBuffer[keyboardPos] = '\0'; + } + return true; + } + } + } + + int btnY = keyboardY + numRows * (keySize + keyGap) + 4; + int delX = SCREEN_WIDTH / 2 - 125; + int enterX = SCREEN_WIDTH / 2 + 5; + + if (x >= delX && x < delX + 120 && y >= btnY && y < btnY + 32) { + if (keyboardPos > 0) { + keyboardPos--; + keyboardBuffer[keyboardPos] = '\0'; + } + return true; + } + + if (x >= enterX && x < enterX + 120 && y >= btnY && y < btnY + 32) { + if (selectedStockSlot >= 0) { + strcpy(stockTickers[selectedStockSlot], keyboardBuffer); + selectedStockSlot = -1; + keyboardPos = 0; + keyboardBuffer[0] = '\0'; + } + return true; + } + + return false; +} + +bool handleStockCustomizeTouch(int32_t x, int32_t y) { + int slotY = 60; + int slotHeight = 36; + + if (x >= 20 && x < 170 && y >= SCREEN_HEIGHT - 50 && y < SCREEN_HEIGHT - 10) { + if (selectedStockSlot >= 0 && keyboardPos > 0) { + strcpy(stockTickers[selectedStockSlot], keyboardBuffer); + } + saveConfig(); + currentScreen = SCREEN_DASHBOARD; + needsRefresh = true; + return true; + } + + if (x >= SCREEN_WIDTH - 170 && x < SCREEN_WIDTH - 20 && y >= SCREEN_HEIGHT - 50 && y < SCREEN_HEIGHT - 10) { + if (selectedStockSlot >= 0 && keyboardPos > 0) { + strcpy(stockTickers[selectedStockSlot], keyboardBuffer); + } + saveConfig(); + currentScreen = SCREEN_CUSTOMIZE_INDICES; + indexScrollOffset = 0; + return true; + } + + for (int i = 0; i < MAX_STOCKS; i++) { + int sy = slotY + i * (slotHeight + 8); + if (x >= 200 && x < 600 && y >= sy && y < sy + slotHeight) { + if (selectedStockSlot >= 0 && selectedStockSlot != i && keyboardPos > 0) { + strcpy(stockTickers[selectedStockSlot], keyboardBuffer); + } + selectedStockSlot = i; + strcpy(keyboardBuffer, stockTickers[i]); + keyboardPos = strlen(keyboardBuffer); + return true; + } + } + + if (selectedStockSlot >= 0) { + int keyboardY = slotY + MAX_STOCKS * (slotHeight + 8) + 20; + if (handleKeyboardTouch(x, y, keyboardY)) { + return true; + } + } + + return false; +} + +bool handleWifiConfigTouch(int32_t x, int32_t y) { + if (!wifiScanDone) return true; + + if (x >= 50 && x < 200 && y >= SCREEN_HEIGHT - 50 && y < SCREEN_HEIGHT - 10) { + currentScreen = SCREEN_DASHBOARD; + needsRefresh = true; + return true; + } + + int listY = 85; + int itemHeight = 36; + int visibleItems = 5; + + if (x >= 380 && x < 420 && y >= listY - 28 && y < listY - 6) { + if (wifiScrollOffset > 0) { + wifiScrollOffset--; + } + return true; + } + + if (x >= 380 && x < 420 && y >= listY + visibleItems * (itemHeight + 4) + 6 && y < listY + visibleItems * (itemHeight + 4) + 28) { + if (wifiScrollOffset + visibleItems < scannedCount) { + wifiScrollOffset++; + } + return true; + } + + for (int i = 0; i < visibleItems; i++) { + int idx = wifiScrollOffset + i; + if (idx >= scannedCount) break; + + int iy = listY + i * (itemHeight + 4); + if (x >= 50 && x < 750 && y >= iy && y < iy + itemHeight) { + selectedSSID = scannedSSIDs[idx]; + return true; + } + } + + if (x >= 50 && x < 750 && y >= 305 && y < 345 && selectedSSID.length() > 0) { + currentScreen = SCREEN_WIFI_PASSWORD; + keyboardPos = 0; + keyboardBuffer[0] = '\0'; + keyboardCaps = false; + keyboardShift = false; + keyboardSymbols = false; + return true; + } + + return false; +} + +bool handleWifiPasswordTouch(int32_t x, int32_t y) { + if (x >= 50 && x < 200 && y >= SCREEN_HEIGHT - 50 && y < SCREEN_HEIGHT - 10) { + currentScreen = SCREEN_WIFI_CONFIG; + return true; + } + + if (x >= SCREEN_WIDTH - 100 && x < SCREEN_WIDTH - 50 && y >= 100 && y < 150) { + showPasswordPlain = !showPasswordPlain; + return true; + } + + const char* letterRows[] = { + "QWERTYUIOP", + "ASDFGHJKL", + "ZXCVBNM" + }; + const char* symbolRows[] = { + "1234567890", + "-=_+[]{}\\", + "!@#$%^&*()" + }; + + const int keySize = 36; + const int keyGap = 3; + int keyboardY = 170; + + if (!keyboardSymbols) { + for (int r = 0; r < 3; r++) { + int rowLen = strlen(letterRows[r]); + int rowWidth = rowLen * (keySize + keyGap) - keyGap; + int startX = (SCREEN_WIDTH - rowWidth) / 2; + int keyY = keyboardY + r * (keySize + keyGap); + + for (int k = 0; k < rowLen; k++) { + int keyX = startX + k * (keySize + keyGap); + if (x >= keyX && x < keyX + keySize && y >= keyY && y < keyY + keySize) { + if (keyboardPos < 63) { + char c = letterRows[r][k]; + if (!(keyboardCaps || keyboardShift)) { + c = tolower(c); + } + keyboardBuffer[keyboardPos++] = c; + keyboardBuffer[keyboardPos] = '\0'; + if (keyboardShift) keyboardShift = false; + } + return true; + } + } + } + } else { + for (int r = 0; r < 3; r++) { + int rowLen = strlen(symbolRows[r]); + int rowWidth = rowLen * (keySize + keyGap) - keyGap; + int startX = (SCREEN_WIDTH - rowWidth) / 2; + int keyY = keyboardY + r * (keySize + keyGap); + + for (int k = 0; k < rowLen; k++) { + int keyX = startX + k * (keySize + keyGap); + if (x >= keyX && x < keyX + keySize && y >= keyY && y < keyY + keySize) { + if (keyboardPos < 63) { + keyboardBuffer[keyboardPos++] = symbolRows[r][k]; + keyboardBuffer[keyboardPos] = '\0'; + } + return true; + } + } + } + } + + int btnY = keyboardY + 3 * (keySize + keyGap) + 4; + + if (x >= 50 && x < 130 && y >= btnY && y < btnY + 36) { + keyboardCaps = !keyboardCaps; + return true; + } + + if (x >= 140 && x < 220 && y >= btnY && y < btnY + 36) { + keyboardShift = !keyboardShift; + return true; + } + + if (x >= 230 && x < 310 && y >= btnY && y < btnY + 36) { + keyboardSymbols = !keyboardSymbols; + return true; + } + + if (x >= SCREEN_WIDTH / 2 - 40 && x < SCREEN_WIDTH / 2 + 40 && y >= btnY && y < btnY + 36) { + if (keyboardPos > 0) { + keyboardPos--; + keyboardBuffer[keyboardPos] = '\0'; + } + return true; + } + +if (x >= SCREEN_WIDTH / 2 + 50 && x < SCREEN_WIDTH / 2 + 150 && y >= btnY && y < btnY + 36) { + wifiPassword = String(keyboardBuffer); + preferences.begin("wifi", false); + preferences.putString("ssid", selectedSSID); + preferences.putString("password", wifiPassword); + preferences.end(); + + wifiConnecting = true; + wifiConnectStartTime = millis(); + + WiFi.mode(WIFI_STA); + WiFi.begin(selectedSSID.c_str(), wifiPassword.c_str()); + + currentScreen = SCREEN_DASHBOARD; + needsRefresh = true; + return true; + } + +return false; +} + +bool handleIndexCustomizeTouch(int32_t x, int32_t y) { + int listY = 60; + int itemHeight = 40; + int visibleItems = 8; + + if (x >= 20 && x < 170 && y >= SCREEN_HEIGHT - 50 && y < SCREEN_HEIGHT - 10) { + saveConfig(); + currentScreen = SCREEN_DASHBOARD; + needsRefresh = true; + return true; + } + + if (x >= SCREEN_WIDTH - 170 && x < SCREEN_WIDTH - 20 && y >= SCREEN_HEIGHT - 50 && y < SCREEN_HEIGHT - 10) { + currentScreen = SCREEN_DASHBOARD; + loadConfig(); + return true; + } + + if (x >= 380 && x < 420 && y >= listY - 30 && y < listY - 6) { + if (indexScrollOffset > 0) { + indexScrollOffset--; + } + return true; + } + + if (x >= 380 && x < 420 && y >= listY + visibleItems * (itemHeight + 4) + 8 && y < listY + visibleItems * (itemHeight + 4) + 32) { + if (indexScrollOffset + visibleItems < numAvailableIndices) { + indexScrollOffset++; + } + return true; + } + + for (int i = 0; i < visibleItems; i++) { + int idx = indexScrollOffset + i; + if (idx >= numAvailableIndices) break; + + int iy = listY + i * (itemHeight + 4); + if (x >= 150 && x < 650 && y >= iy && y < iy + itemHeight) { + bool alreadySelected = false; + int selectedSlot = -1; + + for (int j = 0; j < MAX_INDICES; j++) { + if (indexSelected[j] && strcmp(indexTickers[j], availableIndices[idx]) == 0) { + alreadySelected = true; + selectedSlot = j; + break; + } + } + + if (alreadySelected) { + indexSelected[selectedSlot] = false; + indexTickers[selectedSlot][0] = '\0'; + } else { + int count = 0; + for (int j = 0; j < MAX_INDICES; j++) { + if (indexSelected[j]) count++; + } + + if (count < MAX_INDICES) { + for (int j = 0; j < MAX_INDICES; j++) { + if (!indexSelected[j]) { + strcpy(indexTickers[j], availableIndices[idx]); + indexSelected[j] = true; + break; + } + } + } + } + return true; + } + } + + return false; +} + +void showRefreshingMessage() { + lcd.fillSmoothRoundRect(0, SCREEN_HEIGHT - 40, SCREEN_WIDTH, 40, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + String wifiStatus = WiFi.status() == WL_CONNECTED ? "OK" : "OFFLINE"; + lcd.drawString("WiFi: " + wifiStatus, 20, SCREEN_HEIGHT - 20); + + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("REFRESHING...", SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20); + + drawGearIcon(SCREEN_WIDTH - 32, SCREEN_HEIGHT - 20, 9, TFT_WHITE); +} + +void drawHardMoneyDetailScreen() { + lcd.fillScreen(TFT_BLACK); + + lcd.fillSmoothRoundRect(0, 0, SCREEN_WIDTH, 45, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("< BACK", 15, 22); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("HARD MONEY", SCREEN_WIDTH / 2, 22); + drawFlipIcon(SCREEN_WIDTH - 32, 22, 12, TFT_WHITE); + + int col1X = 50; + int col2X = 310; + int col3X = 570; + int colWidth = 180; + + int headerY = 65; + int priceY = 90; + + lcd.setTextColor(TFT_ORANGE, TFT_BLACK); + lcd.setFont(&fonts::Roboto_Thin_24); + lcd.setTextDatum(textdatum_t::middle_left); + lcd.drawString("BITCOIN", col1X, headerY); + lcd.drawString(formatPriceUS(btcPrice, 0), col1X, priceY); + + lcd.setTextColor(TFT_GOLD, TFT_BLACK); + lcd.drawString("GOLD", col2X, headerY); + lcd.drawString(formatPriceUS(goldPrice, 2), col2X, priceY); + + lcd.setTextColor(TFT_SILVER, TFT_BLACK); + lcd.drawString("SILVER", col3X, headerY); + lcd.drawString(formatPriceUS(silverPrice, 2), col3X, priceY); + + int highLowY = priceY + 30; + int rowHeight = 22; + + lcd.setFont(&fonts::DejaVu12); + lcd.setTextDatum(textdatum_t::middle_left); + + lcd.setTextColor(TFT_GREEN, TFT_BLACK); + lcd.drawString("Day High:", col1X, highLowY); + lcd.drawString(formatPriceUS(btcHighDay, 0, true), col1X + 65, highLowY); + lcd.setTextColor(TFT_GREEN, TFT_BLACK); + lcd.drawString("Day High:", col2X, highLowY); + lcd.drawString(formatPriceUS(goldHighDay, 2, true), col2X + 65, highLowY); + lcd.drawString("Day High:", col3X, highLowY); + lcd.drawString(formatPriceUS(silverHighDay, 2, true), col3X + 65, highLowY); + + lcd.setTextColor(0xF800, TFT_BLACK); + lcd.drawString("Day Low:", col1X, highLowY + rowHeight); + lcd.drawString(formatPriceUS(btcLowDay, 0, true), col1X + 65, highLowY + rowHeight); + lcd.drawString("Day Low:", col2X, highLowY + rowHeight); + lcd.drawString(formatPriceUS(goldLowDay, 2, true), col2X + 65, highLowY + rowHeight); + lcd.drawString("Day Low:", col3X, highLowY + rowHeight); + lcd.drawString(formatPriceUS(silverLowDay, 2, true), col3X + 65, highLowY + rowHeight); + + lcd.setTextColor(TFT_GREEN, TFT_BLACK); + lcd.drawString("Mo High:", col1X, highLowY + rowHeight * 2); + lcd.drawString(formatPriceUS(btcHighMonth, 0, true), col1X + 65, highLowY + rowHeight * 2); + lcd.drawString("Mo High:", col2X, highLowY + rowHeight * 2); + lcd.drawString(formatPriceUS(goldHighMonth, 2, true), col2X + 65, highLowY + rowHeight * 2); + lcd.drawString("Mo High:", col3X, highLowY + rowHeight * 2); + lcd.drawString(formatPriceUS(silverHighMonth, 2, true), col3X + 65, highLowY + rowHeight * 2); + + lcd.setTextColor(0xF800, TFT_BLACK); + lcd.drawString("Mo Low:", col1X, highLowY + rowHeight * 3); + lcd.drawString(formatPriceUS(btcLowMonth, 0, true), col1X + 65, highLowY + rowHeight * 3); + lcd.drawString("Mo Low:", col2X, highLowY + rowHeight * 3); + lcd.drawString(formatPriceUS(goldLowMonth, 2, true), col2X + 65, highLowY + rowHeight * 3); + lcd.drawString("Mo Low:", col3X, highLowY + rowHeight * 3); + lcd.drawString(formatPriceUS(silverLowMonth, 2, true), col3X + 65, highLowY + rowHeight * 3); + + lcd.setTextColor(TFT_GREEN, TFT_BLACK); + lcd.drawString("Yr High:", col1X, highLowY + rowHeight * 4); + lcd.drawString(formatPriceUS(btcHighYear, 0, true), col1X + 65, highLowY + rowHeight * 4); + lcd.drawString("Yr High:", col2X, highLowY + rowHeight * 4); + lcd.drawString(formatPriceUS(goldHighYear, 2, true), col2X + 65, highLowY + rowHeight * 4); + lcd.drawString("Yr High:", col3X, highLowY + rowHeight * 4); + lcd.drawString(formatPriceUS(silverHighYear, 2, true), col3X + 65, highLowY + rowHeight * 4); + + lcd.setTextColor(0xF800, TFT_BLACK); + lcd.drawString("Yr Low:", col1X, highLowY + rowHeight * 5); + lcd.drawString(formatPriceUS(btcLowYear, 0, true), col1X + 65, highLowY + rowHeight * 5); + lcd.drawString("Yr Low:", col2X, highLowY + rowHeight * 5); + lcd.drawString(formatPriceUS(goldLowYear, 2, true), col2X + 65, highLowY + rowHeight * 5); + lcd.drawString("Yr Low:", col3X, highLowY + rowHeight * 5); + lcd.drawString(formatPriceUS(silverLowYear, 2, true), col3X + 65, highLowY + rowHeight * 5); + + int sparklineY = highLowY + rowHeight * 6 + 15; + + const char* periodLabels[] = {"D", "W", "M", "Y", "A"}; + int btnWidth = 50; + int btnHeight = 30; + int btnGap = 10; + int totalBtnWidth = 5 * btnWidth + 4 * btnGap; + int btnStartX = (SCREEN_WIDTH - totalBtnWidth) / 2; + + for (int i = 0; i < 5; i++) { + int btnX = btnStartX + i * (btnWidth + btnGap); + uint16_t bgColor = (hmSparklinePeriod == i) ? TFT_ORANGE : TFT_DARKGRAY; + lcd.fillSmoothRoundRect(btnX, sparklineY, btnWidth, btnHeight, 6, bgColor); + lcd.setTextColor(TFT_WHITE, bgColor); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString(periodLabels[i], btnX + btnWidth / 2, sparklineY + btnHeight / 2); + } + + int sparklineDrawY = sparklineY + btnHeight + 15; + int sparklineHeight = 80; + + if (btcHmLoaded && btcHmCount > 1) { + double minVal = btcHmHistory[0]; + double maxVal = btcHmHistory[0]; + for (int i = 0; i < btcHmCount; i++) { + if (btcHmHistory[i] < minVal) minVal = btcHmHistory[i]; + if (btcHmHistory[i] > maxVal) maxVal = btcHmHistory[i]; + } + double range = maxVal - minVal; + if (range < 1) range = 1; + + for (int i = 0; i < btcHmCount - 1; i++) { + int x1 = col1X + (i * colWidth) / (btcHmCount - 1); + int x2 = col1X + ((i + 1) * colWidth) / (btcHmCount - 1); + int y1 = sparklineDrawY + sparklineHeight - (int)((btcHmHistory[i] - minVal) * sparklineHeight / range); + int y2 = sparklineDrawY + sparklineHeight - (int)((btcHmHistory[i + 1] - minVal) * sparklineHeight / range); + lcd.drawLine(x1, y1, x2, y2, TFT_ORANGE); + } + } + + if (goldHmLoaded && goldHmCount > 1) { + double minVal = goldHmHistory[0]; + double maxVal = goldHmHistory[0]; + for (int i = 0; i < goldHmCount; i++) { + if (goldHmHistory[i] < minVal) minVal = goldHmHistory[i]; + if (goldHmHistory[i] > maxVal) maxVal = goldHmHistory[i]; + } + double range = maxVal - minVal; + if (range < 1) range = 1; + + for (int i = 0; i < goldHmCount - 1; i++) { + int x1 = col2X + (i * colWidth) / (goldHmCount - 1); + int x2 = col2X + ((i + 1) * colWidth) / (goldHmCount - 1); + int y1 = sparklineDrawY + sparklineHeight - (int)((goldHmHistory[i] - minVal) * sparklineHeight / range); + int y2 = sparklineDrawY + sparklineHeight - (int)((goldHmHistory[i + 1] - minVal) * sparklineHeight / range); + lcd.drawLine(x1, y1, x2, y2, TFT_GOLD); + } + } + + if (silverHmLoaded && silverHmCount > 1) { + double minVal = silverHmHistory[0]; + double maxVal = silverHmHistory[0]; + for (int i = 0; i < silverHmCount; i++) { + if (silverHmHistory[i] < minVal) minVal = silverHmHistory[i]; + if (silverHmHistory[i] > maxVal) maxVal = silverHmHistory[i]; + } + double range = maxVal - minVal; + if (range < 1) range = 1; + + for (int i = 0; i < silverHmCount - 1; i++) { + int x1 = col3X + (i * colWidth) / (silverHmCount - 1); + int x2 = col3X + ((i + 1) * colWidth) / (silverHmCount - 1); + int y1 = sparklineDrawY + sparklineHeight - (int)((silverHmHistory[i] - minVal) * sparklineHeight / range); + int y2 = sparklineDrawY + sparklineHeight - (int)((silverHmHistory[i + 1] - minVal) * sparklineHeight / range); + lcd.drawLine(x1, y1, x2, y2, TFT_SILVER); + } + } + + lcd.fillSmoothRoundRect(0, SCREEN_HEIGHT - 40, SCREEN_WIDTH, 40, 8, TFT_ORANGE); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextDatum(textdatum_t::middle_left); + String wifiStatus; + if (wifiConnecting) { + wifiStatus = "Connecting..."; + } else if (WiFi.status() == WL_CONNECTED) { + wifiStatus = WiFi.SSID(); + if (wifiStatus.length() > 20) { + wifiStatus = wifiStatus.substring(0, 17) + "..."; + } + } else { + wifiStatus = "OFFLINE"; + } + lcd.drawString("WiFi: " + wifiStatus, 20, SCREEN_HEIGHT - 20); + + lcd.setTextDatum(textdatum_t::middle_center); + unsigned long elapsed = millis() - lastRefresh; + int secondsLeft; + if (lastRefresh == 0) { + secondsLeft = 0; + } else { + secondsLeft = (REFRESH_INTERVAL - (elapsed % REFRESH_INTERVAL)) / 1000; + } + int mins = secondsLeft / 60; + int secs = secondsLeft % 60; + char timeStr[8]; + sprintf(timeStr, "%d:%02d", mins, secs); + lcd.drawString(String(timeStr), SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20); + + drawGearIcon(SCREEN_WIDTH - 32, SCREEN_HEIGHT - 20, 9, TFT_WHITE); +} + +bool handleHardMoneyTouch(int32_t x, int32_t y) { + if (x >= 10 && x < 100 && y >= 5 && y < 40) { + currentScreen = SCREEN_DASHBOARD; + needsRefresh = true; + return true; + } + + if (x >= SCREEN_WIDTH - 55 && x < SCREEN_WIDTH - 10 && y >= 5 && y < 40) { + displayFlipped = !displayFlipped; + preferences.begin("dashboard", false); + preferences.putBool("flipped", displayFlipped); + preferences.end(); + lcd.setRotation(displayFlipped ? 2 : 0); + return true; + } + + if (x >= 20 && x < 200 && y >= SCREEN_HEIGHT - 40 && y < SCREEN_HEIGHT) { + currentScreen = SCREEN_WIFI_CONFIG; + wifiScanDone = false; + wifiPassword = ""; + selectedSSID = ""; + keyboardPos = 0; + keyboardBuffer[0] = '\0'; + keyboardCaps = false; + keyboardShift = false; + keyboardSymbols = false; + showPasswordPlain = false; + scanWifiNetworks(); + return true; + } + + if (x >= SCREEN_WIDTH - 50 && x < SCREEN_WIDTH - 14 && y >= SCREEN_HEIGHT - 32 && y < SCREEN_HEIGHT - 8) { + currentScreen = SCREEN_CUSTOMIZE_STOCKS; + selectedStockSlot = -1; + keyboardPos = 0; + keyboardBuffer[0] = '\0'; + return true; + } + + int btnWidth = 50; + int btnHeight = 30; + int btnGap = 10; + int totalBtnWidth = 5 * btnWidth + 4 * btnGap; + int btnStartX = (SCREEN_WIDTH - totalBtnWidth) / 2; + + int headerY = 55; + int priceY = 80; + int highLowY = priceY + 30; + int rowHeight = 22; + int sparklineY = highLowY + rowHeight * 6 + 15; + + for (int i = 0; i < 5; i++) { + int btnX = btnStartX + i * (btnWidth + btnGap); + if (x >= btnX && x < btnX + btnWidth && y >= sparklineY && y < sparklineY + btnHeight) { + if (hmSparklinePeriod != i) { + hmSparklinePeriod = i; + btcHmLoaded = false; + goldHmLoaded = false; + silverHmLoaded = false; + hmNeedsRefresh = true; + } + return true; + } + } + + return false; +} + +void refreshDataAndShow() { + drawDashboard(); + showRefreshingMessage(); + updatePrices(); + lastRefresh = millis(); + drawDashboard(); +} + +bool handleDashboardTouch(int32_t x, int32_t y) { + if (x >= SCREEN_WIDTH - 55 && x < SCREEN_WIDTH - 10 && y >= 5 && y < 40) { + displayFlipped = !displayFlipped; + preferences.begin("dashboard", false); + preferences.putBool("flipped", displayFlipped); + preferences.end(); + lcd.setRotation(displayFlipped ? 2 : 0); + needsRefresh = true; + return true; + } + + if (x >= 20 && x < 200 && y >= 0 && y < 45) { + currentScreen = SCREEN_HARD_MONEY_DETAIL; + hmSparklinePeriod = 2; + hmNeedsRefresh = true; + hmFirstLoad = true; + return true; + } + + if (x >= SCREEN_WIDTH - 50 && x < SCREEN_WIDTH - 14 && y >= SCREEN_HEIGHT - 32 && y < SCREEN_HEIGHT - 8) { + currentScreen = SCREEN_CUSTOMIZE_STOCKS; + selectedStockSlot = -1; + keyboardPos = 0; + keyboardBuffer[0] = '\0'; + return true; + } + + if (x >= 20 && x < 200 && y >= SCREEN_HEIGHT - 40 && y < SCREEN_HEIGHT) { + currentScreen = SCREEN_WIFI_CONFIG; + wifiScanDone = false; + wifiPassword = ""; + selectedSSID = ""; + keyboardPos = 0; + keyboardBuffer[0] = '\0'; + keyboardCaps = false; + keyboardShift = false; + keyboardSymbols = false; + showPasswordPlain = false; + scanWifiNetworks(); + return true; + } + + return true; +} + +void setup() { + Serial.begin(115200); + delay(3000); + + lcd.init(); + lcd.setBrightness(255); + + preferences.begin("dashboard", true); + displayFlipped = preferences.getBool("flipped", false); + preferences.end(); + + lcd.setRotation(displayFlipped ? 2 : 0); + + lcd.fillScreen(TFT_WHITE); + delay(250); + lcd.fillScreen(TFT_ORANGE); + delay(250); + lcd.fillScreen(TFT_BLACK); + delay(250); + + lcd.setTextColor(TFT_WHITE, TFT_BLACK); + lcd.setFont(&fonts::DejaVu24); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString("Bitcoin Dashboard", SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 40); + lcd.setFont(&fonts::DejaVu18); + lcd.drawString("Starting...", SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2); + + loadConfig(); + + preferences.begin("wifi", true); + String ssid = preferences.getString("ssid", ""); + String pass = preferences.getString("password", ""); + preferences.end(); + + if (ssid.length() > 0) { + lcd.setFont(&fonts::DejaVu18); + lcd.drawString("WiFi: " + ssid, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 40); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid.c_str(), pass.c_str()); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED && attempts < 60) { + delay(500); + Serial.print("."); + attempts++; + } + + if (WiFi.status() == WL_CONNECTED) { + lcd.drawString("Connected! IP:", SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 80); + lcd.setFont(&fonts::DejaVu12); + lcd.drawString(WiFi.localIP().toString(), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 110); + + delay(1000); + // In setup(), after WiFi connection is established: +// Initialize MQTT module + mqttModule.begin("your-mqtt-broker-address", 1883, "username", "password"); + mqttModule.setTopic("financial/prices"); + // Connect to MQTT broker + mqttModule.connect(); + fetchAllHistory(); + lastRefresh = millis(); + lastHistoryRefresh = millis(); + drawDashboard(); + return; + fetchAllHistory(); + lastRefresh = millis(); + lastHistoryRefresh = millis(); + drawDashboard(); + return; + } + + lcd.drawString("WiFi failed", SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 80); + delay(2000); + } + + drawDashboard(); +} + +void drawFooterCountdown() { + unsigned long elapsed = millis() - lastRefresh; + int secondsLeft; + if (lastRefresh == 0) { + secondsLeft = 0; + } else { + secondsLeft = (REFRESH_INTERVAL - (elapsed % REFRESH_INTERVAL)) / 1000; + } + int mins = secondsLeft / 60; + int secs = secondsLeft % 60; + char timeStr[8]; + sprintf(timeStr, "%d:%02d", mins, secs); + + lcd.setFont(&fonts::DejaVu18); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setTextDatum(textdatum_t::middle_center); + lcd.drawString(String(timeStr), SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20); +} + +void loop() { + int32_t x, y; + + if (needsRefresh && currentScreen == SCREEN_DASHBOARD) { + needsRefresh = false; + drawDashboard(); + refreshDataAndShow(); + } + + if (lcd.getTouch(&x, &y)) { + switch (currentScreen) { + case SCREEN_DASHBOARD: + handleDashboardTouch(x, y); + break; + case SCREEN_CUSTOMIZE_STOCKS: + handleStockCustomizeTouch(x, y); + break; + case SCREEN_CUSTOMIZE_INDICES: + handleIndexCustomizeTouch(x, y); + break; + case SCREEN_WIFI_CONFIG: + handleWifiConfigTouch(x, y); + break; + case SCREEN_WIFI_PASSWORD: + handleWifiPasswordTouch(x, y); + break; + case SCREEN_HARD_MONEY_DETAIL: + handleHardMoneyTouch(x, y); + break; + } + + if (!needsRefresh) { + switch (currentScreen) { + case SCREEN_DASHBOARD: + updatePrices(); + lastRefresh = millis(); + drawDashboard(); + break; + case SCREEN_CUSTOMIZE_STOCKS: + drawStockCustomizeScreen(); + break; + case SCREEN_CUSTOMIZE_INDICES: + drawIndexCustomizeScreen(); + break; + case SCREEN_WIFI_CONFIG: + drawWifiConfigScreen(); + break; + case SCREEN_WIFI_PASSWORD: + drawWifiPasswordScreen(); + break; + case SCREEN_HARD_MONEY_DETAIL: + drawHardMoneyDetailScreen(); + break; + } + } + + delay(300); + } + +static unsigned long lastCountdownUpdate = 0; +if (currentScreen == SCREEN_DASHBOARD && millis() - lastCountdownUpdate >= 1000) { + lastCountdownUpdate = millis(); + drawFooterCountdown(); +} +if (currentScreen == SCREEN_HARD_MONEY_DETAIL && millis() - lastCountdownUpdate >= 1000) { + lastCountdownUpdate = millis(); + lcd.setFont(&fonts::DejaVu18); + lcd.setTextColor(TFT_WHITE, TFT_ORANGE); + lcd.setTextDatum(textdatum_t::middle_center); + unsigned long elapsed = millis() - lastRefresh; + int secondsLeft; + if (lastRefresh == 0) { + secondsLeft = 0; + } else { + secondsLeft = (REFRESH_INTERVAL - (elapsed % REFRESH_INTERVAL)) / 1000; + } + int mins = secondsLeft / 60; + int secs = secondsLeft % 60; + char timeStr[8]; + sprintf(timeStr, "%d:%02d", mins, secs); + lcd.drawString(String(timeStr), SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20); + } + + if (currentScreen == SCREEN_HARD_MONEY_DETAIL) { + if (hmNeedsRefresh || !btcHmLoaded || !goldHmLoaded || !silverHmLoaded) { + hmNeedsRefresh = false; + drawHardMoneyDetailScreen(); + fetchAllHmData(hmSparklinePeriod); + drawHardMoneyDetailScreen(); + } + unsigned long fetchInterval = (hmSparklinePeriod == 0) ? HM_FETCH_INTERVAL_DAILY : HM_FETCH_INTERVAL_HISTORICAL; + if (millis() - hmLastFetch > fetchInterval) { + fetchAllHmData(hmSparklinePeriod); + drawHardMoneyDetailScreen(); + } + } + + if (wifiConnecting) { + if (WiFi.status() == WL_CONNECTED) { + wifiConnecting = false; + if (!timeSynced) { + syncTime(); + } + if (currentScreen == SCREEN_DASHBOARD) { + drawDashboard(); + } + } else if (millis() - wifiConnectStartTime > WIFI_CONNECT_TIMEOUT) { + wifiConnecting = false; + if (currentScreen == SCREEN_DASHBOARD) { + drawDashboard(); + } + } + } + + if (currentScreen == SCREEN_DASHBOARD && millis() - lastHistoryRefresh > HISTORY_REFRESH_INTERVAL) { + fetchAllHistory(); + lastHistoryRefresh = millis(); + } + + if (currentScreen == SCREEN_DASHBOARD && millis() - lastRefresh > REFRESH_INTERVAL) { + showRefreshingMessage(); + // Update prices via MQTT instead of HTTP + // updatePrices(); // Commented out HTTP update + mqttModule.update(); + lastRefresh = millis(); + drawDashboard(); + } + + if (currentScreen == SCREEN_HARD_MONEY_DETAIL && millis() - lastRefresh > REFRESH_INTERVAL) { + // updatePrices(); // Commented out HTTP update + lastRefresh = millis(); + fetchAllHmData(hmSparklinePeriod); + drawHardMoneyDetailScreen(); + } + + // In the main loop, update MQTT module + mqttModule.update(); + + // Handle MQTT messages + + delay(100); +} diff --git a/src/mqtt_module.cpp b/src/mqtt_module.cpp new file mode 100644 index 0000000..8eda94a --- /dev/null +++ b/src/mqtt_module.cpp @@ -0,0 +1,132 @@ +/** + * MQTT Module Implementation + * Handles connection to MQTT broker and price updates + */ + +#include "mqtt_module.h" + +MQTTModule::MQTTModule() + : _port(1883), _lastReconnectAttempt(0), _initialized(false) { +} + +void MQTTModule::begin(const char* server, int port, const char* user, const char* pass) { + _server = String(server); + _port = port; + _user = String(user); + _pass = String(pass); + + _wifiClient.setInsecure(); + _mqttClient.setClient(_wifiClient); + _mqttClient.setBufferSize(MQTT_MAX_PACKET_SIZE); + _mqttClient.setCallback([this](char* t, byte* p, unsigned int l) { + this->_callback(t, p, l); + }); + + _mqttClient.setServer(_server.c_str(), _port); + _initialized = true; + + Serial.printf("[MQTT] Configured for %s:%d\n", _server.c_str(), _port); +} + +void MQTTModule::setTopic(const char* topic) { + _topic = String(topic); + Serial.printf("[MQTT] Topic set to: %s\n", _topic.c_str()); +} + +void MQTTModule::_callback(char* topic, byte* payload, unsigned int length) { + char buffer[MQTT_MAX_PACKET_SIZE]; + if (length >= MQTT_MAX_PACKET_SIZE) { + length = MQTT_MAX_PACKET_SIZE - 1; + } + + memcpy(buffer, payload, length); + buffer[length] = '\0'; + + Serial.printf("[MQTT] Received: %s\n", buffer); + _parsePriceData(buffer); +} + +void MQTTModule::_parsePriceData(const char* json) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, json); + + if (error) { + Serial.printf("[MQTT] JSON parse error: %s\n", error.c_str()); + return; + } + + bool updated = false; + + if (doc.containsKey("btc")) { + btcPrice = doc["btc"].as(); + Serial.printf("[MQTT] BTC: $%.2f\n", btcPrice); + updated = true; + } + + if (doc.containsKey("gold")) { + goldPrice = doc["gold"].as(); + Serial.printf("[MQTT] Gold: $%.2f\n", goldPrice); + updated = true; + } + + if (doc.containsKey("silver")) { + silverPrice = doc["silver"].as(); + Serial.printf("[MQTT] Silver: $%.2f\n", silverPrice); + updated = true; + } + + if (updated) { + needsRefresh = true; + Serial.println("[MQTT] Triggering screen refresh"); + } +} + +void MQTTModule::update() { + if (!_initialized) return; + + if (!_mqttClient.connected()) { + unsigned long now = millis(); + if (now - _lastReconnectAttempt > 5000) { + _lastReconnectAttempt = now; + reconnect(); + } + } else { + _mqttClient.loop(); + } +} + +bool MQTTModule::isConnected() { + return _mqttClient.connected(); +} + +void MQTTModule::reconnect() { + if (WiFi.status() != WL_CONNECTED) { + Serial.println("[MQTT] WiFi not connected, skipping reconnect"); + return; + } + + Serial.print("[MQTT] Connecting..."); + + String clientId = "esp32-financial-" + String((uint32_t)ESP.getEfuseMac(), HEX); + + if (_mqttClient.connect(clientId.c_str(), _user.c_str(), _pass.c_str())) { + Serial.println(" connected!"); + + if (_topic.length() > 0) { + if (_mqttClient.subscribe(_topic.c_str())) { + Serial.printf("[MQTT] Subscribed to: %s\n", _topic.c_str()); + } else { + Serial.println("[MQTT] Subscribe failed"); + } + } + } else { + Serial.printf(" failed (rc=%d)\n", _mqttClient.state()); + } +} + +void MQTTModule::disconnect() { + if (_mqttClient.connected()) { + _mqttClient.disconnect(); + Serial.println("[MQTT] Disconnected"); + } +} diff --git a/src/mqtt_module.h b/src/mqtt_module.h new file mode 100644 index 0000000..046eb06 --- /dev/null +++ b/src/mqtt_module.h @@ -0,0 +1,46 @@ +/** + * MQTT Module for Financial Dashboard + * Handles connection to MQTT broker and price updates + * Additive to existing firmware - uses needsRefresh flag + */ + +#ifndef MQTT_MODULE_H +#define MQTT_MODULE_H + +#include +#include +#include +#include +#include + +#define MQTT_MAX_PACKET_SIZE 2048 + +extern bool needsRefresh; +extern double btcPrice, goldPrice, silverPrice; + +class MQTTModule { +public: + MQTTModule(); + void begin(const char* server, int port, const char* user, const char* pass); + void setTopic(const char* topic); + void update(); + bool isConnected(); + void reconnect(); + void disconnect(); + +private: + WiFiClientSecure _wifiClient; + PubSubClient _mqttClient; + String _server; + int _port; + String _user; + String _pass; + String _topic; + unsigned long _lastReconnectAttempt; + bool _initialized; + + void _callback(char* topic, byte* payload, unsigned int length); + void _parsePriceData(const char* json); +}; + +#endif