← Torna al blog
Projects 17 giugno 2026 · 15 min di lettura

Come ho trasformato una sveglia economica in un head-up display per le mie corse (Parte 2)

Parte 2, e ultima. Se non hai letto la Parte 1, parti da lì: copre il problema, la configurazione BLE per il cardiofrequenzimetro e come ho aperto il modulo del proiettore per trovare il chip TM1621D.


Collegare i fili

Prima di arrivare al racconto, c’è l’hardware da sistemare. Il nastro del proiettore ha sette fili: uno rosso, sei grigi. Ecco come li ho collegati all’ESP32:

FiloColoreSegnalePin ESP32Note
0RossoGNDGNDMassa di alimentazione
1Grigion/dGPIO 12Pulsante
2GrigioCSGPIO 21Chip select (latchPin)
3GrigioWRGPIO 22Clock (clkPin)
4GrigioDATAGPIO 23Dati seriali (dataPin)
5Grigio4.7V5V / VINAlimentazione
6GrigioGNDGNDMassa del pulsante

Pin 12: un pulsante gratis. Analizzando il circuito mi sono accorto che il filo 1 non veniva più usato per pilotare il display una volta staccato il modulo dalla scheda principale: fluttuava libero. L’ho riutilizzato come INPUT_PULLUP (il pin resta HIGH di default, premere un pulsante lo porta LOW), ottenendo così un pulsante senza dover aggiungere altri collegamenti. I due GND hanno ruoli diversi: il filo 0 è il ritorno di alimentazione del display, il filo 6 è il riferimento per il pulsante.

Alimentazione. Dichiarato a 4.7V, funziona benissimo sul pin VIN a 5V dell’ESP32. Non ho mai riscontrato problemi di stabilità.

Schema del circuito disegnato a mano: sette fili dal modulo del proiettore collegati ai pin GPIO dell'ESP32


Il problema del vocabolario

Alla fine della Parte 1 avevo qualcosa che chiamavo il Sacro Graal: lcdShift(), una funzione capace di trasferire qualsiasi pattern a 13 bit al TM1621D tramite un’interfaccia a 3 fili. Fisicamente, possedevo il chip. Potevo far accadere qualcosa su quella parete.

Perché 13 bit? Il comando di scrittura del TM1621D ha una struttura fissa: 3 bit per l’opcode (quale operazione stai chiedendo), 6 bit per l’indirizzo (abbastanza per indicizzare le 64 celle RAM interne del chip) e 4 bit per il nibble di dati (quali segmenti accendere). 3 + 6 + 4 = 13. La larghezza del comando non è arbitraria: è esattamente la somma di questi tre campi.

Il problema era che non avevo idea di cosa dire. Conoscevo la struttura, non il contenuto. Quale nibble forma un 3? Quale indirizzo punta alla cifra più a sinistra? Niente di niente.

Mi ci è voluto quasi un mese per trovare una risposta completa a quella domanda.


Il datasheet che nessuno aveva tradotto

Il TM1621D ha un datasheet. È interamente in cinese.

Non leggo il cinese. Quindi ho fatto quello che fa qualsiasi ingegnere quando le parole sono inutili: le ho ignorate del tutto e ho guardato la struttura. I datasheet seguono una grammatica universale indipendentemente dalla lingua: le tabelle hanno righe e colonne, i valori binari significano valori binari, e un blocco di bit nella colonna di destra dice sempre qualcosa sulle modalità operative.

C’era una tabella a cui continuavo a tornare.

Tabella dei comandi del TM1621D: la pagina rilevante del datasheet in cinese

Tre tipi di operazione, ognuno identificato da un codice a 3 bit. La riga con 101 indicava un’operazione di scrittura dati. Quindi lo scheletro di ogni comando era qualcosa del genere:

[101] [indirizzo] [com]
 ^^^     ^^^^^     ^^^
scrivi   quale     quali
          cella    segmenti

Quanti bit per l’indirizzo? Quanti per il com? Cosa faceva realmente ai segmenti scrivere a un certo indirizzo? Questo ancora non lo sapevo.

Prima di ricorrere alla forza bruta, ho trovato un progetto su Hackster.io dove qualcuno aveva fatto qualcosa di simile: Custom Output from Alarm Clock Ceiling Projector. Ho letto giusto il necessario per confermare che il progetto fosse fattibile, poi ho chiuso la scheda deliberatamente. Volevo trovare la mappa dei segmenti da solo. Per me era proprio quello il punto.


Quattro ore di niente

Voglio essere onesto su come sono andate le ore successive, perché la maggior parte dei resoconti salta questa parte.

