Come realizzare un orologio astronomico moderno sfruttando le potenzialità dell’ESP32
La misura del tempo è uno degli aspetti più affascinanti e complessi dell’elettronica applicata. Dietro a ciò che comunemente chiamiamo “orologio” si nascondono concetti come fusi orari, ora solare, ora civile, ora legale e sincronizzazione con riferimenti temporali globali. Questo progetto nasce con l’obiettivo di realizzare un orologio astronomico basato su ESP32, capace non solo di mostrare data e ora corrette, ma anche di gestire in modo automatico il passaggio tra ora solare (CET) e ora legale (CEST) e di calcolare in tempo reale la posizione del Sole rispetto all’osservatore. Non si tratta quindi di un semplice orologio digitale, ma di un sistema completo che integra rete, tempo reale, calcolo astronomico e visualizzazione, pensato per essere affidabile, didattico ed espandibile.
Acquista le componenti su Aliexpress:
- ESP32-WROOM 32 – https://s.click.aliexpress.com/e/_c4Krjim7
- Display LCD 2004 I2C – https://s.click.aliexpress.com/e/_c33jFYBV
- HC-SR501 – https://s.click.aliexpress.com/e/_c4deJxaR
- DS3231 – https://s.click.aliexpress.com/e/_c456JXwR
- Jumper – https://s.click.aliexpress.com/e/_c44KssxV
Acquista le componenti su Amazon:
- ESP32-WROOM 32 – https://amzn.to/3ZxINCp
- Display LCD 2004 I2C – https://amzn.to/4avhrBN
- HC-SR501 – https://amzn.to/4tCltB2
- DS3231 – https://amzn.to/4rgwJBs
- Jumper – https://amzn.to/4cvdf7D
Progetto
L’orologio astronomico è progettato per mantenere il tempo in modo robusto e coerente, anche in assenza di connessione internet. Il sistema utilizza due fonti temporali complementari:
-
NTP, per ottenere un riferimento temporale globale e preciso
-
RTC (Real Time Clock), per mantenere l’orario quando l’ESP32 è spento o scollegato dalla rete

Una scelta importante è quella di mantenere l’orario interno del sistema in UTC. Questo approccio evita ambiguità e problemi legati al cambio dell’ora legale. Il passaggio a ora locale viene effettuato solo al momento della visualizzazione, applicando dinamicamente il fuso orario italiano e le regole europee per l’ora legale. Oltre alla funzione di orologio, il progetto integra un modulo di calcolo astronomico che determina la posizione apparente del Sole in base alla data, all’ora e alla posizione geografica dell’osservatore. Vengono così calcolate:
-
Altitudine del Sole, ovvero l’altezza sull’orizzonte
-
Azimut del Sole, cioè la direzione cardinale rispetto al Nord
Queste informazioni rendono il progetto particolarmente interessante per chi si avvicina all’astronomia, alla meteorologia o alla progettazione di sistemi di inseguimento solare.

Una delle prime scelte progettuali riguarda la piattaforma hardware. In questo progetto è stata utilizzata una ESP32, preferita a una classica scheda Arduino per diversi motivi tecnici. L’ESP32 integra Wi-Fi e Bluetooth nativamente, eliminando la necessità di moduli esterni e semplificando notevolmente l’architettura del sistema. Questo è fondamentale per la sincronizzazione dell’orario tramite NTP (Network Time Protocol), che richiede una connessione di rete stabile.

Ad adornare il progetto e dargli un’aspetto più smart ci pensa il sensore HC-SR501 che si occuperà dei integrare la funzione di accensione/spegnimento del display LCD quando viene rilevato movimento, così da mostrare dati solo in presenza dell’utente.
Dal punto di vista delle prestazioni, l’ESP32 dispone di un processore dual-core a 32 bit, una quantità di RAM superiore e una gestione più avanzata delle periferiche rispetto a un Arduino Uno o Nano. Questo permette di eseguire senza difficoltà calcoli matematici complessi, come quelli necessari per determinare altezza e azimut del Sole, mantenendo allo stesso tempo la gestione del display e dei servizi di rete.

