Если вы посмотрели видео, то уже хорошо понимаете, что представляет из себя этот гаджет, но на всякий случай ещё раз перечислю, что он умеет: онлайн‑синхронизация времени, фоторамка, демонстрация логотипа, имитация волшебного шара из фильма «Трасса 60». В часах используется модуль WeAct ESP32‑C6 Mini с процессором ESP32‑C6 QFN32 и дисплей WeAct ST7735 (9 $ за всё вместе с доставкой с AliExpress).

Вид изнутри
Вид изнутри

Написание кода не обошлось без проблем: многие библиотеки для обновления времени через сеть не работали, а также библиотеку Adafruit ST7735 пришлось немного подправить, чтобы убрать белые полоски с краёв экрана — об этом будет написано ниже. Заодно сразу отмечу: подтягивающий резистор для кнопок не нужен, код задействует встроенный в плату резистор, предназначенный для этих целей.

Небоходимые библиотеки для Arduino IDE

  1. NTPClient from Fabrice Weinberg

  2. Adafruit-GFX-Library

  3. Adafruit-ST7735-Library

Код (из кода вырезаны массивы с изображениями, полный код на GitHub):

Скрытый текст
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <Arduino.h>
#include <WiFi.h>
#include <NTPClient.h>
#include <WiFiUdp.h>



// пины управления дисплеем
#define TFT_CS    20    // Chip select
#define TFT_DC    9    // Data/Command
#define TFT_RST   8   // Reset
#define TFT_BL   19    // Backlight

// мягкая замена стандартных SPI-пинов:
#define TFT_SCK   6 
#define TFT_MOSI  2 
// инициализация дисплея
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);

char ssid[] = "Your Wi-fi SSID";
char password[] = "Your Wi-fi password";
int status = WL_IDLE_STATUS;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP);
int page = 0;
int frames = 0;

#define LOGO_HEIGHT 64
#define LOGO_WIDTH  128


void setup() {
  pinMode(0, INPUT_PULLUP);
  pinMode(1, INPUT_PULLUP);
  pinMode(3, INPUT_PULLUP);
  pinMode(14, INPUT_PULLUP);
  // запускаем SPI с указанием своих ножек
  // порядок: SCK, MISO (не нужен, ставим -1), MOSI, SS (CS)
  SPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);

  // инициализация дисплея
  tft.initR(INITR_BLACKTAB);
  tft.setRotation(0);
  tft.fillScreen(ST77XX_BLACK);
  ledcAttach(TFT_BL, 5000, 8);
  ledcWrite(TFT_BL, 60);

  tft.setTextColor(ST77XX_GREEN);
  tft.setTextSize(1);
  uint16_t x = (tft.width()  - 6 * 2 * 5) / 2;
  uint16_t y = (tft.height() - 8 * 2)     / 2;
  tft.setCursor(0, 0);
  tft.println("Display initialized");
  delay (1000);
  status = WiFi.begin(ssid, password);
  if (WiFi.waitForConnectResult() != WL_CONNECTED) {
    tft.println("Wi-Fi connection error");
    return;
  } else {
    tft.println("Wi-Fi connected");
  }
  IPAddress ip = WiFi.localIP();
  tft.println("Local IP:");
  tft.println(ip);
  timeClient.begin();
  tft.println("NTP Time update");
  timeClient.update();
  WiFi.disconnect();
  tft.println(timeClient.getFormattedTime());
  tft.print("\n");
  tft.print("\n");
  matrix("Wake up, Neo...", 15);
  matrix("The Matrix has you...", 21);
  
}

void loop() {

  switch(page) {
    case 0:
       home();
       break;
    case 1:
       home1();
       break;
     case 2:
       home2();
       break;
     case 3:
       home3();
       break;
    case 4:
       home4();
       break;
  }

}

void matrix(char text[], int size) {
  for (int i = 0; i < size; i++) {
    tft.print(text[i]);
    delay(200);
  }
  tft.print("\n");
}

void matrixstr(String text) {
  for (int i = 0; i < text.length(); i++) {
    tft.print(text[i]);
    delay(200);
  }
  tft.print("\n");
}

bool onoff() {
  if (!digitalRead(3)) {
    delay(200);
    return true;
  } else {
    return false;
  }
}

bool startpause() {
  if (!digitalRead(14)) {
    delay(200);
    return true;
  } else {
    return false;
  }
}

bool sound() {
  if (!digitalRead(1)) {
    delay(200);
    return true;
  } else {
    return false;
  }
}