Armato dell’opcode 101 e poco altro, ho iniziato a inviare comandi. Ho variato la larghezza dell’indirizzo, la larghezza dei dati, l’ordine dei bit, la temporizzazione. Il display è rimasto congelato nel suo stato di avvio incasinato. Niente si muoveva.

Dopo un paio d’ore ho smesso di tirare a indovinare e sono passato a un approccio sistematico. Ho scritto una funzione che scandiva automaticamente tutte le combinazioni di indirizzo e nibble di dati (un nibble sono semplicemente 4 bit, la metà bassa di un byte), mettendo in pausa dopo ogni comando così potevo osservare la parete:

void segmentsTest() {
  for (int coms = 8; coms < 17; coms++) {
    clearAll();
    for (int address = 14; address < 25; address++) {
      unsigned long cmd = (0b101 << 10) | (address << 4) | coms;
      lcdShift(cmd, 13);
      delayMicroseconds(50);
      Serial.print("address = "); Serial.println(address);
      Serial.print("coms = "); Serial.println(coms);
      waitForOK(); // si ferma finché non premo Invio nel monitor seriale
    }
  }
}

Ogni comando impacchetta tre cose in 13 bit: l’opcode 101 in cima, l’indirizzo in mezzo e un nibble a 4 bit in fondo. waitForOK() significava che avanzavo manualmente, un comando alla volta, occhi fissi sul proiettore.

Ho premuto Invio per altre due ore.


PM

Poi, con una combinazione che non avevo consapevolmente previsto, qualcosa si è mosso.

Non una cifra. Niente che avessi chiesto. Solo PM: quel piccolo indicatore nell’angolo del display, quello che ti dice se l’orologio segna le 3 del pomeriggio o le 3 del mattino.

Non sembra granché. Ma dopo quattro ore di silenzio assoluto, un singolo segmento che si accende sulla parete era tutto. Significava che il protocollo era giusto. L’opcode era giusto. La larghezza dei bit era giusta. Il framing era giusto.

Avevo la lingua. Mi mancava ancora il dizionario.

L'indicatore PM acceso sulla parete: la prima prova che il protocollo funzionava


Mezza giornata con un quaderno

Una volta che segmentsTest() riusciva a muovere segmenti singoli, sapevo esattamente cosa dovevo fare: mapparli tutti, uno per uno. Un display a sette segmenti è fatto di sette barre controllabili indipendentemente: scrivi il nibble giusto all’indirizzo giusto e si accendono formando una cifra. Dovevo solo trovare quale nibble e quale indirizzo facevano cosa, per tutte e dieci le cifre.

Ecco come funzionava l’indirizzamento per questo display specifico:

Indirizzo = quale posizione della cifra. Nibble COM = quali barre si accendono.

Il display ha quattro slot per le cifre. Ogni slot è controllato da una coppia di indirizzi RAM consecutivi:

CifraIndirizzi
1, più a sinistra21, 22
219, 20
317, 18
4, più a destra15, 16

Ogni indirizzo memorizza un nibble a 4 bit. Ogni bit corrisponde a una barra fisica. Metti un bit a 1 e la barra si accende. Mettilo a 0 e si spegne.

Dopo aver mappato ogni cifra in questo modo, e riempito diverse pagine del quaderno nel farlo, ecco la tabella completa:

Pagine del quaderno: mappatura manuale di indirizzi/nibble a sette segmenti per ogni cifra

Esempio: il numero 3.

La cifra 3 richiede cinque barre: in alto, in alto a destra, al centro, in basso a destra, in basso. Analizzando i dati dello sweep, quelle barre comparivano a:

  • Indirizzo 15: due valori nibble separati, COM 0b0111 e COM 0b1000, ognuno accende barre diverse della stessa cifra. Dato che puntano allo stesso indirizzo, li metto in OR: 0b0111 | 0b1000 = 0b1111.
  • Indirizzo 14: COM 0b0001, che controlla la barra in basso.

Quindi per mostrare un 3: scrivi 0b1111 all’indirizzo 15, scrivi 0b0001 all’indirizzo 14. La funzione shiftDigit() nel codice aggiunge automaticamente l’offset giusto in base a quale delle quattro posizioni stiamo considerando.

