
Wat ik heb gebouwd
Dit was een vrij belangrijke test: de multiplexer scheelt flink wat pinnen, waardoor een tekort aan GPIO’s geen probleem meer is. Het eerste prototype bewees dat één ESP32 twee encoders en twee OLED’s tegelijk aankan. Deze keer ging ik van twee naar drie modules, en in plaats van kale hoeken kreeg elke module een echte taak: H, S en V. Eén ESP32, één TCA9548A, drie SH1106-OLED’s en drie EC11-encoders, plus een WiFi-webpagina die de kleur live voorvertoont en met de knoppen meesynchroniseert in beide richtingen.
Het systeem doet vier dingen:
- Drie OLED’s tonen elk één HSV-kanaal: Hue (H), Saturation (S) en Value (V), elk met een voortgangsbalk in de actuele kleur.
- Drie encoders regelen de drie kanalen: aan de knop draaien en waarde plus balk bewegen mee.
- Een webpagina geeft de kleur live weer en synchroniseert met de hardware in beide richtingen.
- Een kant-en-klare hook voor NeoPixel later: het refresh-pad is zo gebouwd dat een LED-strip aan dezelfde HSV-state kan hangen.
Drie problemen maakten het interessant:
- Alle drie de OLED’s delen I2C-adres 0x3C, dus parallel op de bus kan niet zomaar.
- Drie encoders kosten zes GPIO’s, en ik wilde het pinbudget beheersbaar houden.
- WiFi-RF stoort op I2C, en de stroompiek bij zenden kan de board resetten.
Hardware
| Component | Aantal | Opmerkingen |
|---|---|---|
| ESP32-devboard | 1 | ESP32-D0WD-V3 |
| TCA9548A I2C-multiplexer | 1 | Standaardadres 0x70 |
| 1.3“ OLED + EC11-module | 3 | Driverchip is SH1106, geen SSD1306 |
| Dupont-draden | een handvol | Kort en dik wint van lang en dun |
| 5V 2A-voeding | 1 | Niet voeden vanaf een PC-USB-poort |
Let op: 1.3“ OLED’s zijn vrijwel altijd SH1106, alleen de 0.96“-versies zijn SSD1306. Verkeerde library betekent een wit scherm met af en toe een zwarte regel.
Bedrading
De OLED + EC11-module (9 pinnen, twee groepen)
OLED-zijde (4 pinnen, I2C):
| Opschrift | Betekenis | Aansluiting |
|---|---|---|
| VCC | Voeding | 3.3V |
| GND | Massa | GND |
| SDA | I2C-data | via TCA9548A-kanaal |
| SCL | I2C-klok | via TCA9548A-kanaal |
Encoder-zijde (5 pinnen, mechanische schakelaar):
| Opschrift | Betekenis | Aansluiting |
|---|---|---|
| TRA | Fase A | ESP32-GPIO |
| TRB | Fase B | ESP32-GPIO |
| CON | Common | Module trekt hem intern omhoog, mag los |
| PSH | Knopzijde 1 | Los als ongebruikt |
| BAK | Knopzijde 2 | Los als ongebruikt |
Mijn modules trekken CON/BAK intern omhoog naar 3.3V, dus er hoeft niets aangesloten te worden. Reageert die van jou niet met losse CON, leg CON dan aan GND.
ESP32 naar TCA9548A
| ESP32 | TCA9548A | Opmerkingen |
|---|---|---|
| GPIO 21 | SDA | Hoofd-I2C-data |
| GPIO 22 | SCL | Hoofd-I2C-klok |
| 3.3V | VIN | Voeding |
| GND | GND | Gedeelde massa |
| los | RST | Mag los, bij mij hangt hij los |
Over RST: active-low reset. De meeste modules hebben een interne pull-up, dus los laten werkt. Hij heeft hier de hele tijd los gehangen, zonder problemen.
TCA9548A naar de drie OLED’s
| Module | OLED SDA | OLED SCL |
|---|---|---|
| 1 | SD0 | SC0 |
| 2 | SD1 | SC1 |
| 3 | SD2 | SC2 |
Elke module: VCC naar 3.3V, GND naar GND.
ESP32 naar de drie encoders
| Module | TRA | TRB | CON | PSH | BAK |
|---|---|---|---|---|---|
| 1 | GPIO 25 | GPIO 18 | los | los | los |
| 2 | GPIO 26 | GPIO 27 | los | los | los |
| 3 | GPIO 4 | GPIO 16 | los | los | los |
Pinbudget
| Doel | GPIO’s | Aantal |
|---|---|---|
| I2C (SDA/SCL) | 21, 22 | 2 |
| Encoder module 1 | TRA→GPIO 25, TRB→GPIO 18 | 2 |
| Encoder module 2 | TRA→GPIO 26, TRB→GPIO 27 | 2 |
| Encoder module 3 | TRA→GPIO 4, TRB→GPIO 16 | 2 |
| Totaal | 8 |
Elke extra module kost twee GPIO’s (TRA, TRB). Die rekensom klopte prima.
De TCA9548A-truc
Alle drie de OLED’s antwoorden op 0x3C, dus parallel aansluiten geeft drie schermen die hetzelfde frame tonen. De TCA9548A vertakt één I2C-bus naar 8 kanalen, en je kiest het actieve kanaal door een bitmask naar adres 0x70 te schrijven:
Wire.beginTransmission(0x70);
Wire.write(1 << channel); // kanaal `channel` inschakelen
Wire.endTransmission();
Er geleidt maar één kanaal tegelijk, dus de drie 0x3C-OLED’s zien elkaar nooit.
De encoders gaan bewust niet door de multiplexer: een EC11 is een mechanische schakelaar die kale niveaus uitstuurt, geen I2C-device. De TCA9548A stuurt alleen SDA/SCL door, dus TRA/TRB gaan rechtstreeks naar ESP32-GPIO’s. Wie echt pinnen wil besparen, verhuist ze naar een ander soort module, een I2C GPIO-expander zoals de MCP23017. Maar elke module kost maar 2 GPIO’s en drie modules maar 6, en die heeft de ESP32 ruim liggen, dus een MCP23017 verdient pas boven de 5 modules zijn plek.
Encoderdecodering
Een EC11 geeft Gray-code op A/B: 11 → 10 → 00 → 01 → 11. Mechanische contacten stuiteren, en een simpele lookuptabel laat stappen vallen of verdubbelt ze. De Ben Buxton-state machine houdt 4 bits historie bij en telt per overgang richting op via een tabel met 16 waarden:
static const int8_t KNOB_DIR[16] = {
0, -1, 1, 0,
1, 0, 0, -1,
-1, 0, 0, 1,
0, 1, -1, 0
};
void IRAM_ATTR isrEnc1() {
uint8_t s = (digitalRead(ENC1_A) << 1) | digitalRead(ENC1_B);
encState[0] = ((encState[0] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[0]];
if (d) encDelta[0] += d;
}
Het stuiteren was daarna grotendeels weg.
Websynchronisatie

De pagina heeft drie sliders en een live kleurvoorbeeld, inclusief donker/licht-schakelaar, want op een lichte pagina ziet het voorbeeld er niet uit.
- Hardware → web: de pagina pollt elke 400ms GET /get en raakt de sliders alleen aan als de waarden echt verschillen.
- Web → hardware: elke slider-input vuurt GET /hsv?value=h,s,v, en de ESP32 ververst meteen alle drie de OLED’s.
- Conflictpreventie: de pagina onthoudt lastInteraction en slaat 800ms na gebruikersinput het pollen over, zodat een poll nooit door een lopende drag heen prikt.
De bugs
Bug 1: 1.3“ OLED’s zijn SH1106, geen SSD1306
Symptoom: scherm grotendeels wit, af en toe flitst er één regel zwarte tekst.
Oorzaak: het RAM van de SH1106 is 132 kolommen breed. Een SSD1306-library schrijft 128 kolommen en het hele frame komt verschoven binnen.
Oplossing: de Adafruit_SH110X-library en een Adafruit_SH1106G-object:
#include <Adafruit_SH110X.h>
Adafruit_SH1106G disp(128, 64, &Wire, -1);
disp.begin(0x3C, true); // true = de module heeft een eigen resetcircuit
Bug 2: zendpiek van WiFi reset de board
Symptoom: de seriële poort meldt rst:0x1 (POWERON_RESET), telkens 1-2 seconden nadat WiFi start.
Oorzaak: WiFi-zenden piekt rond de 500mA. Met drie OLED’s erbij kan de AMS1117-regulator op de board dat transiënt niet bijbenen.
Oplossingen, op volgorde van prioriteit:
- Zendvermogen verlagen (het effectiefst, kost niets):
11dBm is ruim genoeg voor een router een paar meter verderop.WiFi.setTxPower(WIFI_POWER_11dBm); - Voeden uit een 5V 2A-lader, nooit uit een PC-USB-poort.
- Een elco van 470µF-1000µF over 3.3V en GND om de pieken op te vangen.
- De drie OLED’s uit een eigen 3.3V-rail voeden, met gedeelde GND.
Bug 3: WiFi-RF-interrupts breken I2C
Symptoom: zodra WiFi verbindt, stoppen de OLED’s met verversen, reageren de encoders niet meer en doet de webpagina niets.
Oorzaak: de ESP32 zet WiFi standaard in power-save. De RF-interrupts hakken dwars door de I2C-timing heen.
Oplossing:
WiFi.setSleep(false); // WiFi power-save uitzetten
Bug 4: de RST-pin van de TCA9548A
RST is active-low reset. De meeste modules trekken hem intern omhoog, dus los laten is prima, en zo heeft hij hier de hele tijd gedraaid, volledig stabiel.
Doet de TCA9548A raar, check dan eerst de gebruikelijke verdachten: SDA/SCL omgewisseld, een losse dupont-draad, een draad waar iemand tegenaan is gelopen.
Bug 5: contact van encoderfase A versleten
Symptoom: module 1 springt tussen 0 en 359, netto delta nul. Modules 2 en 3 doen het normaal.
Oorzaak: het A- of B-contact van de EC11 is fysiek versleten. Met nog maar één fase die schakelt, registreert de state machine +1 en daarna -1, en de som valt weg.
Diagnose:
- Flash een mini-sketch die alleen de A/B-niveaus print.
- Draai aan de knop en check of beide fases 0/1 schakelen.
- Schakelt er maar één, wissel dan de A/B-draden om.
- Verhuist het probleem met de encoder mee, dan is de encoder dood. Verhuist het met de GPIO mee, dan is de GPIO of de draad het.
Oplossing: een reservemodule erin. Module 1 heeft dat uiteindelijk gekregen.
Resultaat
Seriële output bij het opstarten:
=== ESP32 HSV Controller (final) ===
OK OLED 1
OK OLED 2
OK OLED 3
Connecting to MyWiFi .....
WiFi OK. IP: 192.168.1.254
HTTP server on port 80
Ready.
Elke OLED toont de kanaalnaam, de actuele waarde en een voortgangsbalk in de kleur van de huidige HSV-waarde, alle drie de balken in dezelfde kleur. Interacties:
| Actie | Effect |
|---|---|
| Encoder 1 draaien | H verandert, OLED 1 en de webpagina volgen |
| Encoder 2 draaien | S verandert |
| Encoder 3 draaien | V verandert |
| Een webslider slepen | Alle drie de OLED’s verversen tegelijk |
| Pagina pollt elke 400ms | Encoderwijzigingen verschijnen vanzelf op de pagina |
Opschalen
| Modules | Aanpak |
|---|---|
| 1-5 | Rechtstreeks op GPIO’s, het simpelst |
| 6-8 | TCA9548A met alle 8 kanalen + 12 GPIO’s |
| 8+ | Een MCP23017 (16 GPIO’s over I2C) of een tweede TCA9548A |
De NeoPixel-hook: voeg in refreshAll() een call toe die de huidige HSV omzet naar RGB888 en naar de strip duwt.
#include <Adafruit_NeoPixel.h>
Adafruit_NeoPixel strip(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
void updateStrip() {
uint8_t r, g, b;
hsvToRgb888(hVal, sVal, vVal, r, g, b);
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(i, strip.Color(r, g, b));
}
strip.show();
}
De webpagina en de encoderpaden veranderen helemaal niet.
Volledige code
Libraries: Adafruit SH110X, Adafruit GFX Library, Adafruit BusIO (wordt automatisch geïnstalleerd). Board: ESP32 Dev Module.
/*
* ESP32 + TCA9548A + 3x (OLED + EC11) + WiFi web sync
* Final version
*/
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
// ==================== WiFi config ====================
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// ==================== I2C ====================
#define SDA_PIN 21
#define SCL_PIN 22
#define TCA_ADDR 0x70
// ==================== Encoders ====================
#define ENC1_A 25
#define ENC1_B 18
#define ENC2_A 26
#define ENC2_B 27
#define ENC3_A 4
#define ENC3_B 16
// ==================== OLED ====================
Adafruit_SH1106G disp1(128, 64, &Wire, -1);
Adafruit_SH1106G disp2(128, 64, &Wire, -1);
Adafruit_SH1106G disp3(128, 64, &Wire, -1);
WebServer server(80);
// ==================== HSV state ====================
int hVal = 0;
int sVal = 100;
int vVal = 100;
// ==================== Encoder state machine ====================
static const int8_t KNOB_DIR[16] = {
0, -1, 1, 0,
1, 0, 0, -1,
-1, 0, 0, 1,
0, 1, -1, 0
};
volatile uint8_t encState[3] = {0, 0, 0};
volatile int8_t encDelta[3] = {0, 0, 0};
// ==================== Web page HTML ====================
const char htmlPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=yes">
<title>HSV Controller</title>
<style>
:root{--bg:#000;--text:#f5f5f7;--card-bg:rgba(28,28,30,0.65);--slider-track:#3a3a3c;--slider-thumb:#0a84ff;--border:rgba(255,255,255,0.15);--shadow:rgba(0,0,0,0.5);}
body.light{--bg:#f2f2f7;--text:#1c1c1e;--card-bg:rgba(255,255,255,0.7);--slider-track:#c7c7cc;--slider-thumb:#007aff;--border:rgba(0,0,0,0.1);--shadow:rgba(0,0,0,0.08);}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background-color:var(--bg);color:var(--text);margin:0;padding:20px;display:flex;justify-content:center;align-items:center;min-height:100vh;transition:background-color .3s ease,color .3s ease;}
.container{width:100%;max-width:480px;background:var(--card-bg);backdrop-filter:blur(30px);-webkit-backdrop-filter:blur(30px);border-radius:32px;padding:30px 28px;box-shadow:0 20px 50px var(--shadow);border:1px solid var(--border);}
.color-preview{width:100%;height:130px;border-radius:20px;margin-bottom:24px;box-shadow:0 10px 30px rgba(0,0,0,.35);transition:background .12s linear;border:1px solid var(--border);}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;}
h1{font-size:22px;font-weight:600;margin:0;letter-spacing:-.4px;}
.theme-toggle{background:rgba(255,255,255,.15);border:none;border-radius:50%;width:42px;height:42px;font-size:18px;cursor:pointer;color:var(--text);display:flex;align-items:center;justify-content:center;transition:transform .2s;}
.theme-toggle:hover{transform:scale(1.08);}
.slider-group{margin:16px 0;}
.slider-group label{display:flex;align-items:center;justify-content:space-between;font-size:15px;font-weight:500;gap:8px;}
.slider-group span{min-width:60px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;}
input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:6px;border-radius:3px;background:var(--slider-track);outline:none;margin:8px 0;}
input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:24px;height:24px;border-radius:50%;background:var(--slider-thumb);border:2px solid rgba(255,255,255,.3);cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.3);}
input[type="range"]::-moz-range-thumb{width:22px;height:22px;border-radius:50%;background:var(--slider-thumb);border:2px solid rgba(255,255,255,.3);cursor:pointer;}
.info{margin-top:20px;font-size:13px;opacity:.75;text-align:center;font-weight:500;line-height:1.6;}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#30d158;margin-right:6px;vertical-align:middle;animation:pulse 2s infinite;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.4;}}
</style></head>
<body class="dark">
<div class="container">
<div class="header"><h1>HSV Control</h1><button class="theme-toggle" id="theme-toggle">☀️</button></div>
<div class="color-preview" id="preview"></div>
<div class="slider-group"><label>Hue <input id="h" type="range" min="0" max="360" value="0"><span id="h-val">0°</span></label></div>
<div class="slider-group"><label>Saturation <input id="s" type="range" min="0" max="100" value="100"><span id="s-val">100%</span></label></div>
<div class="slider-group"><label>Value <input id="v" type="range" min="0" max="100" value="100"><span id="v-val">100%</span></label></div>
<div class="info" id="info"><span class="status-dot"></span>RGB: 255,0,0</div>
</div>
<script>
let H=0,S=100,V=100,lastInteraction=0;
const preview=document.getElementById('preview'),info=document.getElementById('info');
const elH=document.getElementById('h'),elS=document.getElementById('s'),elV=document.getElementById('v');
const elHv=document.getElementById('h-val'),elSv=document.getElementById('s-val'),elVv=document.getElementById('v-val');
function hsvToRgb(h,s,v){s/=100;v/=100;let c=v*s,x=c*(1-Math.abs((h/60)%2-1)),m=v-c,r=0,g=0,b=0;if(h<60){r=c;g=x;}else if(h<120){r=x;g=c;}else if(h<180){g=c;b=x;}else if(h<240){g=x;b=c;}else if(h<300){r=x;b=c;}else{r=c;b=x;}return[Math.round((r+m)*255),Math.round((g+m)*255),Math.round((b+m)*255)];}
function updateUI(){const rgb=hsvToRgb(H,S,V);preview.style.background='rgb('+rgb[0]+','+rgb[1]+','+rgb[2]+')';info.innerHTML='<span class="status-dot"></span>RGB: '+rgb.join(',')+' | HSV: '+Math.round(H)+'°, '+S+'%, '+V+'%';if(document.activeElement!==elH)elH.value=H;if(document.activeElement!==elS)elS.value=S;if(document.activeElement!==elV)elV.value=V;elHv.textContent=Math.round(H)+'°';elSv.textContent=S+'%';elVv.textContent=V+'%';}
function sendHSV(){lastInteraction=Date.now();fetch('/hsv?value='+Math.round(H)+','+Math.round(S)+','+Math.round(V)).catch(()=>{});}
elH.addEventListener('input',function(){H=Number(this.value)%360;updateUI();sendHSV();});
elS.addEventListener('input',function(){S=Number(this.value);updateUI();sendHSV();});
elV.addEventListener('input',function(){V=Number(this.value);updateUI();sendHSV();});
setInterval(function(){if(Date.now()-lastInteraction<800)return;fetch('/get').then(r=>r.json()).then(d=>{if(d.h!==Math.round(H)||d.s!==S||d.v!==V){H=d.h;S=d.s;V=d.v;updateUI();}}).catch(()=>{});},400);
const themeToggle=document.getElementById('theme-toggle');const body=document.body;const savedTheme=localStorage.getItem('theme');
if(savedTheme==='light'){body.classList.remove('dark');body.classList.add('light');themeToggle.innerHTML='🌙';}
themeToggle.addEventListener('click',function(){if(body.classList.contains('dark')){body.classList.remove('dark');body.classList.add('light');themeToggle.innerHTML='🌙';localStorage.setItem('theme','light');}else{body.classList.remove('light');body.classList.add('dark');themeToggle.innerHTML='☀️';localStorage.setItem('theme','dark');}});
updateUI();
</script></body></html>
)rawliteral";
// ==================== TCA9548A ====================
bool tcaSelect(uint8_t ch) {
for (int i = 0; i < 3; i++) {
Wire.beginTransmission(TCA_ADDR);
Wire.write(1 << ch);
if (Wire.endTransmission() == 0) return true;
delayMicroseconds(100);
}
return false;
}
// ==================== HSV to RGB565 ====================
uint16_t hsvToRgb565(int h, int s, int v) {
float sf = s / 100.0f, vf = v / 100.0f, hf = h / 60.0f;
int i = (int)hf; float f = hf - i;
float p = vf * (1 - sf);
float q = vf * (1 - sf * f);
float t = vf * (1 - sf * (1 - f));
float r, g, b;
switch (i % 6) {
case 0: r=vf; g=t; b=p; break;
case 1: r=q; g=vf; b=p; break;
case 2: r=p; g=vf; b=t; break;
case 3: r=p; g=q; b=vf; break;
case 4: r=t; g=p; b=vf; break;
case 5: r=vf; g=p; b=q; break;
default: r=g=b=0;
}
return ((uint16_t)(r*31)<<11) | ((uint16_t)(g*63)<<5) | (uint16_t)(b*31);
}
// ==================== Draw one OLED ====================
void drawOne(Adafruit_SH1106G &disp, const char* label, int value,
int maxVal, uint16_t barColor) {
disp.clearDisplay();
disp.setTextSize(1);
disp.setTextColor(SH110X_WHITE);
disp.setCursor(0, 0);
disp.print(label);
disp.setTextSize(2);
disp.setCursor(0, 14);
disp.print(value);
disp.print(maxVal == 359 ? " deg" : " %");
disp.drawRect(0, 46, 128, 14, SH110X_WHITE);
int fillW = map(value, 0, maxVal, 0, 126);
if (fillW > 0) disp.fillRect(1, 47, fillW, 12, barColor);
disp.display();
}
void refreshAll() {
uint16_t color = hsvToRgb565(hVal, sVal, vVal);
if (tcaSelect(0)) drawOne(disp1, "Hue (H)", hVal, 359, color);
if (tcaSelect(1)) drawOne(disp2, "Saturation (S)", sVal, 100, color);
if (tcaSelect(2)) drawOne(disp3, "Value (V)", vVal, 100, color);
}
// ==================== Encoder ISRs ====================
void IRAM_ATTR isrEnc1() {
uint8_t s = (digitalRead(ENC1_A) << 1) | digitalRead(ENC1_B);
encState[0] = ((encState[0] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[0]];
if (d) encDelta[0] += d;
}
void IRAM_ATTR isrEnc2() {
uint8_t s = (digitalRead(ENC2_A) << 1) | digitalRead(ENC2_B);
encState[1] = ((encState[1] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[1]];
if (d) encDelta[1] += d;
}
void IRAM_ATTR isrEnc3() {
uint8_t s = (digitalRead(ENC3_A) << 1) | digitalRead(ENC3_B);
encState[2] = ((encState[2] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[2]];
if (d) encDelta[2] += d;
}
// ==================== Handle encoders ====================
void handleEncoders() {
int8_t d[3];
noInterrupts();
d[0] = encDelta[0]; encDelta[0] = 0;
d[1] = encDelta[1]; encDelta[1] = 0;
d[2] = encDelta[2]; encDelta[2] = 0;
interrupts();
bool changed = false;
if (d[0] != 0) {
hVal = (hVal + d[0] + 360) % 360;
changed = true;
}
if (d[1] != 0) {
sVal = constrain(sVal + d[1], 0, 100);
changed = true;
}
if (d[2] != 0) {
vVal = constrain(vVal + d[2], 0, 100);
changed = true;
}
if (changed) refreshAll();
}
// ==================== HTTP handlers ====================
void handleRoot() {
server.send_P(200, "text/html; charset=utf-8", htmlPage);
}
void handleHSV() {
if (server.hasArg("value")) {
String val = server.arg("value");
int p1 = val.indexOf(',');
int p2 = val.indexOf(',', p1 + 1);
if (p1 > 0 && p2 > p1) {
hVal = constrain(val.substring(0, p1).toInt(), 0, 359);
sVal = constrain(val.substring(p1 + 1, p2).toInt(), 0, 100);
vVal = constrain(val.substring(p2 + 1).toInt(), 0, 100);
refreshAll();
}
}
server.send(200, "text/plain", "OK");
}
void handleGet() {
char buf[64];
snprintf(buf, sizeof(buf), "{\"h\":%d,\"s\":%d,\"v\":%d}", hVal, sVal, vVal);
server.send(200, "application/json", buf);
}
// ==================== Setup ====================
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n=== ESP32 HSV Controller (final) ===");
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);
delay(100);
// --- Init the three OLEDs ---
Adafruit_SH1106G* disps[3] = { &disp1, &disp2, &disp3 };
for (int ch = 0; ch < 3; ch++) {
if (!tcaSelect(ch)) {
Serial.printf("!! tcaSelect(%d) FAIL\n", ch);
continue;
}
delay(20);
if (!disps[ch]->begin(0x3C, true)) {
Serial.printf("!! OLED %d begin FAIL\n", ch + 1);
} else {
Serial.printf("OK OLED %d\n", ch + 1);
// Self-test: show a big number
disps[ch]->clearDisplay();
disps[ch]->setTextSize(5);
disps[ch]->setTextColor(SH110X_WHITE);
char num[2] = { (char)('1' + ch), 0 };
disps[ch]->setCursor(50, 12);
disps[ch]->print(num);
disps[ch]->display();
}
}
delay(800);
// --- Three encoders ---
const uint8_t aPin[3] = {ENC1_A, ENC2_A, ENC3_A};
const uint8_t bPin[3] = {ENC1_B, ENC2_B, ENC3_B};
for (int i = 0; i < 3; i++) {
pinMode(aPin[i], INPUT_PULLUP);
pinMode(bPin[i], INPUT_PULLUP);
encState[i] = (digitalRead(aPin[i]) << 1) | digitalRead(bPin[i]);
}
attachInterrupt(digitalPinToInterrupt(ENC1_A), isrEnc1, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC1_B), isrEnc1, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC2_A), isrEnc2, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC2_B), isrEnc2, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC3_A), isrEnc3, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC3_B), isrEnc3, CHANGE);
// --- First real frame ---
refreshAll();
// --- Start WiFi ---
WiFi.mode(WIFI_STA);
WiFi.setSleep(false); // keep WiFi from interfering with I2C
WiFi.setTxPower(WIFI_POWER_11dBm); // keep the current spike from resetting the board
Serial.printf("Connecting to %s ", ssid);
WiFi.begin(ssid, password);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
delay(500);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print("WiFi OK. IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("WiFi failed");
}
delay(300);
refreshAll(); // refresh once more after WiFi settles
// --- HTTP routes ---
server.on("/", handleRoot);
server.on("/hsv", handleHSV);
server.on("/get", handleGet);
server.begin();
Serial.println("HTTP server on port 80");
Serial.println("Ready.");
}
// ==================== Loop ====================
uint32_t lastRefresh = 0;
void loop() {
server.handleClient();
handleEncoders();
// Force a full refresh every 3 seconds (against display drift)
if (millis() - lastRefresh > 3000) {
lastRefresh = millis();
refreshAll();
}
delay(3);
}