bool reset() {
  if (!digitalRead(0)) {
    delay(200);
    return true;
  } else {
    return false;
  }
}

void home() {
  while(page==0) {
    if (frames==90) {
              static int y = 0;
              if (y==120) {y = 0;} else {y+=40;}
              tft.fillScreen(ST77XX_BLACK);
              tft.setTextSize(2);
              tft.setCursor(0, y);
              day();
              //tft.setTextSize(4);
              //time();
              tft.println(timeClient.getFormattedTime());
              frames = 0;
    } else {frames++;}
      if (onoff()) {page=1;}
      if (startpause()) {page=2;}
      if (sound()) {page=3;}
      if (reset()) {page=4;}
    delay(30);
  }
}

void home1() {
	   static int disp = 0;
		 if (disp==0) {
       		tft.fillScreen(ST77XX_BLACK);
		tft.setTextSize(1);
		tft.setCursor(0, 40);
		tft.println("Press on/off when");
		tft.println("you're ready.");
		disp = 1;
		 }

     if (onoff()) {
		          tft.fillScreen(ST77XX_BLACK);
							tft.setTextColor(ST77XX_WHITE);
							tft.fillTriangle(64, 33, 118, 126, 10, 126, 0x296d);
              tft.setTextSize(2);
              tft.setCursor(50, 80);
							if ((timeClient.getEpochTime() % 2) > 0) {
								tft.println("YES");
							} else {
								tft.println("NO");
							}
							delay(3000);
							tft.setTextColor(ST77XX_GREEN);
							page=0;
							disp=0;
	}
}

void home2() {
  while(page==2) {
    if (frames==90) {
              static int y = 0;
              if (y==120) {y = 0;} else {y+=40;}
              tft.fillScreen(ST77XX_BLACK);
              tft.setTextSize(2);
              tft.setCursor(0, y);
              daym();
              //tft.setTextSize(4);
              //time();
              matrixstr(timeClient.getFormattedTime());
              frames = 0;
    } else {frames++;}
      if (onoff()) {page=1;}
      if (startpause()) {page=2;}
      if (sound()) {page=3;}
      if (reset()) {page=4;}
      delay(30);
  }
}

void home3() {
  while(page==3) {
     posters();
  }
}

void home4() {
  while(page==4) {
      logoanimation();
      if (onoff()) {page=1;}
      if (startpause()) {page=2;}
      if (sound()) {page=3;}
      if (reset()) {page=4;}
  }
}

void nextpage() {
  if (page == 2) {
    page = 0;
  } else {
    page++;
  }
}

void logoanimation() {
	for (int i = 0; i < 31; i++){
		tft.fillScreen(ST77XX_BLACK);
    tft.drawBitmap(0, 48, logoallArray[i], LOGO_WIDTH, LOGO_HEIGHT, 0x07E0);
		delay(21);
	}
}

void posters() {
	for (int i = 0; i < 6; i++){
		tft.drawRGBBitmap(0, 0, postersallArray[i], 128, 160);
		  if (onoff()) {page=1;}
      if (startpause()) {page=2;}
      if (sound()) {page=3;}
      if (reset()) {page=4;}
		delay(5000);
	}
}

void day() {
 int x = timeClient.getDay();
 switch(x) {
  case 0:
    tft.println("Sunday");
    break;
  case 1:
    tft.println("Monday");
    break;
  case 2:
    tft.println("Tuesday");
    break;
  case 3:
    tft.println("Wednesday");
    break;
  case 4:
    tft.println("Thursday");
    break;
  case 5:
    tft.println("Friday");
    break;
  case 6:
    tft.println("Saturday");
    break;
 }
}

void daym() {
 int x = timeClient.getDay();
 switch(x) {
  case 0:
    matrixstr("Sunday");
    break;
  case 1:
    matrixstr("Monday");
    break;
  case 2:
    matrixstr("Tuesday");
    break;
  case 3:
    matrixstr("Wednesday");
    break;
  case 4:
    matrixstr("Thursday");
    break;
  case 5:
    matrixstr("Friday");
    break;
  case 6:
    matrixstr("Saturday");
    break;
 }
}

void time() {
  tft.print(timeClient.getHours());
  tft.print(":");
  if (timeClient.getMinutes() < 10) {tft.print(0);}
  tft.print(timeClient.getMinutes());
  tft.print("\n");
}

В Arduino IDE вам необходимо в первую очередь в менеджере плат установить пакет ESP32 от автора Espressif Systems. Для прошивки выбираем в инструментах плату ESP32C6 Dev Module. Но сначала нужно отредактировать файл Adafruit_ST7735.cpp библиотеки Adafruit_ST7735_and_ST7789_Library. У меня он лежит здесь:
C:\Users\Paperclip\Documents\Arduino\libraries\Adafruit_ST7735_and_ST7789_Library

Нам необходимо добавить строчки в if options функции Adafruit_ST7735::initR :

_colstart = 2;
_rowstart = 1;
Вот таким образом
Вот таким образом

Теперь дисплей должен после компиляции работать корректно.

Кнопки и пины

Вы можете подключить кнопки и дисплей к другим пинам, а не так, как у меня, но нужно учитывать, что есть пины, специально отведённые под ту или иную функцию, например SPI, а некоторые пины лучше вообще не трогать. Предлагаю руководствоваться таблицами из даташита производителя.

Итого

Изначально я планировал использовать кнопки на устройстве в качестве Bluetooth‑клавиатуры, но все библиотеки, которые я перепробовал, не заработали, поэтому от этой идеи пришлось отказаться. Генератор случайных ответов YES/NO в имитации «волшебного шара» из фильма Трасса 60 определяет результат, исходя из того, чётное или нечётное количество секунд на момент выполнения функции. Более правильно было бы использовать сбор энтропии по фоновому напряжению на пинах или специальную функцию генерации случайных чисел от ESP, которую поддерживает этот процессор, вероятнее всего тоже собирая энтропию каким‑либо образом.

Исходя из вышенаписанного можно сделать вывод о том, что пора с Arduino переходить на ESP-IDF для более тонкой работы с возможностями платы и использования FreeRTOS.

Комментарии (8)


  1. zbot
    20.08.2025 18:46

    ...Написание кода не обошлось без проблем: многие библиотеки для обновления времени через сеть не работали ...

    Вырезал кусок из своей программы:

    //https://github.com/arduino-libraries/NTPClient
    
    #include <NTPClient.h>
    
    NTPClient timeClient(ntpUDP, "pool.ntp.org", utcOffsetInSeconds);
    
    void setup(){
     configTime(10800, 0, "pool.ntp.org", "time.nist.gov", "time.windows.com");  // get UTC time via NTP
     secured_client.setTrustAnchors(&cert);
     timeClient.begin();  // Инициализация NTP-клиента
    
    time_t now = time(nullptr);
     struct tm* timeinfo = localtime(&now);
     int year = timeinfo->tm_year + 1900; 
     int month = timeinfo->tm_mon + 1;
     int day = timeinfo->tm_mday;
     int hours = timeinfo->tm_hour;
     int minutes = timeinfo->tm_min;
     }


  1. enjoyneering
    20.08.2025 18:46

    Зачем вам библиотека NTPClient from Fabrice Weinberg? Arduino для ESP32 и ESP8266 давно умеют получать время по NTP из коробки, все есть в примерах.


  1. mpp2508
    20.08.2025 18:46

    Читая заголовок, ожидал увидеть модификацию прошивки тетриса, превращающую его в часы. А тут от него остался один корпус. Я бы сделал заголовок "Делаем настольные часы с Wi-Fi в корпусе от тетриса"


    1. REPISOT
      20.08.2025 18:46

      А тут от него остался один корпус.

      Я даже этого не увидел, потому что в статье фото готового устройства нет.


    1. assdestr0yer Автор
      20.08.2025 18:46

      Корпус и кнопки, и каким образом нужно модифицировать прошивку тетриса, чтобы он начал ловить Wi-Fi?))


      1. mpp2508
        20.08.2025 18:46

        Откуда я знаю? Может это специальный тетрис с WiFi. Суть в том, что от тетриса остался один корпус, и тетрисом называться не может.


  1. Zara6502
    20.08.2025 18:46

    пардон автор, но оформление статьи и заголовка "Copilot сбежал из больницы". Нет фото готового устройства, огромное видео с ютуба не в спойлер завёрнуто, нет дублирующего видео на Рутубе например, вот у меня ютуб не работает там где я статьи читаю, а там где работает - я хабр не читаю. Как я понял от "тетриса" у вас часть корпуса и как верно написали выше - тогда и заголовок должен быть другим.


    1. dlinyj
      20.08.2025 18:46

      В видео оскорбление про мать, и это не достойно вашего просмотра. Ничего интересного.