Community project

Ceas LED Bluetooth

Photo of Ceas LED Bluetooth
Generated with AI

Cenusa Leonas

Published November 4, 2026

Build a Bluetooth-connected LED clock display that shows time, weather, air quality, and currency rates on a 32x32 pixel panel. This project uses an ESP32 microcontroller to receive data via Bluetooth, fetch real-time information from web services, and display it on the iPixel BLE panel with automatic timezone detection and alarm functionality.

This guide provides a complete parts list, wiring diagram for connecting the ESP32 to the LED panel, step-by-step assembly instructions, and the full firmware with web-based configuration. Configure your location for weather and air quality data, set your timezone, and customize alarm settings through the built-in web interface.

Wiring diagram

Wiring diagram for Ceas LED Bluetooth
Loading diagram…

Gather all the parts

QtyComponent
1

Panou iPixel BLE 32x32

Panou existent, alimentat separat prin intrarea originala. BLE 40:F3:AE:E8:E5:5B; comenzi GATT FA02.

Assemble it in 3 steps

1. Alimenteaza separat panoul

Foloseste intrarea, cablul si alimentatorul original al panoului. Nu lega panoul la pinii ESP32.

  • Nu alimenta matricea din pinul 3V3 sau 5V al placii; curentul mare poate supraincalzi placa.

2. Asaza placa langa panou

Alimenteaza ESP32 prin USB-C cu un cablu de date. Nu sunt necesare fire intre placa si panou. Camera nu este folosita.

  • Inchide aplicatia iPixel de pe telefon pentru a elibera conexiunea Bluetooth.

3. Configureaza locatia meteo

Deschide in browser adresa IP a ESP32 afisata in monitorul serial, de pe aceeasi retea Wi-Fi. Introdu latitudinea si longitudinea si apasa Salveaza locatia. Coordonatele raman salvate dupa repornire. Daca reteaua lipseste, conecteaza-te la Ceas-iPixel fara parola si deschide http://192.168.4.1 pentru configurare.

  • Panoul alterneaza aproximativ 20 de secunde de ceas cu 10 secunde de temperatura in grade Celsius si pictograma meteo. Vremea se actualizeaza la 15 minute si este ascunsa daca datele sunt mai vechi de o ora.
  • Pagina este in romana. Date meteo Open-Meteo, pentru uz necomercial. Hotspotul si pagina locala nu au parola; foloseste-le doar langa persoane de incredere.
  • Alarma vizuala se configureaza din sectiunea Alarma, dupa ora locala a coordonatelor. Implicit este dezactivata; butoanele Testeaza si Opreste permit verificarea. La alarma panoul trece la 100% pentru durata aleasa, apoi revine la paginile si luminozitatea normale. Nu exista sunet.
  • Atentie la sensibilitatea la lumina intermitenta. Alarma alterneaza culori intense la fiecare 0,7 secunde. Foloseste numai alimentatorul original al panoului, inclusiv la luminozitate maxima.

Review all connections

1. Connections between "panel" and "ESP32"

FunctionpanelESP32
powerUSB_POWER → Alimentatorul original al panoului, prin cablul originalEXT

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <NimBLEDevice.h>
#include <time.h>
#include <sys/time.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <vector>
#include <cmath>
#include "settings_navigation.h"

WebServer web(80);
Preferences prefs;
NimBLEClient* client=nullptr;
NimBLERemoteCharacteristic* output=nullptr;
uint32_t nextTry=0, lastSync=0;
int lastDay=-1;
bool fresh=false, setupAP=false;
const char* zone="UTC0";
int utcOffset=0;
String timezoneName="În curs de identificare";
bool alarmEnabled=false, alarmActive=false;
int alarmHour=7, alarmMinute=0, alarmDuration=60;
int alarmLastDate=-1;
uint32_t alarmStarted=0;
void startAlarm(){alarmActive=true;alarmStarted=millis();}
void stopAlarm();
void localAt(time_t now,tm* result){now+=utcOffset;gmtime_r(&now,result);}


double latitude=44.4268, longitude=26.1025;
bool locationSet=false, weatherValid=false, weatherScreen=false;
bool airEnabled=true, airValid=false;
float airIndex=0;
uint32_t nextAir=0, airAt=0;
String airError="Alege locația pentru calitatea aerului.";
bool eurEnabled=true, usdEnabled=true;
float eurRate=0,usdRate=0;
String rateDate,rateError="Curs încă indisponibil.";
uint32_t nextRates=0;
String airLabel(){
 if(airIndex<=20)return "BUN";
 if(airIndex<=40)return "ACCEPTABIL";
 if(airIndex<=60)return "MODERAT";
 if(airIndex<=80)return "SLAB";
 if(airIndex<=100)return "FOARTE SLAB";
 return "EXTREM DE SLAB";
}
float temperature=0;
int weatherCode=0;
int weatherMinutes=15; // 1 minute to 24 hours; persisted independently of page duration.
uint32_t nextWeather=0, weatherAt=0, screenAt=0;
String weatherError="Alege locația pentru vreme.";
// Persistent display preferences; the BLE frame format stays unchanged.
bool fields[]={true,false,false,true,true,true,false,true,true,true};
const char* fieldKeys[]={"clockOn","clockDay","clockDate","dateOn","dateDay","dateYear","dateCountry","weatherOn","weatherIcon","weatherTemp"};
const char* fieldLabels[]={"Pagina cu ora","Ziua săptămânii","Data sub ceas","Pagina cu data","Ziua săptămânii","Anul","Textul ROMANIA","Pagina cu vremea","Pictograma meteo","Temperatura"};
int dimStart=21,dimEnd=6,dimPercent=5,dayPercent=30;
int dateFormat=0;
bool dateCity=false;
String cityName;
uint32_t nextCity=0;
const char* fontKeys[]={"fontClock","fontClockDay","fontClockDate","fontDate","fontDateDay","fontYear","fontCity","fontTemp"};
const char* fontLabels[]={"Ora","Ziua de pe pagina orei","Data de sub ceas","Data","Ziua de pe pagina datei","Anul","Localitatea","Temperatura"};
int fontSizes[]={2,1,1,1,1,1,1,2};
String htmlSafe(String t){t.replace("&","&amp;");t.replace("<","&lt;");t.replace(">","&gt;");t.replace("\"","&quot;");t.replace("'","&#39;");return t;}
String panelText(String t){
 const char* from[]={"ă","â","î","ș","ş","ț","ţ","Ă","Â","Î","Ș","Ş","Ț","Ţ"};
 const char* to[]={"A","A","I","S","S","T","T","A","A","I","S","S","T","T"};
 for(int i=0;i<14;i++)t.replace(from[i],to[i]);t.toUpperCase();return t;
}
int pageSkins[]={0,0,0}, pageAligns[]={0,0,0}, pageBorders[]={0,0,0};
int pageSeconds[]={10,10,20}; // weather, date, clock
const uint32_t palettes[][3]={{0x1ed2ff,0x50ff64,0xffb414},{0x00ffff,0xff40c0,0xa070ff},{0xffa040,0xff6040,0xffdc80},{0xffffff,0xffffff,0xffffff},{0x40ff80,0x20c060,0x80ffb0},
 {0x4090ff,0x80dfff,0x20b0d0},{0xc080ff,0xff80d0,0x9060ff},{0xff4040,0xffa060,0xff7020},{0xffd040,0xfff0a0,0xffb020},{0xffa0c0,0xc0a0ff,0xa0e0ff},{0x30e0b0,0xb0ff90,0x60e0e0},{0xa0c0ff,0xe0f0ff,0x7090ff}};
