/* SmartCharge - Меню (ESP32 + OLED + Encoder) [U8g2 + стабильный энкодер] - Драйвер OLED: USE_SH1106=1 для SH1106, 0 для SSD1306 - I2C: SDA=21, SCL=22, адрес 0x3C (проверен сканером) - Энкодер: A=GPIO32, B=GPIO33, BTN=GPIO26 (все с INPUT_PULLUP) - Подключение энкодера: общий (COM) -> GND; A -> 32; B -> 33. Кнопка: один контакт -> GND, второй -> 26. - Рекомендация: к A и B поставить по 10нФ на землю для тихого вращения. */ #define USE_SH1106 1 // 1 = SH1106, 0 = SSD1306 #include #include #include // ------------ OLED ------------ #if USE_SH1106 U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); #else U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); #endif #define MENU_FONT u8g2_font_6x12_t_cyrillic // ------------ ПИНЫ ЭНКОДЕРА ------------ const int ENC_A_PIN = 32; const int ENC_B_PIN = 33; const int ENC_BTN_PIN = 26; // ------------ УСТОЙЧИВАЯ ОБРАБОТКА ЭНКОДЕРА ------------ // Алгоритм: принимаем только валидные переходы и считаем «полный шаг» = 4 тика. // Плюс фильтр по времени, чтобы отсечь дребезг (микросекунды). volatile int16_t encSteps = 0; // целые клики (детенты) static uint8_t lastAB = 0; static int8_t accum = 0; static uint32_t lastEdgeUs = 0; const uint32_t ENC_DEBOUNCE_US = 500; // игнорируем переходы быстрее 0.5 мкс (защита от дребезга) const int8_t trans[4][4] = { /*from\to: 00 01 10 11 */ /*00*/ { 0, +1, -1, 0 }, /*01*/ { -1, 0, 0, +1 }, /*10*/ { +1, 0, 0, -1 }, /*11*/ { 0, -1, +1, 0 } }; inline void encoderUpdate() { // читаем сразу и A, и B (быстро) uint8_t a = (uint8_t)digitalRead(ENC_A_PIN); uint8_t b = (uint8_t)digitalRead(ENC_B_PIN); uint8_t ab = (a << 1) | b; // 0..3 if (ab == lastAB) return; // нет изменений uint32_t now = micros(); if (now - lastEdgeUs < ENC_DEBOUNCE_US) { // слишком быстро -> дребезг lastAB = ab; return; } lastEdgeUs = now; int8_t d = trans[lastAB & 0x03][ab & 0x03]; lastAB = ab; if (d == 0) return; // невалидный прыжок (дребезг) accum += d; // Полный шаг у обычного энкодера — 4 валидных перехода if (accum >= +4) { encSteps++; accum = 0; } else if (accum <= -4) { encSteps--; accum = 0; } } int16_t encoderReadAndClear() { noInterrupts(); int16_t v = encSteps; encSteps = 0; interrupts(); return v; } // ------------ КНОПКА (простой дебаунс) ------------ bool btnPressed = false; bool btnShortClick = false; bool btnLongReleased = false; uint32_t btnDownAt = 0; const uint32_t BTN_DEBOUNCE_MS = 15; const uint32_t BTN_LONG_MS = 800; void buttonUpdate() { static bool lastRaw = true; // INPUT_PULLUP -> покой = HIGH (true) static uint32_t lastChange = 0; bool raw = digitalRead(ENC_BTN_PIN); // HIGH=не нажата, LOW=нажата uint32_t now = millis(); if (raw != lastRaw) { // изменение lastRaw = raw; lastChange = now; } // дебаунс: ждём стабильности if (now - lastChange < BTN_DEBOUNCE_MS) return; if (!raw && !btnPressed) { // нажали btnPressed = true; btnShortClick = false; btnLongReleased = false; btnDownAt = now; } else if (raw && btnPressed) { // отпустили btnPressed = false; if (now - btnDownAt >= BTN_LONG_MS) btnLongReleased = true; else btnShortClick = true; } } bool isShortClick(){ if (btnShortClick){ btnShortClick=false; return true;} return false; } bool isLongPressReleased(){ if (btnLongReleased){ btnLongReleased=false; return true;} return false; } // ------------ ПРОФИЛИ/МЕНЮ (как раньше) ------------ enum : uint8_t { CHEM_LIION=0, CHEM_LFP=1, CHEM_LTO=2 }; static inline const char* chemToStr(uint8_t c){ switch(c){ case CHEM_LIION: return "Li-ion"; case CHEM_LFP: return "LiFePO4"; case CHEM_LTO: return "LTO"; } return "Li-ion"; } struct Profile { uint8_t chem; // CHEM_* uint8_t S; // 1..4 uint16_t capacity_mAh; // 100..10000 float rateC; // 0.1..1.0 float ItermA; // A }; Preferences prefs; const int NUM_PROFILES = 5; Profile profiles[NUM_PROFILES]; int lastProfileIndex = 0; void loadProfiles() { prefs.begin("sc", true); for (int i=0;i0)? +step : -step; } // --- screens void screenHome(){ u8g2.clearBuffer(); drawHeader("Главное меню"); const char* items[]={"Старт","Профили","Параметры","Сервис"}; int n=4; int r=readRot(); if(r){ cursor += (r>0?1:-1); if(cursor<0) cursor=n-1; if(cursor>=n) cursor=0; } for(int i=0;i0?1:-1); if(cursor<0) cursor=n-1; if(cursor>=n) cursor=0; } for(int i=0;i0?1:-1); if(startProfileIdx<0) startProfileIdx=n-1; if(startProfileIdx>=n) startProfileIdx=0; } if(startProfileIdx=topIndex+4) topIndex=startProfileIdx-3; for(int row=0; row<4; row++){ int i=topIndex+row; if(i>=n) break; String line="["+String(i+1)+"] "; line+=chemToStr(profiles[i].chem); line+=" S="+String(profiles[i].S)+" "+String(profiles[i].capacity_mAh)+"mAh"; drawMenuItem(12+(row+1)*11, line, i==startProfileIdx); } drawFooter("Нажми: правка | Держи: назад"); u8g2.sendBuffer(); if(isShortClick()){ Profile &p=profiles[startProfileIdx]; quickCap_mAh=p.capacity_mAh; quickRateC=p.rateC; quickItermA=(p.ItermA>0.001f)?p.ItermA:((p.chem==CHEM_LIION?0.05f:0.02f)*p.capacity_mAh/1000.0f); ui=UI_START_QUICK_CAP; saveLastIndex(startProfileIdx); } if(isLongPressReleased()){ ui=UI_START_MODE; cursor=0; } } void editNumberInt(uint16_t &val,uint16_t minV,uint16_t maxV,uint16_t step,const char* title){ u8g2.clearBuffer(); drawHeader(title); int r=readRot(); if(r){ long nv=(long)val + (r>0?step:-step); if(nvmaxV) nv=maxV; val=(uint16_t)nv; } u8g2.setFont(MENU_FONT); u8g2.setCursor(2, 22+10); u8g2.print(val); u8g2.print(" "); drawFooter("Нажми: ОК | Держи: отмена"); u8g2.sendBuffer(); } void editNumberFloat(float &val,float minV,float maxV,float step,const char* title){ u8g2.clearBuffer(); drawHeader(title); int r=readRot(); if(r){ float nv=val+(r>0?step:-step); if(nvmaxV) nv=maxV; val=nv; } u8g2.setFont(MENU_FONT); u8g2.setCursor(2, 22+10); char b[16]; dtostrf(val,0,(step<0.1f?2:1),b); u8g2.print(b); drawFooter("Нажми: ОК | Держи: отмена"); u8g2.sendBuffer(); } void screenStartQuickCap(){ editNumberInt(quickCap_mAh,100,10000,100,"Емкость (mAh)"); if(isShortClick()) ui=UI_START_QUICK_RATEC; if(isLongPressReleased()) ui=UI_START_PROFILE; } void screenStartQuickRateC(){ editNumberFloat(quickRateC,0.1f,1.0f,0.1f,"C-rate"); if(isShortClick()) ui=UI_START_QUICK_ITERM; if(isLongPressReleased()) ui=UI_START_PROFILE; } void screenStartQuickIterm(){ editNumberFloat(quickItermA,0.01f,5.0f,0.01f,"Iterm (A)"); if(isShortClick()) ui=UI_START_CONFIRM; if(isLongPressReleased()) ui=UI_START_PROFILE; } void screenStartConfirm(){ u8g2.clearBuffer(); drawHeader("Подтверждение"); Profile &p=profiles[startProfileIdx]; u8g2.setFont(MENU_FONT); u8g2.setCursor(2,22); u8g2.print("Режим: "); u8g2.print(startMode==SM_CHARGE?"Заряд":(startMode==SM_DISCHARGE?"Разряд":"МиллиОм")); u8g2.setCursor(2,33); u8g2.print("Профиль: "); u8g2.print(chemToStr(p.chem)); u8g2.print(" S="); u8g2.print(p.S); u8g2.setCursor(2,44); u8g2.print("Cap="); u8g2.print(quickCap_mAh); u8g2.print("mAh C="); u8g2.print(quickRateC,1); u8g2.setCursor(2,55); u8g2.print("Iterm="); u8g2.print(quickItermA,2); u8g2.print("A"); drawFooter("Нажми: ПУСК | Держи: назад"); u8g2.sendBuffer(); if(isShortClick()){ ui=UI_MESSAGE; } if(isLongPressReleased()){ ui=UI_START_PROFILE; } } void screenMessage(){ u8g2.clearBuffer(); drawHeader("Сообщение"); u8g2.setFont(MENU_FONT); u8g2.setCursor(2,26); u8g2.print("Старт (демо)."); u8g2.setCursor(2,38); u8g2.print("Дальше: алгоритм/логи"); drawFooter("Нажми: в меню"); u8g2.sendBuffer(); if(isShortClick() || isLongPressReleased()){ ui=UI_HOME; cursor=0; } } // Профили enum EditField { EF_CHEM=0, EF_S, EF_CAP, EF_RATEC, EF_ITERM, EF_SAVE, EF_CANCEL }; int profListCursor=0, editProfileIdx=0; EditField editField=EF_CHEM; void screenProfilesList(){ u8g2.clearBuffer(); drawHeader("Профили"); int n=NUM_PROFILES; int r=readRot(); if(r){ profListCursor += (r>0?1:-1); if(profListCursor<0) profListCursor=n-1; if(profListCursor>=n) profListCursor=0; } if(profListCursor=topIndex+4) topIndex=profListCursor-3; for(int row=0; row<4; row++){ int i=topIndex+row; if(i>=n) break; String line="["+String(i+1)+"] "; line+=chemToStr(profiles[i].chem); line+=" S="+String(profiles[i].S)+" "+String(profiles[i].capacity_mAh)+"mAh"; drawMenuItem(12+(row+1)*11, line, i==profListCursor); } drawFooter("Нажми: редакт. | Держи: назад"); u8g2.sendBuffer(); if(isShortClick()){ editProfileIdx=profListCursor; editField=EF_CHEM; ui=UI_PROFILE_EDIT_FIELD; } if(isLongPressReleased()){ ui=UI_HOME; cursor=0; } } void screenProfileEditField(){ u8g2.clearBuffer(); drawHeader("Редакт.профиля"); const char* items[]={"Химия","S (банок)","Емкость (mAh)","C-rate","Iterm (A)","Сохранить","Отмена"}; int n=7; int r=readRot(); if(r){ int c=(int)editField + (r>0?1:-1); if(c<0) c=n-1; if(c>=n) c=0; editField=(EditField)c; } for(int i=0;i0?1:-1); if(v<0) v=2; if(v>2) v=0; c=(uint8_t)v; } u8g2.setFont(MENU_FONT); u8g2.setCursor(2, 22+10); u8g2.print(chemToStr(c)); drawFooter("Нажми: ОК | Держи: отмена"); u8g2.sendBuffer(); if(isShortClick()||isLongPressReleased()) ui=UI_PROFILE_EDIT_FIELD; } void screenEditS(){ u8g2.clearBuffer(); drawHeader("S (1..4)"); uint8_t &S=profiles[editProfileIdx].S; int r=readRot(); if(r){ int v=(int)S+(r>0?1:-1); if(v<1) v=4; if(v>4) v=1; S=(uint8_t)v; } u8g2.setFont(MENU_FONT); u8g2.setCursor(2,22+10); u8g2.print("S="); u8g2.print(S); drawFooter("Нажми: ОК | Держи: отмена"); u8g2.sendBuffer(); if(isShortClick()||isLongPressReleased()) ui=UI_PROFILE_EDIT_FIELD; } void editNumberInt(uint16_t &val,uint16_t minV,uint16_t maxV,uint16_t step,const char* title); void editNumberFloat(float &val,float minV,float maxV,float step,const char* title); void screenEditCap(){ editNumberInt(profiles[editProfileIdx].capacity_mAh,100,10000,100,"Емкость (mAh)"); if(isShortClick()||isLongPressReleased()) ui=UI_PROFILE_EDIT_FIELD; } void screenEditRateC(){ editNumberFloat(profiles[editProfileIdx].rateC,0.1f,1.0f,0.1f,"C-rate"); if(isShortClick()||isLongPressReleased()) ui=UI_PROFILE_EDIT_FIELD; } void screenEditIterm(){ editNumberFloat(profiles[editProfileIdx].ItermA,0.01f,5.0f,0.01f,"Iterm (A)"); if(isShortClick()||isLongPressReleased()) ui=UI_PROFILE_EDIT_FIELD; } // Заглушки void screenSettings(){ u8g2.clearBuffer(); drawHeader("Параметры"); u8g2.setFont(MENU_FONT); u8g2.setCursor(2,26); u8g2.print("Позже: лог/экран/сеть"); drawFooter("Держи: назад"); u8g2.sendBuffer(); if(isShortClick()||isLongPressReleased()) ui=UI_HOME; } void screenService(){ u8g2.clearBuffer(); drawHeader("Сервис"); u8g2.setFont(MENU_FONT); u8g2.setCursor(2,26); u8g2.print("Позже: тесты/калибровка"); drawFooter("Держи: назад"); u8g2.sendBuffer(); if(isShortClick()||isLongPressReleased()) ui=UI_HOME; } // ------------ SETUP/LOOP ------------ void setup(){ Serial.begin(115200); Wire.begin(21,22); Wire.setClock(100000); u8g2.begin(); u8g2.enableUTF8Print(); u8g2.setI2CAddress(0x3C << 1); u8g2.setBusClock(100000); pinMode(ENC_A_PIN, INPUT_PULLUP); pinMode(ENC_B_PIN, INPUT_PULLUP); pinMode(ENC_BTN_PIN, INPUT_PULLUP); // Инициализируем lastAB текущим состояние контактов, чтобы избежать «скачка» при старте lastAB = ((uint8_t)digitalRead(ENC_A_PIN) << 1) | (uint8_t)digitalRead(ENC_B_PIN); loadProfiles(); // Экран приветствия + подсказка по пинам u8g2.clearBuffer(); drawHeader("SmartCharge UI"); u8g2.setFont(MENU_FONT); u8g2.setCursor(2,20); u8g2.print("Меню готово."); u8g2.setCursor(2,32); u8g2.print("Encoder A=32 B=33 BTN=26"); u8g2.setCursor(2,44); u8g2.print("COM/BTN GND, pullups вкл."); u8g2.sendBuffer(); delay(700); } void loop(){ // Регулярно опрашиваем энкодер и кнопку encoderUpdate(); buttonUpdate(); switch(ui){ case UI_HOME: screenHome(); break; case UI_START_MODE: screenStartMode(); break; case UI_START_PROFILE: screenStartProfile(); break; case UI_START_QUICK_CAP: screenStartQuickCap(); break; case UI_START_QUICK_RATEC: screenStartQuickRateC(); break; case UI_START_QUICK_ITERM: screenStartQuickIterm(); break; case UI_START_CONFIRM: screenStartConfirm(); break; case UI_PROFILES_LIST: screenProfilesList(); break; case UI_PROFILE_EDIT_FIELD: screenProfileEditField(); break; case UI_EDIT_CHEM: screenEditChem(); break; case UI_EDIT_S: screenEditS(); break; case UI_EDIT_CAP: screenEditCap(); break; case UI_EDIT_RATEC: screenEditRateC(); break; case UI_EDIT_ITERM: screenEditIterm(); break; case UI_SETTINGS: screenSettings(); break; case UI_SERVICE: screenService(); break; case UI_MESSAGE: screenMessage(); break; } // Без лишних задержек, чтобы не пропускать тики delay(1); }