/* SmartCharge UI (меню обновлённое) + идеальный энкодер на PCNT + МиллиОм (1S) Изменения: - Подсказки снизу только на главном экране. - Старт→Профиль: "Быстрый старт" или "Изменить параметры". - МиллиОм (1S): без профилей, один клик = измерение Voc/нагрузка (INA219 + FR120N). Пины: I2C OLED: SDA=21, SCL=22, addr 0x3C Encoder: S1=GPIO32, S2=GPIO33, Key=GPIO26 (модуль "GND S1 S2 Key 5V" -> 3V3) MOSFET: GPIO25 (FR120N модуль вход "PWM/EN") DS18B20: GPIO16 (опционально) */ #define USE_SH1106 1 // 1=SH1106, 0=SSD1306 #include #include #include #include "driver/pcnt.h" #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 // ---------- Пины ---------- #define ENC_A 32 // S1 (A/pulse) #define ENC_B 33 // S2 (B/dir) #define ENC_SW 26 // кнопка #define MOSFET_PIN 25 #define ONEWIRE_PIN 16 // ---------- Энкодер/PCNT ---------- #define ENC_DIR (+1) #define DETENT_DIV 1 // 1 фронт = 1 шаг (самая отзывчивая) #define FILTER_US 8 // аппаратный антидребезг PCNT (~макс 12.8 мкс) // Кнопка bool btnPressed=false, btnShort=false, btnLongRel=false; uint32_t btnDownAt=0; const uint32_t BTN_DB_MS=15, BTN_LONG_MS=700; void buttonUpdate(){ static bool lastRaw=true; static uint32_t lastT=0; bool raw = digitalRead(ENC_SW); // PULLUP: 1=отпущена, 0=нажата uint32_t now=millis(); if (raw!=lastRaw){ lastRaw=raw; lastT=now; } if (now-lastT < BTN_DB_MS) return; if (!raw && !btnPressed){ btnPressed=true; btnShort=false; btnLongRel=false; btnDownAt=now; } else if (raw && btnPressed){ btnPressed=false; if(now-btnDownAt>=BTN_LONG_MS) btnLongRel=true; else btnShort=true; } } inline bool isClick(){ if(btnShort){btnShort=false; return true;} return false; } inline bool isHold(){ if(btnLongRel){btnLongRel=false; return true;} return false; } void pcntInit(){ pcnt_config_t cfg = {}; cfg.pulse_gpio_num = (gpio_num_t)ENC_A; cfg.ctrl_gpio_num = (gpio_num_t)ENC_B; cfg.unit = PCNT_UNIT_0; cfg.channel = PCNT_CHANNEL_0; cfg.pos_mode = PCNT_COUNT_INC; // фронт A -> +1 cfg.neg_mode = PCNT_COUNT_DIS; // спад игнорируем cfg.lctrl_mode = PCNT_MODE_REVERSE; // B=LOW -> инверсия cfg.hctrl_mode = PCNT_MODE_KEEP; // B=HIGH -> keep pcnt_unit_config(&cfg); uint16_t ticks = (uint16_t)min(1023, (int)(FILTER_US * 80)); pcnt_set_filter_value(PCNT_UNIT_0, ticks); pcnt_filter_enable(PCNT_UNIT_0); pcnt_counter_pause(PCNT_UNIT_0); pcnt_counter_clear(PCNT_UNIT_0); pcnt_counter_resume(PCNT_UNIT_0); } long readDetents(){ static long accum = 0; int16_t cnt=0; pcnt_get_counter_value(PCNT_UNIT_0, &cnt); if (cnt) pcnt_counter_clear(PCNT_UNIT_0); accum += (long)cnt * ENC_DIR; long steps = accum / DETENT_DIV; if (steps) accum -= steps * DETENT_DIV; return steps; } int readRot(){ long d = readDetents(); if (!d) return 0; if (d > 3) d = 3; if (d < -3) d = -3; return (int)d; } // ---------- Сенсоры ---------- Adafruit_INA219 ina219; OneWire oneWire(ONEWIRE_PIN); DallasTemperature dallas(&oneWire); // ---------- Профили/меню ---------- 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; enum UiState { UI_HOME=0, UI_START_MODE, UI_START_PROFILE, UI_START_ACTION, UI_START_QUICK_CAP, UI_START_QUICK_RATEC, UI_START_QUICK_ITERM, UI_START_CONFIRM, UI_RINT_READY, UI_PROFILES_LIST, UI_PROFILE_EDIT_FIELD, UI_EDIT_CHEM, UI_EDIT_S, UI_EDIT_CAP, UI_EDIT_RATEC, UI_EDIT_ITERM, UI_SETTINGS, UI_SERVICE, UI_MESSAGE }; UiState ui=UI_HOME; int cursor=0, topIndex=0; enum StartMode { SM_CHARGE=0, SM_DISCHARGE, SM_RINT }; StartMode startMode = SM_CHARGE; int startProfileIdx = 0; uint16_t quickCap_mAh=2500; float quickRateC=0.5f, quickItermA=0.10f; // МиллиОм параметры (простые дефолты) uint16_t rintPulseMs = 250; uint8_t rintSamples = 12; float rLeads_mOhm = 0.0f; bool rintHasResult=false; float rint_Voc=0, rint_Vload=0, rint_I=0, rint_RmOhm=0, rint_Tc= NAN; // ---------- отрисовка ---------- void setFont(){ u8g2.setFont(MENU_FONT); } void drawHeader(const char* t){ setFont(); u8g2.setDrawColor(1); u8g2.drawBox(0,0,128,12); u8g2.setDrawColor(0); u8g2.setCursor(2,10); u8g2.print(t); u8g2.setDrawColor(1); } void drawMenuItem(int y, const String& text, bool sel){ setFont(); if (sel){ u8g2.drawBox(0,y-9,128,11); u8g2.setDrawColor(0); u8g2.setCursor(2,y); u8g2.print(text); u8g2.setDrawColor(1); } else { u8g2.setCursor(2,y); u8g2.print(text); } } // ---------- profiles load/save ---------- void loadProfiles(){ prefs.begin("sc", true); for(int i=0;i=n) cursor=0; } for(int i=0;i=n) cursor=0; } for(int i=0;i=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); } // Ни подсказок u8g2.sendBuffer(); if(isClick()){ // после выбора профиля — экран действия: быстрый старт или правка параметров ui=UI_START_ACTION; saveLastIndex(startProfileIdx); } if(isHold()){ ui=UI_START_MODE; cursor=0; } } void screenStartAction(){ u8g2.clearBuffer(); drawHeader("Действие"); const char* items[]={"Быстрый старт","Изменить параметры"}; int n=2; int r=readRot(); if(r){ cursor += r; if(cursor<0) cursor=n-1; if(cursor>=n) cursor=0; } for(int i=0;i0.001f)?p.ItermA:((p.chem==CHEM_LIION?0.05f:0.02f)*p.capacity_mAh/1000.0f); ui=UI_START_QUICK_CAP; } } if(isHold()){ ui=UI_START_PROFILE; } } // Быстрые параметры 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.setCursor(2, 22+10); u8g2.print(val); u8g2.print(" "); 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; } char b[16]; dtostrf(val,0,(step<0.1f?2:1),b); u8g2.setCursor(2, 22+10); u8g2.print(b); u8g2.sendBuffer(); } void screenStartQuickCap(){ editNumberInt(quickCap_mAh,100,10000,100,"Емкость (mAh)"); if(isClick()){ ui=UI_START_QUICK_RATEC; } if(isHold()){ ui=UI_START_ACTION; } } void screenStartQuickRateC(){ editNumberFloat(quickRateC,0.1f,1.0f,0.1f,"C-rate"); if(isClick()){ ui=UI_START_QUICK_ITERM; } if(isHold()){ ui=UI_START_ACTION; } } void screenStartQuickIterm(){ editNumberFloat(quickItermA,0.01f,5.0f,0.01f,"Iterm (A)"); if(isClick()){ ui=UI_START_CONFIRM; } if(isHold()){ ui=UI_START_ACTION; } } void screenStartConfirm(){ u8g2.clearBuffer(); drawHeader("Подтверждение"); Profile &p=profiles[startProfileIdx]; u8g2.setCursor(2,22); u8g2.print("Режим: "); u8g2.print(startMode==SM_CHARGE?"Заряд":"Разряд"); 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"); u8g2.sendBuffer(); if(isClick()){ ui=UI_MESSAGE; } // сюда позже прикрутим реальный старт if(isHold()){ ui=UI_START_ACTION; } } // --- МиллиОм (1S) --- float avgMany(std::function f, uint8_t n){ double s=0; for(uint8_t i=0;i 150) Tc = NAN; // 3) расчёт float dV = Voc - Vload; float R = (Iload > 0.01f) ? (dV / Iload) : 0.0f; // Ом float Rm = R*1000.0f - rLeads_mOhm; if (Rm < 0) Rm = 0; rint_Voc=Voc; rint_Vload=Vload; rint_I=Iload; rint_RmOhm=Rm; rint_Tc=Tc; rintHasResult=true; } void screenRintReady(){ u8g2.clearBuffer(); drawHeader("МиллиОм (1S)"); u8g2.setCursor(2,22); u8g2.print("Клик: измерить"); u8g2.setCursor(2,33); u8g2.print("Pulse="); u8g2.print(rintPulseMs); u8g2.print("ms N="); u8g2.print(rintSamples); u8g2.setCursor(2,44); u8g2.print("R_leads="); u8g2.print(rLeads_mOhm,1); u8g2.print(" mОм"); if (rintHasResult){ u8g2.setCursor(2,55); if (rint_I < 0.2f) { u8g2.print("I низкий, проверь контакты"); } else { /* ничего */ } } u8g2.sendBuffer(); if(isClick()){ runRintOnce(); // сразу показать результат: u8g2.clearBuffer(); drawHeader("Rвнутр (итог)"); u8g2.setCursor(2,22); u8g2.print("Rint="); u8g2.print(rint_RmOhm,1); u8g2.print(" мОм"); u8g2.setCursor(2,33); u8g2.print("Voc="); u8g2.print(rint_Voc,3); u8g2.print("В"); u8g2.setCursor(2,44); u8g2.print("Vld="); u8g2.print(rint_Vload,3); u8g2.print("В I="); u8g2.print(rint_I,3); u8g2.print("А"); if (!isnan(rint_Tc)){ u8g2.setCursor(2,55); u8g2.print("T="); u8g2.print(rint_Tc,1); u8g2.print("C"); } u8g2.sendBuffer(); delay(900); // чуть задержим, чтоб было видно результат } if(isHold()){ ui=UI_START_MODE; } } // --- Профили --- int profListCursor=0, editProfileIdx=0; enum EditField { EF_CHEM=0, EF_S, EF_CAP, EF_RATEC, EF_ITERM, EF_SAVE, EF_CANCEL }; EditField editField=EF_CHEM; void screenProfilesList(){ u8g2.clearBuffer(); drawHeader("Профили"); int n=NUM_PROFILES; int r=readRot(); if(r){ profListCursor += r; 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); } u8g2.sendBuffer(); if(isClick()){ editProfileIdx=profListCursor; editField=EF_CHEM; ui=UI_PROFILE_EDIT_FIELD; } if(isHold()){ 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; if(c<0) c=n-1; if(c>=n) c=0; editField=(EditField)c; } for(int i=0;i2) v=0; c=(uint8_t)v; } u8g2.setCursor(2, 22+10); u8g2.print(chemToStr(c)); u8g2.sendBuffer(); if(isClick()||isHold()) { 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; if(v<1) v=4; if(v>4) v=1; S=(uint8_t)v; } u8g2.setCursor(2,22+10); u8g2.print("S="); u8g2.print(S); u8g2.sendBuffer(); if(isClick()||isHold()) { ui=UI_PROFILE_EDIT_FIELD; } } void screenEditCap(){ u8g2.clearBuffer(); drawHeader("Емкость (mAh)"); int r=readRot(); if(r){ long nv=(long)profiles[editProfileIdx].capacity_mAh + (r>0?100:-100); if(nv<100) nv=100; if(nv>10000) nv=10000; profiles[editProfileIdx].capacity_mAh=(uint16_t)nv; } u8g2.setCursor(2,22+10); u8g2.print(profiles[editProfileIdx].capacity_mAh); u8g2.sendBuffer(); if(isClick()||isHold()) { ui=UI_PROFILE_EDIT_FIELD; } } void screenEditRateC(){ u8g2.clearBuffer(); drawHeader("C-rate"); int r=readRot(); if(r){ float nv=profiles[editProfileIdx].rateC+(r>0?0.1f:-0.1f); if(nv<0.1f) nv=0.1f; if(nv>1.0f) nv=1.0f; profiles[editProfileIdx].rateC=nv; } u8g2.setCursor(2,22+10); u8g2.print(profiles[editProfileIdx].rateC,1); u8g2.sendBuffer(); if(isClick()||isHold()) { ui=UI_PROFILE_EDIT_FIELD; } } void screenEditIterm(){ u8g2.clearBuffer(); drawHeader("Iterm (A)"); int r=readRot(); if(r){ float nv=profiles[editProfileIdx].ItermA+(r>0?0.01f:-0.01f); if(nv<0.01f) nv=0.01f; if(nv>5.0f) nv=5.0f; profiles[editProfileIdx].ItermA=nv; } u8g2.setCursor(2,22+10); u8g2.print(profiles[editProfileIdx].ItermA,2); u8g2.sendBuffer(); if(isClick()||isHold()) { ui=UI_PROFILE_EDIT_FIELD; } } // --- Настройки/Сервис (заглушки) --- void screenSettings(){ u8g2.clearBuffer(); drawHeader("Параметры"); u8g2.setCursor(2,26); u8g2.print("Позже: сеть/лог/экран"); u8g2.sendBuffer(); if(isClick()||isHold()) { ui=UI_HOME; cursor=0; } } void screenService(){ u8g2.clearBuffer(); drawHeader("Сервис"); u8g2.setCursor(2,26); u8g2.print("Позже: тесты/калибр."); u8g2.sendBuffer(); if(isClick()||isHold()) { ui=UI_HOME; cursor=0; } } // --- Сообщение (заглушка запуска charge/discharge) --- void screenMessage(){ u8g2.clearBuffer(); drawHeader("Сообщение"); u8g2.setCursor(2,26); if (startMode==SM_CHARGE) u8g2.print("Старт заряда (демо)"); else u8g2.print("Старт разряда (демо)"); u8g2.setCursor(2,38); u8g2.print("Дальше прикрутим логику"); u8g2.sendBuffer(); if(isClick()||isHold()){ ui=UI_HOME; cursor=0; } } // ---------- 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, INPUT_PULLUP); pinMode(ENC_B, INPUT_PULLUP); pinMode(ENC_SW, INPUT_PULLUP); pcntInit(); pinMode(MOSFET_PIN, OUTPUT); digitalWrite(MOSFET_PIN, LOW); if (!ina219.begin()){ Serial.println("INA219 not found!"); } dallas.begin(); loadProfiles(); u8g2.clearBuffer(); drawHeader("SmartCharge UI"); u8g2.setCursor(2,20); u8g2.print("OLED ок. PCNT ок."); u8g2.setCursor(2,32); u8g2.print("S1=32 S2=33 Key=26"); u8g2.setCursor(2,44); u8g2.print("Клик для входа"); u8g2.sendBuffer(); delay(600); } void loop(){ buttonUpdate(); switch(ui){ case UI_HOME: screenHome(); break; case UI_START_MODE: screenStartMode(); break; case UI_START_PROFILE: screenStartProfile(); break; case UI_START_ACTION: screenStartAction(); 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_RINT_READY: screenRintReady(); 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; } }