int numbersMap[10][2][2] = {
  {{15, 0b1101}, {14, 0b0111}},  // 0
  {{15, 0b0101}, {0,  0b0000}},  // 1
  {{15, 0b1110}, {14, 0b0011}},  // 2
  {{15, 0b1111}, {14, 0b0001}},  // 3
  {{15, 0b0111}, {14, 0b0100}},  // 4
  {{15, 0b1011}, {14, 0b0101}},  // 5
  {{15, 0b1011}, {14, 0b0111}},  // 6
  {{15, 0b1101}, {0,  0b0000}},  // 7
  {{15, 0b1111}, {14, 0b0111}},  // 8
  {{15, 0b1111}, {14, 0b0101}},  // 9
};

1 e 7 richiedono una sola scrittura: la seconda voce è {0, 0} e viene saltata. Tutte le altre ne richiedono due.

Questo è l’intero lavoro di reverse engineering distillato in 10 righe.


Il firmware finale

Con la mappa dei segmenti funzionante, tutto il resto era implementazione. Il firmware fa tre cose:

  1. Si connette a una fascia cardio BLE e legge il BPM in tempo reale, smussato con una media mobile su 50 campioni.
  2. Pilota il display in modalità Cardio o Cronometro.
  3. Legge il pulsante sul GPIO 12 con una piccola macchina a stati: click singolo avvia/mette in pausa il cronometro, doppio click cambia modalità, pressione lunga azzera il timer.

Tre funzioni, un pulsante, nessuno schermo.

Mostra il codice sorgente completo
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEClient.h>
#include <Arduino.h>

// --- Definizione dei pin ---
#define latchPin 21  // CS
#define clkPin   22  // WR
#define dataPin  23  // DATA

// --- BLE ---
BLEScan* pBLEScan;
BLEAdvertisedDevice* heartRateDevice = nullptr;
const BLEUUID heartRateServiceUUID((uint16_t)0x180D);
const BLEUUID heartRateCharUUID((uint16_t)0x2A37);
bool connected = false;
BLERemoteCharacteristic* pRemoteCharacteristic;

// --- Comandi di inizializzazione HT1621 ---
unsigned long lcdOn    = 0b100000000110;
unsigned long lcdOff   = 0b100000000100;
unsigned long sysEn    = 0b100000000010;
unsigned long rc256k   = 0b100000110000;
unsigned long bias13x4 = 0b100001010010;
unsigned long normal   = 0b100111000110;

// --- Pulsante ---
const int buttonPin = 12;
const unsigned long longPressThreshold = 2000;

// --- Modalità ---
enum Mode { CARDIO, CRONOMETRO };
Mode currentMode = CARDIO;

// --- Cronometro ---
bool running = false;
unsigned long startTime = 0;
unsigned long elapsed = 0;

// --- Smoothing del battito cardiaco ---
#define WINDOW_SIZE 50
int valori[WINDOW_SIZE];
int indice = 0;
bool bufferPieno = false;
int actualValue = 0;
int previousValue = -1;
int previousTimer = -1;

// --- Mappa dei segmenti ---
int numbersMap[10][2][2] = {
  {{15, 0b1101}, {14, 0b0111}},  // 0
  {{15, 0b0101}, {0, 0}},        // 1
  {{15, 0b1110}, {14, 0b0011}},  // 2
  {{15, 0b1111}, {14, 0b0001}},  // 3
  {{15, 0b0111}, {14, 0b0100}},  // 4
  {{15, 0b1011}, {14, 0b0101}},  // 5
  {{15, 0b1011}, {14, 0b0111}},  // 6
  {{15, 0b1101}, {0, 0}},        // 7
  {{15, 0b1111}, {14, 0b0111}},  // 8
  {{15, 0b1111}, {14, 0b0101}}   // 9
};

void lcdShift(unsigned long data, int bits) {
  unsigned long bitmask = 1UL << (bits - 1);
  digitalWrite(latchPin, LOW);
  for (int i = 0; i < bits; i++) {
    digitalWrite(clkPin, LOW);
    digitalWrite(dataPin, (data & bitmask) ? HIGH : LOW);
    delayMicroseconds(2);
    digitalWrite(clkPin, HIGH);
    delayMicroseconds(10);
    bitmask >>= 1;
  }
  digitalWrite(latchPin, HIGH);
  delayMicroseconds(2);
}

void clearAll() {
  for (int address = 0; address < 64; address++) {
    unsigned long cmd = (0b101 << 10) | (address << 4);
    lcdShift(cmd, 13);
    delayMicroseconds(50);
  }
}

void segmentLighter(unsigned long address, unsigned long coms) {
  unsigned long cmd = (0b101 << 10) | (address << 4) | coms;
  lcdShift(cmd, 13);
  delayMicroseconds(50);
}