constexpr int skinCount=sizeof(palettes)/sizeof(palettes[0]);
const char* skinNames[]={"Clasic (aspectul existent)","Neon · cyan și roz","Apus · culori calde","Alb · minimalist","Matrix · verde","Ocean · albastru","Lavandă · violet","Foc · roșu și portocaliu","Auriu · galben","Pastel · nuanțe delicate","Auroră · mentă","Gheață · albastru rece"};
int activePage=2;
bool displayChanged=true;
void stopAlarm(){alarmActive=false;displayChanged=true;screenAt=millis();}
String description(){
 if(weatherCode==0)return "Cer senin";
 if(weatherCode<=3)return "Parțial noros / înnorat";
 if(weatherCode<=48)return "Ceață";
 if(weatherCode<=67)return "Burniță / ploaie";
 if(weatherCode<=77)return "Ninsoare";
 if(weatherCode<=82)return "Averse de ploaie";
 if(weatherCode<=86)return "Averse de ninsoare";
 return "Furtună";
}
void startWeb(){
 web.on("/",HTTP_GET,[]{
 String h=R"HTML(<!doctype html><html lang="ro"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Configurare iPixel</title><style>body{font:16px sans-serif;background:#101827;color:#edf4ff;max-width:520px;margin:30px auto;padding:16px}section{background:#1e2c40;padding:20px;border-radius:16px;margin:16px 0}input,select,button{box-sizing:border-box;width:100%;padding:12px;margin:6px 0 14px;border-radius:8px;border:0}button{background:#56ddd3;color:#102030;font-weight:bold}small{color:#c3d1e4}</style><h1>Configurare iPixel</h1><section><h2>GPS · Locație meteo</h2><form method="POST" action="/location"><label>Latitudine<input name="lat" type="number" min="-90" max="90" step="any" value=")HTML";
 h+=String(latitude,6)+"\" required></label><label>Longitudine<input name='lon' type='number' min='-180' max='180' step='any' value='"+String(longitude,6)+"' required></label><button>Salvează locația</button></form><small>Introdu coordonatele locației dorite. Ora și luminozitatea urmează coordonatele salvate (nu există receptor GPS automat). Fusul: "+htmlSafe(timezoneName)+".</small></section>";
 h+="<section><h2>Afișaj și luminozitate</h2><form method='POST' action='/display'>";
 for(int i=0;i<10;i++){
 if(i==0)h+="<div data-page='ora'><h2>Ora · HH:MM</h2>";
 if(i==3)h+="</div><div data-page='data'><h2>Data</h2>";
 if(i==7)h+="</div><div data-page='vreme'><h2>Vremea</h2>";
 if(i==6)continue; // Retired fixed-country option; use GPS locality instead.
 h+="<label style='display:block'><input style='width:auto' type='checkbox' name='"+String(fieldKeys[i])+"'"+(fields[i]?" checked":"")+"> "+fieldLabels[i]+"</label>";
 }
 h+="<label>Actualizare vreme (minute, 1–1440)</label><div style='display:flex;gap:8px'><button type='button' style='width:60px' onclick=\"this.nextElementSibling.stepDown()\">−</button><input type='number' name='weatherMins' min='1' max='1440' step='1' required value='"+String(weatherMinutes)+"'><button type='button' style='width:60px' onclick=\"this.previousElementSibling.stepUp()\">+</button></div><small>De la 1 minut la 24 de ore (1440 minute). Recomandat: 15 minute. Datele furnizorului nu se schimbă neapărat la fiecare cerere. La eroare, reîncercarea se face după un minut. Intervalul nu schimbă durata paginii pe panou.</small>";
 h+="</div><div data-page='data'><h3>Format dată</h3><select name='dateFmt'>";
 const char* formats[]={"04/11","04 noiembrie","04 NOI"};
 for(int i=0;i<3;i++)h+="<option value='"+String(i)+"'"+(dateFormat==i?" selected":"")+">"+formats[i]+"</option>";
 h+="</select><label><input style='width:auto' type='checkbox' name='dateCity'"+String(dateCity?" checked":"")+"> Localitatea dată de GPS</label><p>Localitate GPS: "+htmlSafe(cityName.length()?cityName:"În curs de identificare")+"</p><small>Date geografice © OpenStreetMap contributors, ODbL. Numele este aproximat după coordonate.</small><h3>Mărimea textelor</h3><small>1–3. Textele lungi derulează. Înălțimea este adaptată spațiului disponibil pentru a nu suprapune rândurile.</small>";
 h+="</div>";
 for(int i=0;i<8;i++)h+=String("<div data-page='")+(i<3?"ora":i<7?"data":"vreme")+"'>"+"<label style='display:block'>"+String(fontLabels[i])+"</label><div style='display:flex;gap:8px'><button type='button' style='width:60px' onclick=\"this.nextElementSibling.stepDown()\">−</button><input type='number' min='1' max='3' required name='"+fontKeys[i]+"' value='"+String(fontSizes[i])+"'><button type='button' style='width:60px' onclick=\"this.previousElementSibling.stepUp()\">+</button></div></div>";
 h+="<div data-page='aer'><h2>Calitatea aerului exterior</h2><p>Indice european AQI: "+String(airValid?String((int)roundf(airIndex))+" · "+airLabel():"indisponibil")+"</p><p>"+htmlSafe(airError)+"</p><small>Coordonatele sunt cele din GPS. Actualizare la 15 minute. Date estimate de model, nu măsurate de ESP32. Valori mai mici = aer mai curat. 0–20 bun, 21–40 acceptabil, 41–60 moderat, 61–80 slab, 81–100 foarte slab, peste 100 extrem de slab. Date: Open-Meteo / Copernicus CAMS, CC BY 4.0. Ecranul afișează lipsa datelor după o oră fără actualizare. Durata ecranului: 10 secunde.</small></div>";
 h+="<div data-page='luminozitate'><h2>Luminozitate redusă</h2><small>Ore locale ale coordonatelor salvate. Dacă orele sunt egale, reducerea este dezactivată. Durata paginilor se modifică din Skin-uri.</small>";
 const char* names[]={"start","end","dim","day"};
 const char* labels[]={"De la ora (0–23)","Până la ora (0–23)","Luminozitate redusă (%)","Luminozitate în restul zilei (%)"};
 int values[]={dimStart,dimEnd,dimPercent,dayPercent};
 for(int i=0;i<4;i++)h+="<label>"+String(labels[i])+"<input type='number' required name='"+names[i]+"' min='"+(i<2?"0":"1")+"' max='"+(i<2?"23":"100")+"' value='"+String(values[i])+"'></label>";
 h+="</div><button>Salvează setările paginii</button></form></section>";
 char alarmTime[6];snprintf(alarmTime,sizeof(alarmTime),"%02d:%02d",alarmHour,alarmMinute);
 h+="<section><h2>Alarmă vizuală zilnică</h2><form method='POST' action='/alarm'><label><input style='width:auto' type='checkbox' name='enabled'"+String(alarmEnabled?" checked":"")+"> Activează alarma</label><label>Ora locală a coordonatelor salvate<input type='time' name='time' required value='"+String(alarmTime)+"'></label><label>Durată (secunde, 5–300)<input type='number' name='duration' min='5' max='300' required value='"+String(alarmDuration)+"'></label><button>Salvează alarma</button></form><form method='POST' action='/alarm-test'><button>Testează acum</button></form><form method='POST' action='/alarm-stop'><button>Oprește alarma</button></form><small>Fără sunet. Luminozitate 100%, clopoțel animat și culori schimbate la 0,7 secunde. Atenție: lumină intensă și alternantă; nu utiliza dacă ești sensibil la lumini intermitente. Setările rămân salvate.</small></section>";
 h+=R"HTML(<section><h2>Wi-Fi</h2><button id="scan" onclick="scanStart()">Scanează rețelele</button><p id="status"></p><select id="networks" aria-label="Rețele găsite" onchange="document.getElementById('ssid').value=this.value"><option value="">Alege o rețea</option></select><form method="POST" action="/save"><label>Nume rețea (2,4 GHz)<input id="ssid" name="ssid" maxlength="32" required></label><label>Parolă<input name="pass" type="password" maxlength="63" autocomplete="new-password" placeholder="Parola rețelei selectate"></label><button>Salvează Wi-Fi și repornește</button></form><small>Setările sunt păstrate după oprire. Fără conexiune, conectează-te la Ceas-iPixel și deschide 192.168.4.1.</small></section><script>
let savedSSID='';fetch('/wifi-status').then(r=>r.json()).then(d=>{savedSSID=d.ssid;document.getElementById('ssid').value=d.ssid;});
async function scanStart(){document.getElementById('scan').disabled=true;document.getElementById('status').textContent='Se caută rețele…';try{await fetch('/scan-start',{method:'POST'});setTimeout(scanPoll,1200);}catch(e){scanFail();}}
function scanFail(){document.getElementById('status').textContent='Scanarea a eșuat. Reîncearcă.';document.getElementById('scan').disabled=false;}
async function scanPoll(){try{let r=await fetch('/scan');let d=await r.json();if(d.busy){setTimeout(scanPoll,1200);return;}if(d.error){scanFail();return;}let sel=document.getElementById('networks');sel.replaceChildren(new Option('Alege o rețea',''));d.networks.forEach(n=>sel.add(new Option(n.ssid+' ('+n.rssi+' dBm'+(n.open?', fără parolă':'')+')',n.ssid)));document.getElementById('status').textContent=d.networks.length+' rețele găsite';document.getElementById('scan').disabled=false;}catch(e){scanFail();}}
</script>)HTML";
 h+="<section><h2>Skin-uri și aspect pe fiecare pagină</h2><form method='POST' action='/skin'>";
 const char* pageNames[]={"Vreme","Dată","Oră"};
 const char* aligns[]={"Centrat","Stânga","Dreapta"};
 const char* borders[]={"Fără decor","Puncte în colțuri","Colțuri în L"};
 for(int p=0;p<3;p++){
 h+="<fieldset><legend>"+String(pageNames[p])+"</legend><label>Skin<select name='skin"+String(p)+"'>";
 for(int i=0;i<skinCount;i++)h+="<option value='"+String(i)+"'"+(pageSkins[p]==i?" selected":"")+">"+skinNames[i]+"</option>";
 h+="</select></label><label>Aliniere texte scurte<select name='align"+String(p)+"'>";
 for(int i=0;i<3;i++)h+="<option value='"+String(i)+"'"+(pageAligns[p]==i?" selected":"")+">"+aligns[i]+"</option>";
 h+="</select></label><label>Decor în colțuri<select name='border"+String(p)+"'>";
 for(int i=0;i<3;i++)h+="<option value='"+String(i)+"'"+(pageBorders[p]==i?" selected":"")+">"+borders[i]+"</option>";
 h+="</select></label><label>Secunde pe pagină<input type='number' name='secs"+String(p)+"' min='3' max='120' required value='"+String(pageSeconds[p])+"'></label></fieldset>";
 }
 h+="<button>Salvează aspectul paginilor</button></form><small>Fiecare pagină are propriile culori, aliniere și decor. Se aplică fără repornire și rămân salvate după oprire. Fonturile, locația și luminozitatea nu se schimbă. Norii rămân gri. Pentru mai mult spațiu, alege fără decor.</small></section>";
 h+="<div id='screenChoices' hidden>";
 const char* screenKeys[]={"clockOn","dateOn","weatherOn","airOn","eurOn","usdOn"};
 const char* screenNames[]={"Oră","Dată","Vreme","Calitatea aerului","€ → lei (BNR)","$ → lei (BNR)"};
 bool screenValues[]={fields[0],fields[3],fields[7],airEnabled,eurEnabled,usdEnabled};
 for(int i=0;i<6;i++)h+="<label style='display:block'><input style='width:auto' type='checkbox' data-screen='"+String(screenKeys[i])+"'"+(screenValues[i]?" checked":"")+"> "+screenNames[i]+"</label>";
 h+="<p>Curs oficial BNR: "+htmlSafe(rateDate)+"</p><p>"+htmlSafe(rateError)+"</p><small>Actualizare la o oră. Ultimul curs publicat este păstrat inclusiv în weekend și fără Wi-Fi. Nu este curs de schimb comercial. 10 secunde pe pagină.</small>";
 h+="<p id='screenStatus' role='status'></p></div>";
 h+=settingsNavigation;h+="</html>";web.send(200,"text/html; charset=utf-8",h);
 });
 web.on("/screens",HTTP_POST,[]{
 fields[0]=web.hasArg("clockOn");fields[3]=web.hasArg("dateOn");fields[7]=web.hasArg("weatherOn");airEnabled=web.hasArg("airOn");
 for(int i:{0,3,7})prefs.putBool(fieldKeys[i],fields[i]);
 prefs.putBool("airOn",airEnabled);eurEnabled=web.hasArg("eurOn");usdEnabled=web.hasArg("usdOn");prefs.putBool("eurOn",eurEnabled);prefs.putBool("usdOn",usdEnabled);displayChanged=true;
 web.send(200,"text/plain; charset=utf-8","Ecrane salvate.");
 });
 web.on("/skin",HTTP_POST,[]{
 int values[3][4];const char* prefixes[]={"skin","align","border","secs"};
 for(int p=0;p<3;p++)for(int k=0;k<4;k++){
 String a=web.arg(String(prefixes[k])+String(p));bool valid=a.length()>0&&a.length()<=3;
 for(char c:a)if(c<'0'||c>'9')valid=false;
 int v=a.toInt();if(!valid||v<(k==3?3:0)||v>(k==0?skinCount-1:k==3?120:2)){web.send(400,"text/plain; charset=utf-8","Setare de aspect invalidă.");return;}
 values[p][k]=v;
 }
 for(int p=0;p<3;p++){
 pageSkins[p]=values[p][0];pageAligns[p]=values[p][1];pageBorders[p]=values[p][2];pageSeconds[p]=values[p][3];
 for(int k=0;k<4;k++)prefs.putInt((String(prefixes[k])+String(p)).c_str(),values[p][k]);
 }
 displayChanged=true;web.sendHeader("Location","/#skinuri");web.send(303);
 });
 web.on("/alarm",HTTP_POST,[]{
 String t=web.arg("time"),d=web.arg("duration");
 bool valid=t.length()==5 && t[2]==':';
 for(int i=0;i<int(t.length());i++)if(i!=2 && (t[i]<'0'||t[i]>'9'))valid=false;
 for(char c:d)if(c<'0'||c>'9')valid=false;
 int hh=t.substring(0,2).toInt(),mm=t.substring(3).toInt(),seconds=d.toInt();
 if(!valid||hh>23||mm>59||seconds<5||seconds>300){web.send(400,"text/plain","Ora sau durata invalida");return;}
 alarmEnabled=web.hasArg("enabled");alarmHour=hh;alarmMinute=mm;alarmDuration=seconds;
 prefs.putBool("alarmOn",alarmEnabled);prefs.putInt("alarmHour",hh);prefs.putInt("alarmMin",mm);prefs.putInt("alarmSecs",seconds);
 stopAlarm();web.sendHeader("Location","/");web.send(303);
 });
 web.on("/alarm-test",HTTP_POST,[]{startAlarm();web.sendHeader("Location","/");web.send(303);});
 web.on("/alarm-stop",HTTP_POST,[]{stopAlarm();web.sendHeader("Location","/");web.send(303);});
 web.on("/display",HTTP_POST,[]{
 String interval=web.arg("weatherMins");bool validInterval=interval.length()>0&&interval.length()<=4;
 for(char c:interval)if(c<'0'||c>'9')validInterval=false;
 int newWeatherMinutes=interval.toInt();
 if(!validInterval||newWeatherMinutes<1||newWeatherMinutes>1440){web.send(400,"text/plain; charset=utf-8","Interval meteo invalid: alege 1–1440 minute.");return;}
 const char* keys[]={"start","end","dim","day"};int v[4];
 for(int i=0;i<4;i++){
 String a=web.arg(keys[i]);bool valid=a.length()>0;
 for(char c:a)if(c<'0'||c>'9')valid=false;
 v[i]=a.toInt();if(!valid||v[i]<(i<2?0:1)||v[i]>(i<2?23:100)){web.send(400,"text/plain; charset=utf-8","Ore sau procente invalide.");return;}
 }
 int newFonts[8];
 for(int i=0;i<8;i++){String a=web.arg(fontKeys[i]);if(a.length()!=1 || a[0]<'1'||a[0]>'3'){web.send(400,"text/plain; charset=utf-8","Mărime font invalidă.");return;}newFonts[i]=a.toInt();}
 String fmt=web.arg("dateFmt");if(fmt.length()!=1||fmt[0]<'0'||fmt[0]>'2'){web.send(400,"text/plain","Format invalid");return;}
 bool f[10];for(int i=0;i<10;i++)f[i]=web.hasArg(fieldKeys[i]);

 if(f[7]&&!f[8]&&!f[9]){web.send(400,"text/plain; charset=utf-8","Alege pictograma sau temperatura pentru vreme.");return;}
 for(int i=0;i<10;i++){fields[i]=f[i];prefs.putBool(fieldKeys[i],f[i]);}
 dateFormat=fmt.toInt();dateCity=web.hasArg("dateCity");prefs.putInt("dateFmt",dateFormat);prefs.putBool("dateCity",dateCity);
 for(int i=0;i<8;i++){fontSizes[i]=newFonts[i];prefs.putInt(fontKeys[i],fontSizes[i]);}
 dimStart=v[0];dimEnd=v[1];dimPercent=v[2];dayPercent=v[3];
 prefs.putInt("dimStart",dimStart);prefs.putInt("dimEnd",dimEnd);prefs.putInt("dimPct",dimPercent);prefs.putInt("dayPct",dayPercent);
 if(weatherMinutes!=newWeatherMinutes){
 weatherMinutes=newWeatherMinutes;prefs.putInt("weatherMins",weatherMinutes);
 nextWeather=millis(); // Apply a changed interval immediately, then use the saved schedule.
 }
 displayChanged=true;
 web.sendHeader("Location","/");web.send(303);
 });
 web.on("/wifi-status",HTTP_GET,[]{
 JsonDocument doc;doc["ssid"]=prefs.getString("ssid","Supraveghere DIICOT");String json;serializeJson(doc,json);web.send(200,"application/json",json);
 });
 web.on("/scan-start",HTTP_POST,[]{
 if(WiFi.scanComplete()!=WIFI_SCAN_RUNNING){WiFi.scanDelete();WiFi.scanNetworks(true);}
 web.send(202,"application/json","{\"busy\":true}");
 });
 web.on("/scan",HTTP_GET,[]{
 int count=WiFi.scanComplete();JsonDocument doc;
 if(count==WIFI_SCAN_RUNNING)doc["busy"]=true;
 else if(count<0)doc["error"]=true;
 else {JsonArray list=doc["networks"].to<JsonArray>();for(int i=0;i<count;i++){
 String ssid=WiFi.SSID(i);if(ssid.isEmpty())continue;
 bool duplicate=false;for(JsonObject n:list)if(n["ssid"].as<String>()==ssid)duplicate=true;
 if(duplicate)continue;
 JsonObject n=list.add<JsonObject>();n["ssid"]=ssid;n["rssi"]=WiFi.RSSI(i);n["open"]=(WiFi.encryptionType(i)==WIFI_AUTH_OPEN);
 }}
 String json;serializeJson(doc,json);web.send(200,"application/json",json);
 });
 web.on("/location",HTTP_POST,[]{
 String a=web.arg("lat"), b=web.arg("lon"); a.replace(",",".");b.replace(",",".");
 char *e1,*e2; double la=strtod(a.c_str(),&e1),lo=strtod(b.c_str(),&e2);
 if(a.isEmpty()||b.isEmpty()||*e1||*e2||!std::isfinite(la)||!std::isfinite(lo)||la< -90||la>90||lo< -180||lo>180){web.send(400,"text/plain; charset=utf-8","Coordonate invalide.");return;}
 cityName="";prefs.putString("city","");nextCity=millis()+1500;displayChanged=true;
 latitude=la;longitude=lo;locationSet=true;weatherValid=false;nextWeather=millis();airValid=false;nextAir=millis();airError="Se descarcă datele pentru noua locație.";
 prefs.putDouble("lat",la);prefs.putDouble("lon",lo);prefs.putBool("location",true);
 weatherError="Locație salvată. Se descarcă vremea.";
 web.sendHeader("Location","/");web.send(303);
 });
 web.on("/save",HTTP_POST,[]{
 String ssid=web.arg("ssid"), pass=web.arg("pass");
 if(ssid.isEmpty() || ssid.length()>32 || pass.length()>63 || (pass.length()>0 && pass.length()<8)){web.send(400,"text/plain; charset=utf-8","Verifică numele rețelei și parola (minimum 8 caractere sau goală pentru o rețea deschisă).");return;}
 prefs.putString("ssid",ssid); prefs.putString("pass",pass);
 web.send(200,"text/plain; charset=utf-8","Salvat. ESP32 repornește. Reconectează telefonul la rețeaua ta.");
 delay(500); ESP.restart();
 });
 web.begin();
}
void startPortal(){
 setupAP=true;
 WiFi.mode(WIFI_AP_STA);
 WiFi.softAP("Ceas-iPixel");
 Serial.println("Configurare: Wi-Fi Ceas-iPixel, fără parolă, http://192.168.4.1");
}
void fetchCity(){
 if(!locationSet||cityName.length()||WiFi.status()!=WL_CONNECTED||(int32_t)(millis()-nextCity)<0)return;
 nextCity=millis()+3600000;
 WiFiClientSecure tls;tls.setInsecure();HTTPClient http;http.setTimeout(4000);http.setConnectTimeout(4000);
 String url="https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=10&accept-language=ro&lat="+String(latitude,6)+"&lon="+String(longitude,6);
 if(!http.begin(tls,url))return;
 http.addHeader("User-Agent","iPixel-ESP32-Clock/1.0 (personal coordinate display)");
 if(http.GET()==200){JsonDocument d;if(!deserializeJson(d,http.getString())){
 JsonObject a=d["address"];const char* keys[]={"city","town","village","municipality","hamlet","county"};
 for(const char* key:keys)if(a[key].is<const char*>()){cityName=a[key].as<String>();break;}
 if(cityName.length()){prefs.putString("city",cityName);displayChanged=true;}
 }}http.end();
}
void fetchRates(){
 if((!eurEnabled&&!usdEnabled)||WiFi.status()!=WL_CONNECTED||(int32_t)(millis()-nextRates)<0)return;
 nextRates=millis()+300000;
 WiFiClientSecure tls;tls.setInsecure();HTTPClient http;http.setTimeout(4000);http.setConnectTimeout(4000);
 if(!http.begin(tls,"https://curs.bnr.ro/nbrfxrates.xml"))return;
 rateError="BNR nu răspunde; păstrez ultimul curs publicat.";
 if(http.GET()==200){
 String xml=http.getString();
 auto value=[&](const char* currency){String tag=String("currency=\"")+currency+"\"";int a=xml.indexOf(tag);if(a<0)return 0.0f;int b=xml.indexOf('>',a),e=xml.indexOf('<',b+1);if(b<0||e<0)return 0.0f;return xml.substring(b+1,e).toFloat();};
 float e=value("EUR"),u=value("USD");int a=xml.indexOf("<Cube date=\"");String date=a<0?String():xml.substring(a+12,a+22);
 if(e>0&&u>0&&std::isfinite(e)&&std::isfinite(u)&&date.length()==10&&date[4]=='-'&&date[7]=='-'){
 eurRate=e;usdRate=u;rateDate=date;rateError="";nextRates=millis()+3600000;
 if(prefs.getString("rateDate","")!=date){prefs.putFloat("eurRate",e);prefs.putFloat("usdRate",u);prefs.putString("rateDate",date);}
 if(activePage==6||activePage==7)displayChanged=true;
 }
 }http.end();
}
void fetchAir(){
 if(!airEnabled || !locationSet || WiFi.status()!=WL_CONNECTED || (int32_t)(millis()-nextAir)<0)return;
 nextAir=millis()+60000;
 WiFiClientSecure tls;tls.setInsecure();HTTPClient http;http.setTimeout(4000);http.setConnectTimeout(4000);
 String url="https://air-quality-api.open-meteo.com/v1/air-quality?latitude="+String(latitude,6)+"&longitude="+String(longitude,6)+"&current=european_aqi";
 if(!http.begin(tls,url))return;
 airError="Serviciul de aer nu răspunde. Reîncerc într-un minut.";
 if(http.GET()==200){JsonDocument d;if(!deserializeJson(d,http.getString()) && d["current"]["european_aqi"].is<float>()){
 float value=d["current"]["european_aqi"].as<float>();
 if(std::isfinite(value)&&value>=0){airIndex=value;airValid=true;airAt=millis();airError="";nextAir=millis()+900000;if(activePage==5)displayChanged=true;}
 }}http.end();
}
void fetchWeather(){
 if(!locationSet || WiFi.status()!=WL_CONNECTED || (int32_t)(millis()-nextWeather)<0)return;
 nextWeather=millis()+60000;
 WiFiClientSecure tls;
 // Public weather data only; no credentials are sent to this endpoint.
 tls.setInsecure();
 HTTPClient http;http.setTimeout(5000);http.setConnectTimeout(5000);
 String url="https://api.open-meteo.com/v1/forecast?latitude="+String(latitude,6)+"&longitude="+String(longitude,6)+"&current=temperature_2m,weather_code&timezone=auto";
 if(!http.begin(tls,url)){weatherError="Nu pot deschide serviciul meteo.";return;}
 int status=http.GET();
 if(status==200){
 JsonDocument doc;
 auto error=deserializeJson(doc,http.getString());
 if(!error && doc["current"]["temperature_2m"].is<float>() && doc["current"]["weather_code"].is<int>()){
 if(doc["utc_offset_seconds"].is<int>()){utcOffset=doc["utc_offset_seconds"].as<int>();timezoneName=doc["timezone"].as<String>();prefs.putInt("utcOffset",utcOffset);prefs.putString("tzName",timezoneName);displayChanged=true;}
 temperature=doc["current"]["temperature_2m"].as<float>();weatherCode=doc["current"]["weather_code"].as<int>();
 weatherValid=true;weatherAt=millis();weatherError="";nextWeather=millis()+uint32_t(weatherMinutes)*60000UL;
 }else weatherError="Răspuns meteo invalid. Reîncerc într-un minut.";
 }else weatherError="Serviciul meteo nu răspunde. Reîncerc într-un minut.";
 http.end();
}
// Some routers block NTP (UDP port 123). HTTPS Date is a fallback only.
void recoverTime(){
 static uint32_t nextAttempt=0;
 if(time(nullptr)>=1700000000 || WiFi.status()!=WL_CONNECTED)return;
 if((int32_t)(millis()-nextAttempt)<0)return;
 nextAttempt=millis()+30000;
 WiFiClientSecure tls;
 // Public time only; this fallback, like NTP, is not authenticated.
 tls.setInsecure();
 HTTPClient http;
 http.setConnectTimeout(3000);http.setTimeout(3000);
 if(!http.begin(tls,"https://www.google.com/generate_204"))return;
 const char* headers[]={"Date"};http.collectHeaders(headers,1);
 int status=http.GET();
 String date=http.header("Date");
 http.end();
 tm utc={};
 if(status>0 && strptime(date.c_str(),"%a, %d %b %Y %H:%M:%S GMT",&utc)){
 // Convert the GMT header without changing the process's Romanian timezone.
 const int monthDays[]={0,31,59,90,120,151,181,212,243,273,304,334};
 int year=utc.tm_year+1900;
 if(year<2024 || year>2099)return;
 auto leaps=[](int y){return y/4-y/100+y/400;};
 int64_t days=365LL*(year-1970)+leaps(year-1)-leaps(1969)+monthDays[utc.tm_mon]+utc.tm_mday-1;
 if(utc.tm_mon>1 && year%4==0 && (year%100!=0 || year%400==0))days++;
 timeval tv={};tv.tv_sec=days*86400+utc.tm_hour*3600+utc.tm_min*60+utc.tm_sec;
 settimeofday(&tv,nullptr);
 Serial.println("Ora sincronizată prin HTTPS; pornesc afișarea.");
 }else Serial.println("Ora încă indisponibilă; reîncerc sincronizarea în 30 secunde.");
}
bool sendPacket(const uint8_t* bytes,size_t size){
 if(!client || !client->isConnected() || !output) return false;
 bool ok=output->writeValue(bytes,size,!output->canWriteNoResponse());
 delay(output->canWriteNoResponse()?3:1);
 if(!ok){Serial.println("Eroare trimitere BLE");client->disconnect();output=nullptr;}
 return ok;
}
bool connectPanel(){
 output=nullptr;
 if(!client) client=NimBLEDevice::createClient();
 client->setConnectTimeout(5000);
 // The supplied address is a public BLE address. Retry as random if necessary.
 bool ok=client->connect(NimBLEAddress("40:F3:AE:E8:E5:5B",0));
 if(!ok) ok=client->connect(NimBLEAddress("40:F3:AE:E8:E5:5B",1));
 if(!ok){Serial.println("Panoul nu răspunde. Închide aplicația iPixel de pe telefon.");return false;}
 for(auto svc:client->getServices(true)){
   auto chr=svc->getCharacteristic(NimBLEUUID("0000fa02-0000-1000-8000-00805f9b34fb"));
   if(chr && (chr->canWrite() || chr->canWriteNoResponse())){output=chr;break;}
 }
 if(!output){Serial.println("FA02 lipsește: protocol incompatibil?");client->disconnect();return false;}
 fresh=true;
 Serial.println("Conectat prin Bluetooth; afișare RGB 32x32 după sincronizarea orei.");
 return true;
}
// Raw RGB frames, matching DonKracho's 32x32 framebuffer protocol.
uint32_t crc32(const uint8_t* d,size_t n){uint32_t c=0xffffffff;while(n--){c^=*d++;for(int i=0;i<8;i++)c=(c>>1)^((c&1)?0xedb88320:0);}return ~c;}
void le32(std::vector<uint8_t>& v,uint32_t n){for(int i=0;i<4;i++)v.push_back(n>>(8*i));}
bool showWeather(int details, int brightness){

 int page=constrain(details,0,2);
 int skin=pageSkins[page], textAlign=pageAligns[page], borderStyle=pageBorders[page];
 uint8_t bits[32][4]={};
 auto pixel=[&](int x,int y){if(x>=0&&x<32&&y>=0&&y<32)bits[y][x/8]|=0x80>>(x%8);};
 if(details==1 || details==2 || details==5 || details==6 || details==7){
 // Compact uppercase font: panel labels use ASCII for reliable tiny glyphs.
 const char* alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-/:";
 const uint8_t font[][5]={
 {2,5,7,5,5},{6,5,6,5,6},{3,4,4,4,3},{6,5,5,5,6},
 {7,4,6,4,7},{7,4,6,4,4},{3,4,5,5,3},{5,5,7,5,5},
 {7,2,2,2,7},{1,1,1,5,2},{5,5,6,5,5},{4,4,4,4,7},
 {5,7,7,5,5},{5,7,7,7,5},{2,5,5,5,2},{6,5,6,4,4},
 {2,5,5,7,3},{6,5,6,5,5},{3,4,2,1,6},{7,2,2,2,2},
 {5,5,5,5,7},{5,5,5,5,2},{5,5,7,7,5},{5,5,2,5,5},
 {5,5,2,2,2},{7,1,2,4,7},
 {7,5,5,5,7},{2,6,2,2,7},{7,1,7,4,7},{7,1,7,1,7},
 {5,5,7,1,1},{7,4,7,1,7},{7,4,7,5,7},{7,1,1,1,1},
 {7,5,7,5,7},{7,5,7,1,7},{0,0,0,0,2},{0,0,7,0,0},{1,1,2,4,4},{0,2,0,2,0}};
 auto line=[&](String text,int y,int size,int height){
 text=panelText(text);bool clockLine=(details==2 && y==10);
 int width=clockLine?(13*size+4):(int(text.length())*4-1)*size;
 int x=textAlign==1?0:textAlign==2?32-width:(textAlign==1?0:textAlign==2?32-width:(32-width)/2);
 if(width>32){int cycle=width+40; x=32-int((millis()-screenAt)/180%cycle);}
 int sy=std::min(size,std::max(1,height/5));
 for(char c:text){const char* g=strchr(alphabet,c);if(g){int i=g-alphabet;for(int r=0;r<5;r++)for(int k=0;k<3;k++)if(font[i][r]&(4>>k))for(int dy=0;dy<sy;dy++)for(int dx=0;dx<size;dx++)pixel(x+(clockLine && c==':'?0:k*size)+dx,y+r*sy+dy);}x+=clockLine?((c==':'||c==' ')?size+1:3*size+1):4*size;}
 };
 time_t now=time(nullptr);tm t;localAt(now,&t);
 const char* days[]={"DUMINICA","LUNI","MARTI","MIERCURI","JOI","VINERI","SAMBATA"};
 const char* months[]={"IAN","FEB","MAR","APR","MAI","IUN","IUL","AUG","SEP","OCT","NOI","DEC"};
 const char* fullMonths[]={"IANUARIE","FEBRUARIE","MARTIE","APRILIE","MAI","IUNIE","IULIE","AUGUST","SEPTEMBRIE","OCTOMBRIE","NOIEMBRIE","DECEMBRIE"};
 char date[32];
 if(dateFormat==0)snprintf(date,sizeof(date),"%02d/%02d",t.tm_mday,t.tm_mon+1);
 else snprintf(date,sizeof(date),"%02d %s",t.tm_mday,dateFormat==1?fullMonths[t.tm_mon]:months[t.tm_mon]);
 if(details==6 || details==7){
 // Draw currency symbols directly: UTF-8 is not supported by the tiny ASCII font.
 const uint8_t euro[]={14,17,16,30,16,30,17,14,0};
 const uint8_t dollar[]={4,15,20,20,14,5,5,30,4};
 const uint8_t* symbol=details==6?euro:dollar;
 for(int r=0;r<9;r++)for(int c=0;c<5;c++)if(symbol[r]&(16>>c))pixel(13+c,r);
 float rate=details==6?eurRate:usdRate;
 line(rate>0?String(rate,4):"--",12,1,7);
 line("LEI",24,1,7);
 }else if(details==5){
 bool available=airValid && millis()-airAt<3600000UL;
 line("AER AQI",0,1,7);
 line(available?String((int)roundf(airIndex)):"--",10,2,12);
 line(available?airLabel():"FARA DATE",25,1,7);
 }else if(details==2){
 if(fields[1])line(days[t.tm_wday],0,fontSizes[1],9);
 char clock[6];snprintf(clock,sizeof(clock),"%02d%c%02d",t.tm_hour,([](){timeval tv;gettimeofday(&tv,nullptr);return tv.tv_usec<500000;})()?':':' ',t.tm_min);
 line(clock,10,fontSizes[0],14);
 if(fields[2])line(date,25,fontSizes[2],7);
 }else{
 int count=1+int(fields[4])+int(fields[5])+int(dateCity);
 int slot=32/count,row=0;
 auto add=[&](String text,int font){line(text,row*slot,fontSizes[font],slot);row++;};
 if(fields[4])add(days[t.tm_wday],4);
 add(date,3);
 if(fields[5])add(String(t.tm_year+1900),5);
 if(dateCity)add(cityName.length()?cityName:"GPS...",6);
 }

 }else if(details==0){
 // Compact 3x5 digits, minus and C; scaled 2x for the temperature.
 const uint8_t glyphs[][5]={{7,5,5,5,7},{2,6,2,2,7},{7,1,7,4,7},{7,1,7,1,7},{5,5,7,1,1},{7,4,7,1,7},{7,4,7,5,7},{7,1,1,1,1},{7,5,7,5,7},{7,5,7,1,7},{0,0,7,0,0},{7,4,4,4,7}};
 String text=String((int)roundf(temperature))+"C";
 int scale=fontSizes[7];
 int width=(text.length()*4-1)*scale;
 int x=width>32?32-int((millis()-screenAt)/180%(width+40)):(textAlign==1?0:textAlign==2?32-width:(32-width)/2);
 int sy=std::min(scale,2);
 if(fields[9])for(char ch:text){int g=ch=='-'?10:ch=='C'?11:ch-'0';if(g<0||g>11)continue;for(int y=0;y<5;y++)for(int b=0;b<3;b++)if(glyphs[g][y]&(4>>b))for(int dy=0;dy<sy;dy++)for(int dx=0;dx<scale;dx++)pixel(x+b*scale+dx,19+y*sy+dy);x+=4*scale;}
 if(fields[8]){
 if(weatherCode==0){
 for(int y=3;y<14;y++)for(int x=10;x<21;x++){int r=(x-15)*(x-15)+(y-8)*(y-8);if(r>=12&&r<=25)pixel(x,y);}
 for(int t=0;t<3;t++){pixel(15,t);pixel(15,15+t);pixel(6+t,8);pixel(23+t,8);}
 }else{
 for(int y=3;y<=11;y++)for(int x=6;x<=25;x++){bool c=((x-12)*(x-12)+(y-8)*(y-8)<=25)||((x-19)*(x-19)+(y-7)*(y-7)<=25);if(c)pixel(x,y);}
 if(weatherCode>=51)for(int x=9;x<26;x+=6){pixel(x,14);pixel(x-1,15);pixel(x-1,16);}
 if(weatherCode>=45&&weatherCode<=48)for(int x=7;x<25;x++){pixel(x,14);pixel(x,16);}
 }
 }
 }
 std::vector<uint8_t> rgb(32*32*3,0);
 for(int y=0;y<32;y++)for(int x=0;x<32;x++)if(bits[y][x/8]&(0x80>>(x%8))){
 size_t i=(y*32+x)*3;
 // Theme colors affect ordinary pages only; cloudy icons remain neutral.
 uint32_t color=palettes[skin][details==2?0:details==1?1:2];
 if(details==2 && (y<10 || y>=24))color=palettes[skin][1];
 if(details==0 && y<19 && weatherCode!=0)color=0xaac3dc;
 rgb[i]=color>>16;rgb[i+1]=color>>8;rgb[i+2]=color;

 }
 if(details>=0 && details<=2 && borderStyle){
 uint32_t color=palettes[skin][details==2?0:details==1?1:2];
 auto dot=[&](int x,int y){size_t i=(y*32+x)*3;rgb[i]=color>>16;rgb[i+1]=color>>8;rgb[i+2]=color;};
 for(int x:{0,31})for(int y:{0,31}){dot(x,y);if(borderStyle==2){dot(x==0?1:30,y);dot(x,y==0?1:30);}}
 }
 if(details==5){
 uint32_t color=!(airValid && millis()-airAt<3600000UL)?0x808080:airIndex<=20?0x50f0d0:airIndex<=40?0x50ff64:airIndex<=60?0xffdc40:airIndex<=80?0xff8030:airIndex<=100?0xff4040:0xc060ff;
 for(int y=0;y<32;y++)for(int x=0;x<32;x++)if(bits[y][x/8]&(0x80>>(x%8))){size_t i=(y*32+x)*3;rgb[i]=color>>16;rgb[i+1]=color>>8;rgb[i+2]=color;}
 }
 if(details==4){
 // A filled bell swings left/right; RGB pairs always contrast.
 const uint8_t colors[][3]={{255,0,0},{0,255,0},{0,0,255}};
 uint32_t phase=(millis()-alarmStarted)/700;
 int bg=phase%3,fg=(phase+2)%3;
 int shift=(phase%4==0?-2:phase%4==2?2:0);
 for(int y=0;y<32;y++)for(int x=0;x<32;x++){
 int bx=x-shift;
 bool bell=(y>=9 && y<=21 && abs(bx-15)<=5+(y-9)/4)
 ||(y>=22&&y<=24&&bx>=6&&bx<=24)
 ||((bx-15)*(bx-15)+(y-7)*(y-7)<=4)
 ||((bx-(15-shift))*(bx-(15-shift))+(y-27)*(y-27)<=4);
 size_t i=(y*32+x)*3;for(int c=0;c<3;c++)rgb[i+c]=colors[bell?fg:bg][c];
 }
 brightness=100;
 }
 // Apply brightness to every RGB channel, including clouds and text.
 // Do not rely on the panel applying its global brightness to raw frames.
 for(auto &channel:rgb)channel=(uint16_t(channel)*brightness+50)/100;
 static std::vector<uint8_t> lastRGB;
 if(!fresh && rgb==lastRGB)return true;
 size_t len=rgb.size()+15;
 std::vector<uint8_t> packet={(uint8_t)len,(uint8_t)(len>>8),2,0,0};
 le32(packet,rgb.size());le32(packet,crc32(rgb.data(),rgb.size()));
 packet.insert(packet.end(),{0,0});packet.insert(packet.end(),rgb.begin(),rgb.end());
 if(!client||!client->isConnected()||!output)return false;
 size_t mtu=client->getMTU()-3;
 for(size_t off=0;off<packet.size();off+=mtu){size_t n=std::min(mtu,packet.size()-off);if(!sendPacket(packet.data()+off,n))return false;web.handleClient();}
 lastRGB=rgb;
 return true;
}
void setup(){
 Serial.begin(115200);
 prefs.begin("ipixel",false);
 airEnabled=prefs.getBool("airOn",true);
 eurEnabled=prefs.getBool("eurOn",true);usdEnabled=prefs.getBool("usdOn",true);eurRate=prefs.getFloat("eurRate",0);usdRate=prefs.getFloat("usdRate",0);rateDate=prefs.getString("rateDate","");if(rateDate.length())rateError="Ultimul curs salvat; aștept actualizarea BNR.";
 // Preserve the former global appearance until each page has its own saved settings.
 for(int p=0;p<3;p++){
 pageSkins[p]=constrain(prefs.getInt(("skin"+String(p)).c_str(),prefs.getInt("skin",0)),0,skinCount-1);
 pageAligns[p]=constrain(prefs.getInt(("align"+String(p)).c_str(),prefs.getInt("align",0)),0,2);
 pageBorders[p]=constrain(prefs.getInt(("border"+String(p)).c_str(),prefs.getInt("border",0)),0,2);
 }
 for(int i=0;i<3;i++)pageSeconds[i]=constrain(prefs.getInt(("secs"+String(i)).c_str(),pageSeconds[i]),3,120);
 alarmEnabled=prefs.getBool("alarmOn",false);
 alarmHour=constrain(prefs.getInt("alarmHour",7),0,23);alarmMinute=constrain(prefs.getInt("alarmMin",0),0,59);
 alarmDuration=constrain(prefs.getInt("alarmSecs",60),5,300);
 utcOffset=prefs.getInt("utcOffset",0);timezoneName=prefs.getString("tzName","În curs de identificare");

 dateFormat=constrain(prefs.getInt("dateFmt",0),0,2);dateCity=prefs.getBool("dateCity",false);cityName=prefs.getString("city","");
 for(int i=0;i<8;i++)fontSizes[i]=constrain(prefs.getInt(fontKeys[i],fontSizes[i]),1,3);
 for(int i=0;i<10;i++)fields[i]=prefs.getBool(fieldKeys[i],fields[i]);
 dimStart=constrain(prefs.getInt("dimStart",21),0,23);dimEnd=constrain(prefs.getInt("dimEnd",6),0,23);
 dimPercent=constrain(prefs.getInt("dimPct",5),1,100);dayPercent=constrain(prefs.getInt("dayPct",30),1,100);
 latitude=prefs.isKey("lat")?prefs.getDouble("lat"):44.4268;
 longitude=prefs.isKey("lon")?prefs.getDouble("lon"):26.1025;
 locationSet=prefs.getBool("location",false);
 weatherMinutes=constrain(prefs.getInt("weatherMins",15),1,1440);
 WiFi.mode(WIFI_STA);
 String ssid=prefs.getString("ssid","Supraveghere DIICOT");
 if(ssid.length()){
 WiFi.begin(ssid.c_str(),prefs.getString("pass","Freakazoid12").c_str());
 uint32_t start=millis();
 while(WiFi.status()!=WL_CONNECTED && millis()-start<20000) delay(100);
 }
 if(WiFi.status()!=WL_CONNECTED) startPortal();
 WiFi.setAutoReconnect(true);
 startWeb();
 if(WiFi.status()==WL_CONNECTED) Serial.printf("Wi-Fi conectat. IP: %s; sincronizez ora.\n",WiFi.localIP().toString().c_str());
 configTzTime(zone,"pool.ntp.org","time.google.com","time.cloudflare.com");
 NimBLEDevice::init("ESP32-Ceas-iPixel");
 NimBLEDevice::setMTU(247);
}
void loop(){
 static uint32_t lostSince=0;
 if(WiFi.status()==WL_CONNECTED)lostSince=0;
 if(!setupAP && WiFi.status()!=WL_CONNECTED){
 if(!lostSince) lostSince=millis();
 if(millis()-lostSince>20000) startPortal();
 }
 web.handleClient();
 recoverTime();
 if(!alarmActive && (activePage!=2 || !weatherValid)){fetchWeather();fetchCity();fetchAir();fetchRates();}
 if(!client || !client->isConnected()){
 if((int32_t)(millis()-nextTry)>=0){connectPanel();nextTry=millis()+15000;}
 delay(10);return;
 }
 time_t now=time(nullptr);
 if(now<1700000000){delay(50);return;}
 tm local; localAt(now,&local);
 int dateKey=(local.tm_year+1900)*1000+local.tm_yday;
 if(alarmEnabled && local.tm_hour==alarmHour && local.tm_min==alarmMinute && alarmLastDate!=dateKey){
 alarmLastDate=dateKey;startAlarm();
 }
 if(alarmActive && millis()-alarmStarted>=uint32_t(alarmDuration)*1000)stopAlarm();
 // Brightness is rendered into frames; keep panel gain at 100%.
 static int lastBrightness=-1;
 bool dim=dimStart<dimEnd?(local.tm_hour>=dimStart && local.tm_hour<dimEnd):(dimStart>dimEnd && (local.tm_hour>=dimStart || local.tm_hour<dimEnd));
 int desiredBrightness=dim?dimPercent:dayPercent;
 if(fresh){
 const uint8_t power[]={5,0,7,1,1};
 if(!sendPacket(power,sizeof(power)))return;
 }
 if(fresh){
 const uint8_t brightness[]={5,0,4,0x80,100};
 if(!sendPacket(brightness,sizeof(brightness)))return;
 }
 if(alarmActive){
 if(showWeather(4,100)){fresh=false;lastBrightness=100;}
 delay(10);return;
 }
 bool eligible=weatherValid && millis()-weatherAt<uint32_t(weatherMinutes)*60000UL+3600000UL;
 auto enabled=[&](int page){return page==6?eurEnabled:page==7?usdEnabled:page==5?airEnabled:page==3?false:page==2?fields[0]:page==1?fields[3]:(fields[7]&&eligible);};
 bool redraw=fresh || displayChanged || desiredBrightness!=lastBrightness;
 if(displayChanged || !enabled(activePage)){
 activePage=enabled(2)?2:enabled(1)?1:enabled(0)?0:enabled(5)?5:enabled(6)?6:enabled(7)?7:3;screenAt=millis();redraw=true;
 }
 if(millis()-screenAt>=(activePage<=2?uint32_t(pageSeconds[activePage])*1000u:10000u)){
 int order[]={2,1,0,5,6,7,3};int at=0;while(order[at]!=activePage)at++;
 for(int n=1;n<=7;n++){int candidate=order[(at+n)%7];if(enabled(candidate)){if(activePage!=candidate)redraw=true;activePage=candidate;break;}}
 screenAt=millis();
 }
 // If weather is the only selected page but not yet available, show a blank
 // frame once; do not silently show a disabled clock/date page.
 static time_t lastClockSecond=0;
 static uint32_t lastWeatherAt=0;
 static uint32_t lastTextTick=0;
 uint32_t textTick=(millis()-screenAt)/180;
 if(textTick!=lastTextTick)redraw=true;
 static int64_t lastBlink=-1;
 timeval blinkTime;gettimeofday(&blinkTime,nullptr);int64_t blink=int64_t(blinkTime.tv_sec)*2+blinkTime.tv_usec/500000;
 if(activePage==2 && blink!=lastBlink)redraw=true;
 if(activePage==0 && weatherAt!=lastWeatherAt)redraw=true;
 if(redraw){
 bool oldIcon=fields[8],oldTemp=fields[9];
 if(activePage==0 && !eligible){fields[8]=false;fields[9]=false;}
 bool ok=showWeather(activePage,desiredBrightness);
 fields[8]=oldIcon;fields[9]=oldTemp;
 if(!ok)return;
 if(lastBrightness!=desiredBrightness || fresh)Serial.printf("Luminozitate RGB: %d%%; ora locală %02d:%02d; regim %s.\n",desiredBrightness,local.tm_hour,local.tm_min,dim?"redus":"zi");
 lastBrightness=desiredBrightness;
 lastTextTick=textTick;
 lastBlink=blink;lastClockSecond=now;lastWeatherAt=weatherAt;fresh=false;displayChanged=false;
 }

 delay(10);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace, diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik