Загрузка данных
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>
#include <Preferences.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include "time.h"
const char* ap_ssid = "ESP32_Vape_Mod";
const char* ap_password = "vape12345678";
AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
#define TFT_SCL 13
#define TFT_SDA 12
#define TFT_DC 14
#define TFT_RST 27
#define TFT_CS 26
#define ENCODER_S1 25
#define ENCODER_S2 33
#define ENCODER_KEY 32
#define LED_PIN 18
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_SDA, TFT_SCL, TFT_RST);
Preferences prefs;
struct Theme { uint16_t bg; uint16_t text; uint16_t accent; uint16_t frame; };
Theme themes[3];
int current_theme_idx = 0;
uint16_t C_BG, C_TEXT, C_ACCENT, C_FRAME;
int watts = 40;
int old_watts = -1;
int puff_total = 0;
int puff_day = 0;
float coil_ohm = 0.18;
long puff_timer = 0;
bool is_vaping = false;
bool block_vape_after_menu = false;
int last_s1_state;
String old_time_str = "";
String current_status_str = "IDLE";
unsigned long last_key_press = 0;
int click_count = 0;
enum Mode { MAIN_SCREEN, MENU };
Mode current_mode = MAIN_SCREEN;
// Переменные для внутренних часов (без интернета)
unsigned long last_clock_update = 0;
int rtc_hour = 0;
int rtc_minute = 0;
int rtc_second = 0;
bool time_is_set = false;
// Веб-страница с кнопкой установки времени
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta name=viewport content="width=device-width, initial-scale=1">
<meta charset=utf-8>
<title>ESP32 Vape Dashboard</title>
<style>
body { font-family: Arial; text-align: center; background: #121212; color: #fff; margin: 0; padding: 20px; }
.card { background: #1e1e1e; padding: 20px; border-radius: 15px; box-shadow: 0 4px 10px rgba(0,0,0,0.5); max-width: 400px; margin: 20px auto; border: 2px solid #07E0; }
h1 { color: #07E0; margin-bottom: 5px; }
.val { font-size: 40px; font-weight: bold; color: #fff; margin: 10px 0; }
.unit { font-size: 20px; color: #07E0; }
.status { font-size: 24px; padding: 10px; border-radius: 8px; font-weight: bold; background: #2a2a2a; transition: 0.2s; margin-bottom: 15px; }
.vaping { background: #ff3333; color: white; animation: blink 1s infinite; }
.stats { display: flex; justify-content: space-around; margin-top: 20px; font-size: 14px; color: #aaa; margin-bottom: 20px; }
.btn { background: #07E0; color: #000; border: none; padding: 12px 20px; font-size: 16px; font-weight: bold; border-radius: 8px; cursor: pointer; width: 100%; transition: 0.2s; }
.btn:active { background: #059900; }
@keyframes blink { 0% {opacity: 1;} 50% {opacity: 0.5;} 100% {opacity: 1;} }
</style>
</head>
<body>
<div class="card">
<h1>VAPE MOD OS</h1>
<div id="status" class="status">ПОДКЛЮЧЕНИЕ...</div>
<div class="val"><span id="watts">--</span><span class="unit"> W</span></div>
<div style="font-size:18px; color:#888; margin-bottom: 15px;">Вольтаж: <span id="volt" style="color:#fff;">0.00</span> V</div>
<button class="btn" onclick="syncTime()">СИНХРОНИЗИРОВАТЬ ВРЕМЯ</button>
<div class="stats">
<div>Затяжек сегодня:<br><b id="p_day" style="color:#07E0; font-size:16px;">--</b></div>
<div>Всего затяжек:<br><b id="p_total" style="color:#07E0; font-size:16px;">--</b></div>
</div>
</div>
<script>
var gateway = `ws://${window.location.hostname}/ws`;
var websocket;
window.addEventListener('load', initWebSocket);
function initWebSocket() {
websocket = new WebSocket(gateway);
websocket.onopen = function(e) { document.getElementById('status').innerHTML = 'В СЕТИ'; };
websocket.onclose = function(e) { document.getElementById('status').innerHTML = 'ОТКЛЮЧЕНО'; setTimeout(initWebSocket, 2000); };
websocket.onmessage = function(e) {
var data = JSON.parse(e.data);
document.getElementById('watts').innerHTML = data.watts;
document.getElementById('volt').innerHTML = data.volt;
document.getElementById('p_day').innerHTML = data.p_day;
document.getElementById('p_total').innerHTML = data.p_total;
var st = document.getElementById('status');
st.innerHTML = data.status;
if(data.status === "VAPING...") { st.classList.add('vaping'); }
else { st.classList.remove('vaping'); }
};
}
// Отправка текущего времени телефона на вейп
function syncTime() {
var now = new Date();
var timeJson = JSON.stringify({
type: "set_time",
h: now.getHours(),
m: now.getMinutes(),
s: now.getSeconds()
});
websocket.send(timeJson);
alert("Время отправлено на вейп!");
}
</script>
</body>
</html>
)rawliteral";
void sendDataToPhone() {
float fake_volt = sqrt(watts * coil_ohm);
String json = "{";
json += "\"watts\":" + String(watts) + ",";
json += "\"volt\":" + String(fake_volt, 2) + ",";
json += "\"p_day\":" + String(puff_day) + ",";
json += "\"p_total\":" + String(puff_total) + ",";
json += "\"status\":\"" + current_status_str + "\"";
json += "}";
ws.textAll(json);
}
// Приём времени от телефона через WebSocket
void onWsEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len) {
if (type == WS_EVT_CONNECT) {
sendDataToPhone();
}
else if (type == WS_EVT_DATA) {
data[len] = '\0';
String message = String((char*)data);
// Проверяем, что пришла команда установки времени
if (message.indexOf("set_time") > 0) {
// Супер-простой разбор JSON вручную, чтобы не тащить тяжелые библиотеки
int h_idx = message.indexOf("\"h\":") + 4;
int m_idx = message.indexOf("\"m\":") + 4;
int s_idx = message.indexOf("\"s\":") + 4;
rtc_hour = message.substring(h_idx, message.indexOf(",", h_idx)).toInt();
rtc_minute = message.substring(m_idx, message.indexOf(",", m_idx)).toInt();
rtc_second = message.substring(s_idx, message.indexOf("}", s_idx)).toInt();
time_is_set = true;
last_clock_update = millis();
// Обновляем UI на экране вейпа
tft.fillRect(15, 8, 40, 10, C_BG);
old_time_str = "";
}
}
}
void loadTheme(int idx) {
C_BG = themes[idx].bg; C_TEXT = themes[idx].text; C_ACCENT = themes[idx].accent; C_FRAME = themes[idx].frame;
}
void drawStaticUI();
void updateWattsUI(bool force = false);
void drawVapingStatus(bool active);
void updateClockUI(bool force = false);
void setup() {
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
themes[0] = {0x0000, 0xFFFF, 0x07E0, 0x39E7};
themes[1] = {0x0000, 0xFFFF, 0xF6A0, 0x781F};
themes[2] = {0x0000, 0xFFFF, 0xF800, 0x7800};
prefs.begin("vape_stats", false);
puff_total = prefs.getInt("total", 0);
puff_day = prefs.getInt("day", 0);
current_theme_idx = prefs.getInt("theme", 0);
watts = prefs.getInt("watts", 40);
loadTheme(current_theme_idx);
tft.initR(INITR_BLACKTAB);
tft.setRotation(1);
tft.fillScreen(C_BG);
tft.setTextSize(1); tft.setTextColor(C_ACCENT); tft.setCursor(10, 35);
tft.print("STARTING AP WI-FI...");
WiFi.softAP(ap_ssid, ap_password);
IPAddress IP = WiFi.softAPIP();
tft.setCursor(10, 55); tft.print("IP: ");
tft.setTextColor(C_TEXT); tft.print(IP.toString());
delay(2000);
ws.onEvent(onWsEvent);
server.addHandler(&ws);
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
request->send_P(200, "text/html", index_html);
});
server.begin();
tft.fillScreen(C_BG);
pinMode(ENCODER_S1, INPUT_PULLUP);
pinMode(ENCODER_S2, INPUT_PULLUP);
pinMode(ENCODER_KEY, INPUT_PULLUP);
last_s1_state = digitalRead(ENCODER_S1);
drawStaticUI();
updateWattsUI(true);
}
void loop() {
unsigned long now = millis();
bool key_now = (digitalRead(ENCODER_KEY) == LOW);
static bool key_last = false;
ws.cleanupClients();
// Внутренний подсчет времени (тикаем каждую секунду)
if (time_is_set && (now - last_clock_update >= 1000)) {
last_clock_update += 1000;
rtc_second++;
if (rtc_second >= 60) { rtc_second = 0; rtc_minute++; }
if (rtc_minute >= 60) { rtc_minute = 0; rtc_hour++; }
if (rtc_hour >= 24) { rtc_hour = 0; }
updateClockUI();
}
if (block_vape_after_menu && key_now) {}
else if (block_vape_after_menu && !key_now) { block_vape_after_menu = false; }
// НАЖАТИЕ КНОПКИ
if (key_now && !key_last) {
if (current_mode == MAIN_SCREEN) {
if (now - last_key_press > 400) click_count = 0;
click_count++;
last_key_press = now;
if (click_count == 3) {
current_mode = MENU;
current_status_str = "IN MENU";
sendDataToPhone();
analogWrite(LED_PIN, 0);
tft.fillScreen(C_BG);
tft.setCursor(20, 40); tft.print("MENU OPENED");
click_count = 0;
delay(300);
}
} else {
block_vape_after_menu = true;
current_mode = MAIN_SCREEN;
current_status_str = "IDLE";
tft.fillScreen(C_BG); drawStaticUI(); updateWattsUI(true);
delay(300);
}
}
// ЛОГИКА ЗАТЯЖКИ
if (current_mode == MAIN_SCREEN && !block_vape_after_menu) {
if (key_now && (now - last_key_press > 400)) {
if (!is_vaping) {
is_vaping = true;
puff_timer = millis();
current_status_str = "VAPING...";
sendDataToPhone();
drawVapingStatus(true);
analogWrite(LED_PIN, map(watts, 5, 80, 15, 255));
} else {
float duration = (millis() - puff_timer) / 1000.0;
tft.fillRect(15, 100, 60, 12, C_BG); tft.setCursor(15, 100);
tft.setTextColor(C_TEXT); tft.print(duration, 1); tft.print(" sec");
}
delay(20);
} else if (!key_now && is_vaping) {
is_vaping = false;
analogWrite(LED_PIN, 0);
puff_total++; puff_day++;
prefs.putInt("total", puff_total); prefs.putInt("day", puff_day);
current_status_str = "IDLE";
sendDataToPhone();
tft.fillRect(12, 22, 136, 38, C_BG); tft.fillRect(15, 100, 80, 15, C_BG);
drawStaticUI(); updateWattsUI(true);
}
}
key_last = key_now;
// ВРАЩЕНИЕ ЭНКОДЕРА
int current_s1_state = digitalRead(ENCODER_S1);
if (current_s1_state != last_s1_state && current_s1_state == LOW) {
if (current_mode == MAIN_SCREEN) {
if (digitalRead(ENCODER_S2) != current_s1_state) watts = (watts >= 80) ? 80 : watts + 5;
else watts = (watts <= 5) ? 5 : watts - 5;
prefs.putInt("watts", watts);
updateWattsUI();
sendDataToPhone();
}
delay(5);
}
last_s1_state = current_s1_state;
}
void updateClockUI(bool force) {
if (current_mode != MAIN_SCREEN || is_vaping) return;
if (!time_is_set) {
tft.setCursor(15, 8); tft.setTextColor(C_FRAME); tft.setTextSize(1);
tft.print("--:--");
return;
}
char timeStringBuff[6];
sprintf(timeStringBuff, "%02d:%02d", rtc_hour, rtc_minute);
String new_time_str = String(timeStringBuff);
if (new_time_str != old_time_str || force) {
tft.fillRect(15, 8, 35, 10, C_BG);
tft.setCursor(15, 8); tft.setTextColor(C_FRAME); tft.setTextSize(1);
tft.print(new_time_str);
old_time_str = new_time_str;
}
}
void drawStaticUI() {
tft.drawRect(0, 0, tft.width(), tft.height(), C_FRAME);
tft.setCursor(15, 65); tft.setTextColor(C_FRAME); tft.print("COIL: ");
tft.print(coil_ohm); tft.print(" R");
tft.setCursor(100, 65); tft.print("PUFF");
tft.setCursor(100, 78); tft.setTextColor(C_ACCENT); tft.setTextSize(2); tft.print(puff_total);
updateClockUI(true);
}
void updateWattsUI(bool force) {
if (current_mode != MAIN_SCREEN) return;
if (watts == old_watts && !force) return;
tft.fillRect(12, 22, 110, 32, C_BG);
tft.setCursor(15, 25); tft.setTextColor(C_TEXT); tft.setTextSize(4); tft.print(watts);
tft.setTextSize(2); tft.setTextColor(C_ACCENT); tft.print(" W");
old_watts = watts;
}
void drawVapingStatus(bool active) {
if (active) {
tft.fillRect(12, 22, 136, 38, C_BG);
tft.setCursor(15, 30); tft.setTextColor(C_ACCENT); tft.setTextSize(3); tft.print("VAPING...");
}
}