unsigned long shiftDigit(unsigned long address, int digit) {
  unsigned long shifter = 0;
  if      (address == 14 && digit == 4) shifter = 2;
  else if (address == 14 && digit == 3) shifter = 0;
  else if (address == 14 && digit == 2) shifter = 6;
  else if (address == 14 && digit == 1) shifter = 8;
  else if (address == 15 && digit == 4) shifter = 2;
  else if (address == 15 && digit == 3) shifter = 0;
  else if (address == 15 && digit == 2) shifter = 4;
  else if (address == 15 && digit == 1) shifter = 6;
  else return address;
  return address + shifter;
}

void printSingleNumberHr(int number, int digit) {
  for (int i = 0; i < 10; i++) {
    if (number == i) {
      for (int c = 0; c < 2; c++) {
        unsigned long address = numbersMap[i][c][0];
        unsigned long com     = numbersMap[i][c][1];
        if (address != 0 && com != 0) {
          address = shiftDigit(address, digit);
          segmentLighter(address, com);
        }
      }
    }
  }
}

void printSingleNumberTimer(int number, int digit) {
  for (int i = 0; i < 10; i++) {
    if (number == i) {
      for (int c = 0; c < 2; c++) {
        unsigned long address = numbersMap[i][c][0];
        unsigned long com     = numbersMap[i][c][1];
        if (address != 0 && com != 0) {
          address = shiftDigit(address, digit);
          if (address == 20) com = com | 8;
          if (address == 19 && (number == 1 || number == 7)) segmentLighter(20, 8);
          segmentLighter(address, com);
        }
      }
    }
  }
}

int* splittaNumeroHr(int numero) {
  static int arr[5];
  char buffer[5];
  sprintf(buffer, "%d", numero);
  int len = strlen(buffer);
  for (int i = 0; i < len; i++) arr[i + 1] = buffer[i] - '0';
  for (int i = len + 1; i <= 4; i++) arr[i] = -1;
  return arr;
}

int* splittaNumeroTimer(int numero) {
  static int arr[5];
  char buffer[5];
  sprintf(buffer, "%04d", numero);
  int len = strlen(buffer);
  for (int i = 0; i < len; i++) arr[i + 1] = buffer[i] - '0';
  return arr;
}

void printNumberHr(int number) {
  int* cifre = splittaNumeroHr(number);
  for (int i = 1; i <= 4; i++) printSingleNumberHr(cifre[i], i);
}

void printNumberTimer(int number) {
  int* cifre = splittaNumeroTimer(number);
  for (int i = 1; i <= 4; i++) printSingleNumberTimer(cifre[i], i);
}

int aggiungiValore(int nuovoValore) {
  valori[indice] = nuovoValore;
  indice++;
  if (indice >= WINDOW_SIZE) { indice = 0; bufferPieno = true; }
  long somma = 0;
  int count = bufferPieno ? WINDOW_SIZE : indice;
  for (int i = 0; i < count; i++) somma += valori[i];
  return (int)(somma / count);
}

class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveServiceUUID() &&
        advertisedDevice.isAdvertisingService(heartRateServiceUUID)) {
      heartRateDevice = new BLEAdvertisedDevice(advertisedDevice);
      pBLEScan->stop();
    }
  }
};

void setupBLE() {
  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setActiveScan(true);
  pBLEScan->start(10, false);
}

void leggiPulsante() {
  static bool btnPressedPrev = false;
  static unsigned long lastClickTime = 0;
  static int clickCount = 0;
  bool btnPressed = digitalRead(buttonPin) == LOW;
  unsigned long now = millis();

  if (btnPressed && !btnPressedPrev) {
    lastClickTime = now;
    clickCount++;
  }

  if (btnPressed && (now - lastClickTime > longPressThreshold)) {
    if (currentMode == CRONOMETRO) {
      running = false; elapsed = 0;
      clearAll(); printNumberTimer(0);
    }
    clickCount = 0;
  }

  if (!btnPressed && !btnPressedPrev && clickCount > 0 && (now - lastClickTime > 400)) {
    if (clickCount == 1 && currentMode == CRONOMETRO) {
      running = !running;
      if (running) startTime = millis() - elapsed;
      else elapsed = millis() - startTime;
    } else if (clickCount == 2) {
      currentMode = (currentMode == CARDIO) ? CRONOMETRO : CARDIO;
      clearAll();
      printNumberHr(actualValue);
    }
    clickCount = 0;
  }

  btnPressedPrev = btnPressed;
}

void aggiornaDisplayCronometro() {
  if (running) elapsed = millis() - startTime;
  int totalSeconds = elapsed / 1000;
  int actualTimer = (totalSeconds / 60) * 100 + (totalSeconds % 60);
  if (actualTimer != previousTimer) {
    clearAll(); printNumberTimer(actualTimer);
    previousTimer = actualTimer;
  } else {
    printNumberTimer(actualTimer);
  }
}