Un altro vantaggio decisivo è il supporto agli aggiornamenti OTA (Over The Air). Grazie a questa funzionalità, il firmware può essere aggiornato via Wi-Fi senza dover collegare fisicamente l’ESP32 al computer. In un progetto che evolve nel tempo, questa caratteristica diventa estremamente comoda e professionale.
Componenti
Il progetto è basato su una combinazione di componenti semplici ma affidabili. Non sono componenti costose o difficilmente reperibili, anzi sono piuttosto comuni e semplici da adoperare e spesso sono presenti in molti starter kit. La ESP32 sarà il cuore del progetto [LINK], gestendo il Wi-Fi, l’elaborazione dei dati, il calcolo astronomico e la comunicazione con le periferiche.

Il modulo RTC DS3231 è un orologio in tempo reale ad alta precisione [LINK], dotato di compensazione termica e batteria tampone CR2032. Il modulo DS3231 è dotato di un TCXO (oscillatore termocompensato) con cui elabora con estrema precisione le informazioni di anno, mese, giorno, ora, minuti e secondi. Inoltre il TCXO gestisce il rilevamento della temperatura in un range compreso tra gli 0°C ed i 70°C.

Il display LCD 20×4 con interfaccia I²C, che abbiamo già visto in passato [LINK], consente di visualizzare simultaneamente data, ora, stato del fuso orario e dati astronomici, anche grazie alle sue 4 righe e 20 colonne. Per semplificare il cablaggio, è spesso presente un modulo adattatore I2C basato su chip come il PCF8574, che converte la comunicazione da parallela (necessaria al controller HD44780) a seriale su bus I2C. Questo riduce le connessioni a soli quattro fili: VCC, GND, SDA e SCL. L’indirizzo I2C di default è solitamente 0x27 o 0x3F, ma può variare a seconda del modulo; è possibile verificarlo con uno scanner I2C prima di configurarlo nel software.

Il sensore HC-SR501 è un sensore PIR [LINK], ossia un Sensore a InfraRossi Passivo (Passive InfraRed), costituito da due unità (slot) realizzate appunto con materiale sensibile agli infrarossi, in grado di “vedere” ad una certa distanza qualsiasi corpo che emani calore e sia in movimento. Questo permetterà alla ESP32 di rilevare movimenti nei pressi dell’orologio astronomico e attivare o disattivare il display LCD, mostrando i dati solo in presenza di movimenti. Il sensore HC-SR501 è dotato di due potenziometri, il primo in grado di regolare la distanza compresa da un minimo di 3 metri fino ad un massimo di 7 metri, l’altro imposta il tempo nel quale di segnale di OUTPUT rimane in HIGH dopo che è stato rilevato un movimento e questo tempo è compreso in un minimo di 5 secondi ad un massimo di 5 minuti.

Collegamenti
Uno degli aspetti più comodi di questo progetto è la semplicità dei collegamenti. Sia il modulo RTC che il display LCD utilizzano il bus I²C, condividendo le stesse linee di comunicazione, ossia SDA collegato al GPIO 21 dell’ESP32 e SCL collegato al GPIO 22 dell’ESP32. Entrambi i moduli vengono alimentati a 5V, rendendo il sistema compatibile con alimentazioni standard e facile da integrare in una realizzazione definitiva. Il sensore HC-SR05 verrà connesso al GPIO33 della ESP32, alimentandolo sempre a 5V. L’ESP32 sarà alimentata tramite porta USB, distribuendo poi la tensione agli altri componenti tramite il pin Vin.