void aggiornaDisplayCardio() {
  if (actualValue != previousValue) {
    clearAll(); printNumberHr(actualValue);
    previousValue = actualValue;
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(latchPin, OUTPUT);
  pinMode(clkPin, OUTPUT);
  pinMode(dataPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);

  digitalWrite(latchPin, HIGH);
  digitalWrite(clkPin, LOW);
  digitalWrite(dataPin, LOW);

  delay(100);
  lcdShift(sysEn, 12);
  lcdShift(rc256k, 12);
  lcdShift(bias13x4, 12);
  lcdShift(lcdOn, 12);
  lcdShift(normal, 12);
  delay(50);
  clearAll();

  setupBLE();
}

void loop() {
  leggiPulsante();

  if (heartRateDevice && !connected) {
    BLEClient* pClient = BLEDevice::createClient();
    pClient->connect(heartRateDevice);
    BLERemoteService* pRemoteService = pClient->getService(heartRateServiceUUID);
    if (!pRemoteService) { heartRateDevice = nullptr; return; }
    pRemoteCharacteristic = pRemoteService->getCharacteristic(heartRateCharUUID);
    if (!pRemoteCharacteristic) { heartRateDevice = nullptr; return; }
    if (pRemoteCharacteristic->canNotify()) {
      pRemoteCharacteristic->registerForNotify(
        [](BLERemoteCharacteristic* c, uint8_t* data, size_t length, bool isNotify) {
          if (length > 1) actualValue = aggiungiValore(data[1]);
        }
      );
      connected = true;
    }
  }

  if (currentMode == CARDIO) aggiornaDisplayCardio();
  else aggiornaDisplayCronometro();

  delay(50);
}

Tre cose degne di nota:

Smoothing. I valori grezzi del cardiofrequenzimetro BLE saltano, specialmente alla prima connessione. La media mobile su 50 campioni mantiene il display stabile senza renderlo lento a seguire i reali cambiamenti di sforzo.

Formato del timer. Il cronometro mostra MM:SS impacchettato come numero a quattro cifre: 0132 per 1 minuto e 32 secondi. I due punti sono un segmento separato, gestito dalla riga com | 8 in printSingleNumberTimer().

Logica del pulsante. Il timeout di 400ms separa il click singolo dal doppio click. La pressione lunga scatta solo con una pressione continua: non puoi attivarla per sbaglio.


La prima corsa

Più di un mese dopo aver svitato per la prima volta quella sveglia, sono uscito con un ESP32 attaccato al petto con il nastro adesivo, il modulo del proiettore montato davanti a me e la fascia BLE a fare il suo lavoro.

Due cose che non avevo previsto: la vibrazione della corsa faceva tremolare leggermente le cifre proiettate sulla superficie, e serve che fuori sia sufficientemente buio perché i numeri siano leggibili: con la piena luce del giorno diventano illeggibili. Entrambi i problemi sono risolvibili, e nessuno dei due comprometteva l’idea di fondo.

Quello che non andava risolto, perché non c’era nulla da aggiustare, era vedere il battito cardiaco salire quando spingevo più forte e scendere quando rallentavo, in tempo reale, senza muovere il polso nemmeno una volta. Anche il cronometro si è rivelato davvero utile: sapere il tempo trascorso senza interrompere la falcata è una piccola cosa finché non l’hai provata, e poi non puoi più tornare indietro.

alt1alt2

Non era un prodotto rifinito. I cavi erano tenuti insieme con nastro isolante. Ma l’idea di fondo, cioè dati che arrivano a te invece che il contrario, era completamente validata.

Niente polso da alzare. Niente schermo da aspettare che si svegli. Niente falcata da interrompere. I numeri erano semplicemente lì, sulla parete davanti a me: esattamente dove li volevo fin dall’inizio.

Lodovico Cortelazzo

Lodovico Cortelazzo

Ex atleta della nazionale. Scienze motorie e ingegneria. Sto costruendo ODKI.

Quindici anni di allenamento, otto a livello nazionale, mi danno uno sguardo specifico su cosa fa davvero la tecnologia sportiva rispetto a cosa promette. Unisco questo a esperienza pratica di ingegneria e prodotto costruendo ODKI, per capire non solo come costruire sport-tech, ma cosa vale davvero la pena costruire. Se stai lavorando a qualcosa in questo ambito e vuoi questo punto di vista, scrivimi.

Lavora con me →