Codice
Per migliorare l’accesso a chiunque voglia replicare questo progetto, il codice è reperibile al seguente LINK su GitHub. Premesso ciò, iniziamo l’analisi del codice scritto, includendo le librerie necessarie per far funzionare il Wi-Fi, il modulo RTC, il display e gli aggiornamenti OTA. Ricordiamoci che le librerie LiquidCrystal_I2C_master e RTClib sono le uniche che abbiamo bisogno di integrare nella IDE di Arduino, non essendo nativamente incluse.
#include <wifi.h>
#include <wire.h>
#include <rtclib.h>
#include <time.h>
#include <arduinoota.h>
#include <liquidcrystal_i2c.h>
Passiamo a configurare le impostazioni di rete, indicando l’SSID e la password della rete Wi-Fi, specificando l’IP statico che deve essere assegnato alla ESP32 ed i relativi parametri di rete.
const char* SSID = "nomerete";
const char* PASS = "password";
IPAddress local_IP(192, 168, 1, 153);
IPAddress gateway(192, 168, 1, 1);
IPAddress subnet(255, 255, 255, 0);
IPAddress dns(192, 168, 1, 1);
Sarà necessario creare 3 variabili numeriche, una intera e due float, per indicare Latitudine, Longitudine e Time Zone. Questi dati serviranno per il calcolo della posizione e altezza del sole relativi alla città di Messina, in cui vivo, ma sarà possibili adattarli liberamente anche ad altre zone.
const float LAT = 38.1938;
const float LON = 15.5540;
const int TZ = 1;
Creiamo ora gli oggetti che utilizzeremo per il display LCD e il modulo RTC, indicando per il display numero di righe, colonne e l’indirizzo di comunicazione. Dichiariamo anche il pin che ci servirà per collegare il sensore PIR e definiamo quanto tempo il display deve rimanere acceso dopo l’ultimo movimento rilevato dal sensore PIR. Il valore è espresso in millisecondi, quindi 30000 corrisponde a 30 secondi. La variabile lastMotionTime memorizza il momento esatto (in millisecondi) in cui è stato rilevato l’ultimo movimento. Il valore viene ottenuto tramite la funzione millis(), che restituisce il tempo trascorso dall’avvio dell’ESP32, mentre la variabile booleana displayOn indica semplicemente lo stato attuale del display. Generiamo anche una variabile PI2 indicante il valore del Pi Grego raddoppiato, che ci aiuterà nei calcoli astronomici.
#define LCD_ADDR 0x27
#define LCD_COLS 20
#define LCD_ROWS 4
#define PIR_PIN 33
const unsigned long DISPLAY_TIMEOUT = 30000;
unsigned long lastMotionTime = 0;
boot displayOn = false;
RTC_DS3231 rtc;
LiquidCrystal_I2C lcd(LCD_ADDR, LCD_COLS, LCD_ROWS);
const float PI2 = 6.283185307;
Sarà anche necessario porre le basi per il calcolo degli anni bisestili ed il calcolo dei giorni dell’anno, che varierà se l’anno in corso è bisestile o no. In parole semplici, con la funzione che generiamo, indichiamo l’anno e controlliamo se è bisestile. Il calcolo è semplice: ogni 4 anni è bisestile, tranne gli anni secolari (divisibili per 100), a meno che non siano divisibili per 400. Successivamente, creiamo una funzione che calcola il numero progressivo del giorno nell’anno, partendo dalla data corrente; somma al giorno del mese tutti i giorni dei mesi precedenti e, se l’anno è bisestile e la data è dopo febbraio, aggiunge un giorno di correzione.
bool bisestile(int y) {
return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}
int giornoAnno(DateTime now) {
static int gm[] = {31,28,31,30,31,30,31,31,30,31,30,31};
int g = now.day();
for (int i = 0; i < now.month() - 1; i++) g += gm[i]; if (now.month() > 2 && bisestile(now.year())) g++;
return g;
}
Procedendo, passiamo al calcolo dell’ora legale (CEST) e solare (CET). Prima esclude direttamente i mesi in cui l’ora legale non può essere attiva (Gennaio, Febbraio, Novembre e Dicembre) e considera sempre valida l’ora legale nei mesi centrali (Aprile–Settembre).
Per Marzo ed Ottobre, che sono i mesi di transizione, calcola l’ultima domenica del mese e verifica l’orario esatto in cui avviene il cambio: alle 2:00 in marzo per l’inizio dell’ora legale e alle 3:00 in ottobre per il ritorno all’ora solare, restituendo true per l’ora legale, false per l’ora solare.
bool oraLegaleEU(DateTime now) {
int y = now.year(), m = now.month(), d = now.day(), h = now.hour();
if (m < 3 || m > 10) return false;
if (m > 3 && m < 10) return true; int lastSunday; if (m == 3) { lastSunday = 31 - ((5 * y / 4 + 4) % 7); return (d > lastSunday || (d == lastSunday && h >= 2));
}
if (m == 10) {
lastSunday = 31 - ((5 * y / 4 + 1) % 7);
return !(d > lastSunday || (d == lastSunday && h >= 3));
}
return false;
}
Passiamo alla parte che ha richiesto più tempo e ricerche, ossia il calcolo della posizione apparente del Sole nel cielo, partendo dalla data, dall’ora e dalla posizione geografica dell’osservatore. La funzione declinazioneSolare() calcola la declinazione del Sole, cioè l’angolo tra i raggi solari e il piano dell’equatore terrestre. Questo valore varia durante l’anno a causa dell’inclinazione dell’asse terrestre ed è responsabile delle stagioni. Il calcolo utilizza una formula approssimata ma molto diffusa, in cui n rappresenta il giorno dell’anno. La funzione equazioneTempo() calcola l’equazione del tempo, una correzione che tiene conto delle irregolarità del moto apparente del Sole. Serve a compensare la differenza tra il tempo solare vero e quello medio indicato dagli orologi, causata dall’orbita ellittica della Terra e dall’inclinazione dell’asse terrestre. La funzione soleAltAz() mette insieme tutti questi elementi per determinare altezza (altitudine) e Azimut del Sole. Prima ricava il giorno dell’anno e calcola la declinazione solare e la latitudine in radianti. Poi applica l’equazione del tempo e la correzione dovuta alla longitudine per ottenere il tempo solare vero. Da questo ricava l’angolo orario del Sole. Infine, utilizzando le formule della trigonometria sferica, calcola l’altezza del Sole sopra l’orizzonte e l’Azimut, cioè la direzione del Sole rispetto al Nord geografico. Questi due valori descrivono completamente la posizione del Sole nel cielo in un determinato istante e sono alla base delle funzionalità “astronomiche” del progetto.
float declinazioneSolare(int n) {
return 23.45 * sin(PI2 * (284 + n) / 365.0);
}
float equazioneTempo(int n) {
float B = PI2 * (n - 81) / 364.0;
return 9.87 * sin(2 * B) - 7.53 * cos(B) - 1.5 * sin(B);
}
void soleAltAz(DateTime now, float &alt, float &az) {
int n = giornoAnno(now);
float dec = declinazioneSolare(n) * DEG_TO_RAD;
float lat = LAT * DEG_TO_RAD;
float et = equazioneTempo(n);
float tc = et + 4 * (LON - 15 * TZ);
float ora = now.hour() + now.minute() / 60.0 + now.second() / 3600.0;
float tst = ora * 60 + tc;
float ha = (tst / 4 - 180) * DEG_TO_RAD;
alt = asin(sin(lat) * sin(dec) + cos(lat) * cos(dec) * cos(ha)) * RAD_TO_DEG;
az = atan2(-sin(ha), tan(dec) * cos(lat) - sin(lat) * cos(ha)) * RAD_TO_DEG;
if (az < 0) az += 360;
}
Procediamo con le funzioni che si occupano dell’orario, prelevando l’ora esatta via Internet e memorizzandola sul modulo RTC. La funzione syncTimeNTP() sincronizza l’orario tramite server NTP e lo salva nel modulo RTC in UTC puro, garantendo precisione e indipendenza dalla rete dopo la sincronizzazione, mentre la funzione oraLocale() converte l’orario UTC dell’RTC nell’ora locale, applicando il fuso orario e aggiungendo automaticamente l’ora legale quando necessario.
bool syncTimeNTP() {
configTime(0, 0, "pool.ntp.org", "time.nist.gov");
struct tm t;
if (!getLocalTime(&t, 10000)) return false;
rtc.adjust(DateTime(
t.tm_year + 1900,
t.tm_mon + 1,
t.tm_mday,
t.tm_hour,
t.tm_min,
t.tm_sec
));
return true;
}
DateTime oraLocale(DateTime utc) {
int offset = TZ;
if (oraLegaleEU(utc + TimeSpan(0, TZ, 0, 0))) offset++;
return utc + TimeSpan(0, offset, 0, 0);
}
Nel Void Setup() vengono inizializzate tutte le periferiche e i servizi necessari al funzionamento del progetto: viene avviata la comunicazione seriale per il debug e il bus I²C per il collegamento con RTC e display, il pin collegato al sensore PIR viene configurato come ingresso, il display LCD viene inizializzato e mantenuto spento all’avvio per ridurre consumi e accensioni inutili. Successivamente viene avviato il modulo RTC. L’ESP32 si connette alla rete Wi-Fi utilizzando un indirizzo IP statico e resta in attesa finché la connessione non è stabilita. Se il RTC segnala una perdita di alimentazione, l’orario viene sincronizzato tramite NTP. Infine viene configurato e avviato il servizio OTA, che consente l’aggiornamento del firmware via Wi-Fi, impostando anche un timeout esteso per upload più affidabili.
void setup() {
Serial.begin(115200);
Wire.begin();
pinMode(PIR_PIN, INPUT);
lcd.init();
lcd.noBacklight(); // display spento all'avvio
lcd.clear();
rtc.begin();
WiFi.config(local_IP, gateway, subnet, dns);
WiFi.begin(SSID, PASS);
while (WiFi.status() != WL_CONNECTED) delay(300);
if (rtc.lostPower()) syncTimeNTP();
ArduinoOTA.setHostname("OrologioAstronomico");
ArduinoOTA.begin();
ArduinoOTA.setTimeout(120000);
}
Infine, nel Void loop() all’inizio viene mantenuto attivo il servizio OTA, permettendo eventuali aggiornamenti del firmware via Wi-Fi. Subito dopo viene letto lo stato del sensore PIR per verificare la presenza di movimento. Quando il movimento viene rilevato, il display viene acceso con lcd.backlight() e viene aggiornato il tempo dell’ultimo rilevamento. Se non viene rilevato alcun movimento per un intervallo superiore al tempo impostato, il display viene spento automaticamente con lcd.noBacklight() per ridurre consumi e usura. Finché il display rimane spento, il programma interrompe l’aggiornamento delle informazioni. Quando il display è attivo, il codice calcola l’ora locale corretta, la posizione del Sole (altezza e azimut) e il giorno progressivo dell’anno, aggiornando infine tutte le informazioni sul display LCD a intervalli regolari.
void loop() {
ArduinoOTA.handle();
// Controllo PIR e gestione display
bool motion = digitalRead(PIR_PIN);
if (motion) {
lastMotionTime = millis(); // aggiorno timer movimento
if (!displayOn) {
lcd.backlight(); // accende display
displayOn = true;
}
}
// Spegni display se inattivo da più di DISPLAY_TIMEOUT
if (displayOn && (millis() - lastMotionTime > DISPLAY_TIMEOUT)) {
lcd.noBacklight();
displayOn = false;
}
// Aggiorno display solo se acceso
if (!displayOn) return;
DateTime now = oraLocale(rtc.now());
float alt, az;
soleAltAz(now, alt, az);
int g = giornoAnno(now);
lcd.setCursor(0, 0);
lcd.printf("%02d/%02d/%04d %02d:%02d",
now.day(), now.month(), now.year(),
now.hour(), now.minute());
lcd.setCursor(0, 1);
lcd.printf("Alt Sole: %5.1f%c", alt, 223);
lcd.setCursor(0, 2);
lcd.printf("Az Sole: %5.1f%c", az, 223);
lcd.setCursor(0, 3);
lcd.printf("%s Giorno:%3d",
oraLegaleEU(now) ? "CEST" : "CET", g);
delay(500);
}