Вторая жизнь дисплея электронной сигареты

8 ноября 2026

Тот, кто разбирал такую же электронную сигарету с экраном, знает, что внутри две платы: основная с контроллером и аккумулятором и отдельная плата дисплея со своим микроконтроллером. Значит, между ними есть свой протокол. Разбирали мы PLONQ. Другие модели не проверяли: если у вас другая сигарета, сначала сверьте подписи контактов и запишите кадры анализатором, протокол может отличаться. Мы его расшифровали, научились рисовать на дисплее что угодно из того, что в нём есть, и сделали из него метеостанцию на ESP32-C3 Super Mini.

Разобранная электронная сигарета PLONQ с работающим дисплеем

Что внутри

Основная плата зелёная, на ней контроллер Puya PY32C642, он управляет нагревом и считает заряд аккумулятора. Плата дисплея чёрная, с маркировкой 03-LED-V5, на ней свой контроллер Puya. Платы соединены четырьмя проводами.

ПроводТипНазначение
5V вход питания дисплея питание платы дисплея
GND общий общий провод
TX (на плате C-D) вход дисплея данные от основной платы к дисплею
KEY выход дисплея кнопка на плате дисплея

Данные идут только в одну сторону, от основной платы к дисплею. Обратно дисплей ничего не отвечает.

Плата дисплея 03-LED-V5 и основная плата электронной сигареты соединены четырьмя проводами

Контакты 5V GND TX KEY на плате дисплея электронной сигареты

Основная плата электронной сигареты с разъёмом USB Type-C

Как расшифровали протокол

Линию TX подключили к логическому анализатору и сделали затяжку, чтобы основная плата начала слать кадры. Видны короткие пачки импульсов, самый короткий импульс около 104 мкс. Это скорость 9600 бит/с. В каждом символе 11 бит: старт, 8 бит данных, бит чётности и стоп. Чётность нечётная, то есть формат 9600 8O1. Между пачками основная плата держит линию в низком уровне и поднимает её примерно за один бит до начала кадра.

Осциллограмма кадра 1F 3A 5F 5D на линии данных дисплея UART 9600 8O1

Кадр состоит из четырёх байт, каждый кадр повторяется четыре раза с интервалом около 6 мс.

БайтНазначение
0 шевроны (5 полос) и бег кольца вокруг кнопки
1 значки: ракета, хвост ракеты, капля, батарея, кольцо
2 число от 0 до 199, значения 200–255 гасят цифры
3 контрольная сумма

Контрольную сумму подобрали по записанным кадрам: это сумма первых трёх байт плюс 0xA5, младший байт.

cs = (b0 + b1 + b2 + 0xA5) & 0xFF

Пример: обычный экран с зарядом 95 % — это кадр 1F 3A 5F 5D.

Карта битов дисплея

Дальше подключили к линии TX USB-TTL переходник на CH340 и перебрали все биты по одному, каждый раз фотографируя экран.

Все сегменты и значки дисплея электронной сигареты включены

Байт 0 — шевроны. Биты 0–4 включают полосы: 10 ближняя к ракете, 01 дальняя. Бит 7 (80) запускает бег огней по кольцу вокруг кнопки. Биты 5 и 6 ничего не делают.

Байт 1 — значки.

МаскаЧто загорается
01 ракета красная
02 ракета белая (01 и 02 вместе — розовая)
04 красное кольцо вокруг кнопки
08 белое кольцо, подсветка кнопки
10 капля
20 полоска батареи над цифрами
40 красный хвост ракеты
80 ничего

Байт 2 — число. На дисплее «1» и две цифры, поэтому максимум 199. Две младшие цифры горят всегда вместе: 5 показывается как «05». Гаснуть отдельно умеет только старшая «1». Отдельными сегментами управлять нельзя.

Несколько особенностей, которые сэкономят время:

  • По кольцу бегают только красный и розовый цвет (04 и 04|08 вместе с битом 80). Белый отдельно с битом бега не загорается.
  • Кадр 80 24 00 выключает экран.
  • После подачи питания дисплей спит и не реагирует на кадры, пока один раз не нажать кнопку.
  • Провод KEY нельзя оставлять висящим в воздухе: дисплей перестаёт отвечать. Нужна подтяжка к питанию.
  • Переключение цвета подсветки кнопкой плата дисплея обрабатывает сама, по линии TX это не передаётся.

Как дисплей работает в сигарете

Во время затяжки шевроны бегут снизу вверх (10 18 1C 1E 1F 00), а число показывает заряд. После затяжки около 7 секунд горит обычный экран, затем всё плавно гаснет: шевроны по одному, потом значки, потом цифры. При нажатии кнопки то же самое идёт в обратном порядке. Если снять испаритель, экран мигает: обычный кадр, через 0,7 с — кадр 1F FF BC со всеми сегментами.

Управление с компьютера

Для проверки хватит USB-TTL переходника. Провод TX от основной платы отключаем, вместо него подаём TX переходника через резистор 1 кОм. Земля общая, дисплей питаем от 5 В.

Нажмите, чтобы посмотреть код
import serial

def frame(b0, b1, b2):
    b = [b0, b1, b2]
    return bytes(b + [(sum(b) + 0xA5) & 0xFF])

port = serial.Serial('/dev/ttyUSB0', 9600, parity=serial.PARITY_ODD)
port.write(frame(0x1F, 0x3A, 42) * 4)   # обычный экран, число 42

Видео на ВК

Видео на YouTube

Монитор компьютера без ESP32

Если Wi-Fi не нужен, дисплей можно подключить к компьютеру через тот же переходник CH340 и выводить на него параметры самого компьютера. Плата микроконтроллера в этом случае не нужна вообще.

CH340ТипДисплей
5V выход питания 5V
GND общий GND
TXD цифровой выход TX (C-D) через 1 кОм
— — KEY через 10 кОм на 5V (подтяжка)

Скрипт pc_monitor.py на Python работает в Windows и Linux, нужны библиотеки pyserial и psutil. Что он показывает:

  • цифры — температура процессора;
  • шевроны — загрузка процессора, от 0 до 5 полос;
  • ракета — белая до 60 °C, с красным хвостом от 60 до 80 °C, красная выше 80 °C;
  • полоска батареи — занято больше 75 % оперативной памяти;
  • капля — на 2 секунды вместо температуры показываются обороты вентилятора в сотнях: 12 означает 1200 об/мин.

В Windows библиотека psutil не видит датчики температуры и вентиляторов, поэтому вместо температуры на цифрах будет загрузка процессора в процентах. Вывести туда что-то своё можно, поменяв пару строк: например, число непрочитанных писем или курс валюты.

Нажмите, чтобы посмотреть код pc_monitor.py
#!/usr/bin/env python3
"""Монитор компьютера на дисплее от электронной сигареты через USB-TTL переходник CH340.

Подключение: TX переходника -> резистор 1 кОм -> TX дисплея (C-D), GND -> GND,
5V переходника -> 5V дисплея, KEY дисплея -> 5V через резистор 10 кОм (подтяжка).
После подачи питания один раз нажмите кнопку дисплея.

Что показывает:
  цифры    — температура процессора (если датчика нет, например в Windows, — загрузка процессора в %)
  шевроны  — загрузка процессора, 0–5 полос
  ракета   — белая до 60 °C, с красным хвостом 60–80 °C, красная выше 80 °C
  батарея  — занято больше 75 % оперативной памяти
  капля    — каждые 4 с вместо температуры показывает обороты вентилятора в сотнях (12 = 1200 об/мин), если датчик есть

Установка:  pip install pyserial psutil
Запуск:     python pc_monitor.py              (порт найдётся сам)
            python pc_monitor.py COM5         (Windows)
            python pc_monitor.py /dev/ttyUSB0 (Linux)
"""
import sys, time
import psutil, serial
from serial.tools import list_ports

R, W, RING_W, DROP, BATT, TAIL = 0x01, 0x02, 0x08, 0x10, 0x20, 0x40
BARS = [0x00, 0x10, 0x18, 0x1C, 0x1E, 0x1F]
PERIOD = 0.25          # обновление 4 раза в секунду

def frame(port, b0, b1, b2):
    b = [b0, b1, b2]
    port.write(bytes(b + [(sum(b) + 0xA5) & 0xFF]) * 4)    # кадр шлём 4 раза, как родная плата

def find_port():
    for p in list_ports.comports():
        if p.vid == 0x1A86:                                  # CH340
            return p.device
    sys.exit('Переходник CH340 не найден, укажите порт: python pc_monitor.py COM5')

def cpu_temp():
    temps = getattr(psutil, 'sensors_temperatures', lambda: {})()
    for name in ('coretemp', 'k10temp', 'zenpower', 'cpu_thermal', 'acpitz'):
        for s in temps.get(name, []):
            if s.current:
                return s.current
    return None

def fan_rpm():
    fans = getattr(psutil, 'sensors_fans', lambda: {})()
    rpm = [f.current for lst in fans.values() for f in lst if f.current]
    return max(rpm) if rpm else None

def main():
    port = serial.Serial(sys.argv[1] if len(sys.argv) > 1 else find_port(),
                         9600, parity=serial.PARITY_ODD)
    psutil.cpu_percent(None)
    load, tick = 0.0, 0
    while True:
        time.sleep(PERIOD)
        tick += 1
        load = 0.6 * load + 0.4 * psutil.cpu_percent(None)   # сглаживание
        t = cpu_temp()
        rpm = fan_rpm()

        icons = RING_W
        if t is None:
            icons |= W
            number = round(load)
        else:
            icons |= W if t < 60 else (W | TAIL if t < 80 else R)
            number = round(t)
        if psutil.virtual_memory().percent > 75:
            icons |= BATT
        if rpm and tick % 32 >= 24:                          # 2 с из 8 &mdash; обороты вентилятора
            icons |= DROP
            number = round(rpm / 100)

        bars = BARS[min(5, int(load / 20 + 0.999))] if load > 2 else 0
        frame(port, bars, icons, max(0, min(199, number)))

if __name__ == &#x27;__main__&#x27;:
    main()

Метеостанция и курс доллара на ESP32-C3

Основную плату сигареты заменили на ESP32-C3 Super Mini. Логика 3,3 В дисплей понимает нормально, даже когда сам он питается от 5 В. Протокол формируется программно на любом выводе с длительностью бита 104 мкс.

ESP32-C3ТипДисплей
5V выход питания 5V
GND общий GND
GPIO4 цифровой выход TX (C-D) через 1 кОм
GPIO6 цифровой вход, подтяжка включена KEY

ESP32-C3 Super Mini приклеена к плате дисплея электронной сигареты вместо родной платы

Погоду плата берёт с бесплатного сервиса met.no раз в 10 минут. На дисплее по кругу идут три фазы, и в каждой цифры показывают свою величину.

Температура и облачность

  • цифры — температура, полоска батареи означает минус;
  • ракета — облачность: красная — ясно, розовая — переменная облачность, белая — облачно;
  • красный хвост ракеты — в ближайший час ожидаются осадки.

Ветер

  • ракета гаснет, горят только полоски хвоста;
  • цифры — скорость ветра в м/с;
  • шевроны — шкала ветра, одна полоса на каждые 3 м/с.

Влажность

  • горит капля, ракеты нет;
  • цифры — относительная влажность в процентах;
  • шевроны — шкала влажности, одна полоса на каждые 20 %.

Между фазами цифры на мгновение гаснут, чтобы было видно, что величина сменилась. Белое кольцо вокруг кнопки означает, что включён режим погоды, а бегущее красное кольцо — сильный ветер, от 10 м/с.

Метеостанция на дисплее электронной сигареты: температура 4 градуса, облачно, ветер

Курс доллара на экране

Для перехода во второй режим нужно нажать кнопку. Второй режим показывает курс доллара к рублю. Плата берёт его с Coinbase раз в 30 минут и сравнивает с курсом прошлого дня. Сервис бесплатный и работает без ключа.

  • красное кольцо вокруг кнопки — включён режим курса;
  • цифры — рубли, затем копейки;
  • полоска батареи — это запятая: когда она горит, на цифрах копейки. На фото ниже сначала 81, потом 96 с полоской — значит, в день съёмки курс был 81,96 рубля.
  • красная ракета, шевроны бегут вниз — рубль дешевеет, курс доллара вырос;
  • белая ракета с мигающим красным хвостом, шевроны бегут вверх — рубль дорожает, курс доллара снизился;
  • белая ракета, шевроны стоят — курс за день изменился меньше чем на 10 копеек.

Обратите внимание: полоска батареи в разных режимах значит разное. В режиме погоды это минус перед температурой, в режиме курса — запятая.

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

Курс доллара 81,96 рубля на дисплее электронной сигареты: сначала рубли, затем копейки с полоской

Прошивка для Arduino IDE

Скетч ниже — полная прошивка метеостанции и курса доллара. Сторонние библиотеки не нужны, всё входит в пакет esp32 от Espressif. В Arduino IDE выберите плату ESP32C3 Dev Module и включите USB CDC On Boot. В начале скетча впишите имя и пароль своей сети Wi-Fi, а также координаты города для прогноза погоды. Остальные настройки можно не трогать.

Скачать vape_weather.ino

Нажмите, чтобы посмотреть код vape_weather.ino
// Метеостанция и курс доллара на дисплее от электронной сигареты (вейпа) и ESP32-C3 Super Mini
// Статья: https://duino.ru/blog/vtoraya-zhizn-displeya-elektronnoy-sigarety/
//
// Arduino IDE: плата "ESP32C3 Dev Module" (пакет esp32 от Espressif), USB CDC On Boot: Enabled.
// Библиотеки ставить не нужно, всё входит в пакет esp32.
//
// Подключение:
//   ESP32-C3 5V    -> дисплей 5V
//   ESP32-C3 GND   -> дисплей GND
//   ESP32-C3 GPIO4 -> резистор 1 кОм -> дисплей TX (на плате C-D)
//   ESP32-C3 GPIO6 -> дисплей KEY (кнопка, подтяжка включается в скетче)
//
// Режим ПОГОДА (белое кольцо вокруг кнопки):
//   цифры        &mdash; температура, полоска батареи над цифрами = минус
//   ракета       &mdash; облачность: красная ясно, розовая переменно, белая пасмурно
//   хвост ракеты &mdash; в ближайший час осадки
//   затем ВЕТЕР  &mdash; только полоски хвоста без ракеты, цифры = ветер в м/с, шевроны 1 полоса на 3 м/с
//   затем ВЛАЖНОСТЬ &mdash; капля без ракеты, цифры = влажность в %, шевроны 1 полоса на 20 %
//   кольцо       &mdash; белое = работает, бегущее красное = ветер от 10 м/с
//   нет данных   &mdash; мигает красная ракета
// Режим КУРС ДОЛЛАРА (красное кольцо), Coinbase раз в 30 минут, сравнение с курсом прошлого дня:
//   цифры        &mdash; рубли, затем на 1,5 с копейки с полоской батареи
//   рубль дешевеет (курс вырос) &mdash; красная ракета, шевроны бегут вниз
//   рубль дорожает (курс упал)  &mdash; белая ракета, мигает красный хвост, шевроны бегут вверх
//   изменение меньше 10 копеек  &mdash; белая ракета, шевроны стоят
// Кнопка на дисплее: погода -> курс -> экран выключен -> погода (режим запоминается).

#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>

// ======== НАСТРОЙКИ ========
const char *WIFI_SSID = "имя_сети";
const char *WIFI_PASS = "пароль";
// Координаты города (сейчас Москва). Свои можно взять в Яндекс или Google Картах.
const char *LAT = "55.7558";
const char *LON = "37.6173";
// ===========================

#define TX_PIN  4
#define KEY_PIN 6
#define BIT_US  104                 // 9600 бит/с

// met.no требует User-Agent с названием программы
const char *UA = "vape-weather-display/1.0 (ESP32-C3; duino.ru)";
const uint32_t WX_POLL_MS = 10UL * 60 * 1000;   // погода раз в 10 минут
const uint32_t BAR_MS = 280, HOLD_MS = 4000, GAP_MS = 600, FRAME_MS = 200;
const float WIND_PER_BAR = 3.0, HUM_PER_BAR = 20.0, WIND_ALERT = 10.0;
const char *FX_URL = "https://api.coinbase.com/v2/prices/USD-RUB/spot";
const uint32_t FX_POLL_MS = 30UL * 60 * 1000;   // курс раз в 30 минут
const uint32_t RUN_MS = 220, RUN_TOTAL_MS = 6000, KOP_MS = 1500;
const float FX_FLAT = 0.10;                      // меньше 10 копеек &mdash; без изменений

// Байт 1 &mdash; значки
enum { RED = 0x01, WHITE = 0x02, RING_R = 0x04, RING_W = 0x08, DROP = 0x10, BATT = 0x20, TAIL = 0x40 };
// Байт 0 &mdash; шевроны (бит 4 ближний к ракете ... бит 0 дальний), бит 7 запускает бег по кольцу
const uint8_t RUN_RING = 0x80;
const uint8_t BAR[6] = { 0x00, 0x10, 0x18, 0x1C, 0x1E, 0x1F };
const uint8_t BLANK = 0xFF;         // число 200..255 гасит цифры

Preferences prefs;
enum Mode { WEATHER = 0, FX = 1, OFF = 2, NMODES = 3 };
int mode = WEATHER;

// ---------- Передача на дисплей: программный UART 9600 8O1 ----------
// Между кадрами линия лежит в низком уровне, как у родной платы.
static inline void bitOut(uint8_t v) { digitalWrite(TX_PIN, v); delayMicroseconds(BIT_US); }

void sendByte(uint8_t b) {
  uint8_t ones = 0;
  bitOut(0);                                          // старт
  for (uint8_t i = 0; i < 8; i++) { uint8_t v = (b >> i) & 1; ones += v; bitOut(v); }
  bitOut(!(ones & 1));                                // бит нечётности
  bitOut(1);                                          // стоп
}

// Кадр: шевроны, значки, число, контрольная сумма. Шлём 4 раза, как родная плата.
void frame(uint8_t bars, uint8_t icons, uint8_t num) {
  uint8_t p[4] = { bars, icons, num, (uint8_t)(bars + icons + num + 0xA5) };
  for (int rep = 0; rep < 4; rep++) {
    portDISABLE_INTERRUPTS();                         // чтобы Wi-Fi не сбивал длительность битов
    digitalWrite(TX_PIN, HIGH); delayMicroseconds(BIT_US);
    for (int i = 0; i < 4; i++) sendByte(p[i]);
    digitalWrite(TX_PIN, LOW);
    portENABLE_INTERRUPTS();
    delay(6);
  }
}

void screenOff() { frame(0x80, 0x24, 0x00); }

// ---------- Погода с met.no (отдельная задача, чтобы не тормозить анимацию) ----------
volatile float gTemp = 0, gHum = 0, gCloud = 0, gWind = 0, gPrecip = 0;
volatile uint32_t gWxAt = 0;
volatile float gFx = 0, gFxPrev = 0;            // курс сейчас и закрытие прошлого дня
volatile uint32_t gFxAt = 0;
bool showKop = false;                           // на цифрах копейки вместо рублей

bool num(const String &s, const char *key, int from, float &out) {
  String k = String("\"") + key + "\":";
  int i = s.indexOf(k, from);
  if (i < 0) return false;
  out = s.substring(i + k.length()).toFloat();
  return true;
}

bool parseWeather(const String &body) {
  int cur = body.indexOf("\"instant\":{\"details\":{");   // первая запись прогноза = сейчас
  if (cur < 0) return false;
  float t, h, c, w, p = 0;
  if (!num(body, "air_temperature", cur, t) || !num(body, "relative_humidity", cur, h) ||
      !num(body, "cloud_area_fraction", cur, c) || !num(body, "wind_speed", cur, w)) return false;
  int n1 = body.indexOf("\"next_1_hours\"", cur);
  if (n1 > 0) num(body, "precipitation_amount", n1, p);
  gTemp = t; gHum = h; gCloud = c; gWind = w; gPrecip = p;
  return true;
}

void weatherTask(void *) {
  String url = String("https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=") + LAT + "&lon=" + LON;
  for (;;) {
    if (WiFi.status() != WL_CONNECTED) { WiFi.reconnect(); vTaskDelay(pdMS_TO_TICKS(5000)); continue; }
    WiFiClientSecure tls; tls.setInsecure();
    HTTPClient http; http.setTimeout(8000); http.useHTTP10(true); http.setUserAgent(UA);
    int code = http.begin(tls, url) ? http.GET() : -1;
    String head;
    if (code == 200) {                    // прогноз около 40 КБ, текущая погода в самом начале
      WiFiClient *st = http.getStreamPtr();
      uint32_t t0 = millis();
      while (head.length() < 3500 && millis() - t0 < 5000) {
        while (st->available() && head.length() < 3500) head += (char)st->read();
        int p = head.indexOf("precipitation_amount");
        if (p > 0 && head.indexOf(&#x27;}&#x27;, p) > 0) break;
        delay(5);
      }
    }
    http.end();
    bool ok = code == 200 && parseWeather(head);
    if (ok) {
      gWxAt = millis();
      Serial.printf("Погода: %.1f C, влажность %.0f %%, облачность %.0f %%, ветер %.1f м/с, осадки %.1f мм\n",
                    gTemp, gHum, gCloud, gWind, gPrecip);
    } else Serial.printf("Не удалось получить погоду, код %d\n", code);
    vTaskDelay(pdMS_TO_TICKS(ok ? WX_POLL_MS : 30000));
  }
}

// ---------- Курс доллара с Coinbase ----------
// Курс с Coinbase: сейчас (date = nullptr) или на дату ГГГГ-ММ-ДД. GET https://api.coinbase.com/v2/prices/USD-RUB/spot[?date=YYYY-MM-DD] -> "amount":"81.95..."
float cbSpot(const char *date) {
  WiFiClientSecure tls; tls.setInsecure();
  HTTPClient http; http.setTimeout(8000); http.useHTTP10(true);
  String url = String(FX_URL) + (date ? String("?date=") + date : String(""));
  int code = http.begin(tls, url) ? http.GET() : -999;
  String body = code == 200 ? http.getString() : "";
  http.end();
  int i = body.indexOf("\"amount\":\"");
  if (code != 200 || i < 0) { Serial.printf("fx: coinbase %s code %d\n", date ? date : "now", code); return 0; }
  return body.substring(i + 10).toFloat();
}

void fxTask(void *) {
  for (;;) {
    if (WiFi.status() != WL_CONNECTED) { vTaskDelay(pdMS_TO_TICKS(5000)); continue; }
    for (int i = 0; i < 40 && time(nullptr) < 1700000000; i++) vTaskDelay(pdMS_TO_TICKS(500));   // ждём время по NTP
    float v = cbSpot(nullptr), prev = 0;
    time_t now = time(nullptr);
    if (now > 1700000000) {                       // вчерашняя дата (UTC)
      time_t y = now - 86400; struct tm t; gmtime_r(&y, &t);
      char d[11]; strftime(d, sizeof d, "%Y-%m-%d", &t);
      prev = cbSpot(d);
    }
    bool ok = v > 1;
    if (ok) {
      gFx = v; if (prev > 1) gFxPrev = prev; gFxAt = millis();
      Serial.printf("Курс доллара: %.2f, вчера %.2f\n", gFx, gFxPrev);
    }
    vTaskDelay(pdMS_TO_TICKS(ok ? FX_POLL_MS : 120000));
  }
}

int fxTrend() {                                  // +1 рубль дорожает, -1 дешевеет, 0 без изменений
  if (gFxPrev <= 0) return 0;
  float d = gFx - gFxPrev;
  return d > FX_FLAT ? -1 : (d < -FX_FLAT ? 1 : 0);
}

// ---------- Кнопка на дисплее ----------
bool pressed = false;
void pollKey() {
  static uint8_t last = HIGH; static uint32_t t = 0;
  uint8_t k = digitalRead(KEY_PIN);
  if (k != last && millis() - t > 40) {
    last = k; t = millis();
    if (k == LOW) {
      static bool woke = false;        // первое нажатие после включения только будит дисплей
      if (woke) mode = (mode + 1) % NMODES;
      woke = true;
      prefs.putInt("mode", mode);
      pressed = true;
    }
  }
}

// ---------- Что показать ----------
bool fresh()  {
  if (mode == FX) return gFxAt && millis() - gFxAt < 2 * FX_POLL_MS + 60000;
  return gWxAt && millis() - gWxAt < 2 * WX_POLL_MS + 60000;
}
int wxPhase = 0;   // погода: 0 &mdash; температура и облачность, 1 &mdash; ветер, 2 &mdash; влажность
bool windy()  { return mode == WEATHER && fresh() && gWind >= WIND_ALERT; }

uint8_t rocket() {
  if (!fresh()) return ((millis() / 400) & 1) ? RED : 0;      // нет данных: мигает красная ракета
  if (mode == FX) {
    int tr = fxTrend();
    if (tr < 0) return RED;
    if (tr > 0) return WHITE | (((millis() / 250) & 1) ? TAIL : 0);
    return WHITE;
  }
  if (wxPhase == 1) return TAIL;                              // ветер: полоски без ракеты
  if (wxPhase == 2) return 0;                                 // влажность: только капля
  uint8_t ic = gCloud < 30 ? RED : (gCloud <= 70 ? (RED | WHITE) : WHITE);
  if (gPrecip > 0.05f) ic |= TAIL;
  return ic;
}

// Держим один кадр ms миллисекунд. false &mdash; нажали кнопку.
bool hold(uint32_t ms, bool showNum, bool showRocket, uint8_t bars, uint8_t extra) {
  uint32_t t0 = millis();
  while (millis() - t0 < ms) {
    pollKey();
    if (pressed) return false;
    uint8_t ic = extra;
    if (fresh()) ic |= (mode == FX || windy()) ? RING_R : RING_W;
    if (showRocket) ic |= rocket();
    uint8_t n = BLANK;
    if (showNum && fresh() && mode == FX) {
      int k = (int)lroundf(gFx * 100);
      if (showKop) { ic |= BATT; n = k % 100; }                // копейки
      else n = k / 100 > 199 ? 199 : k / 100;                  // рубли
    } else if (showNum && fresh()) {
      float x = wxPhase == 1 ? gWind : wxPhase == 2 ? gHum : fabsf(gTemp);
      if (wxPhase == 0 && gTemp < -0.5f) ic |= BATT;          // минус
      int v = (int)lroundf(x);
      n = v > 199 ? 199 : v;
    }
    frame(bars | (windy() ? RUN_RING : 0), ic, n);
    delay(FRAME_MS);
  }
  return true;
}

int nBars(float v, float per) { int n = (int)(v / per + 0.5f); return n < 0 ? 0 : (n > 5 ? 5 : n); }

// Шевроны растут по одному, держатся, убираются по одному
bool bars(int n, uint8_t extra) {
  for (int i = 1; i <= n; i++)      if (!hold(BAR_MS, true, true, BAR[i], extra)) return false;
  if (!hold(HOLD_MS, true, true, BAR[n], extra)) return false;
  for (int i = n - 1; i >= 0; i--)  if (!hold(BAR_MS, true, true, BAR[i], extra)) return false;
  return true;
}

// Бегущие шевроны: up = вверх (рубль дорожает), иначе вниз
bool running(bool up) {
  for (uint32_t step = 0, t0 = millis(); millis() - t0 < RUN_TOTAL_MS; step++) {
    int phase = up ? (int)step : -(int)step;
    uint8_t b = 0;
    for (int pos = 0; pos < 5; pos++)                 // pos 0 &mdash; нижний шеврон (бит 4)
      if ((((pos - phase) % 3) + 3) % 3 == 0) b |= 1 << (4 - pos);
    if (!hold(RUN_MS, true, true, b, 0)) return false;
  }
  return true;
}

void cycleFx() {
  if (!hold(GAP_MS, true, true, 0, 0)) return;
  int tr = fxTrend();
  if (tr != 0) { if (!running(tr > 0)) return; }
  else if (!hold(HOLD_MS, true, true, 0, 0)) return;
  showKop = true;
  bool ok = hold(KOP_MS, true, true, 0, 0);
  showKop = false;
  (void)ok;
}

void cycle() {
  if (!hold(GAP_MS, true, false, 0, 0)) return;                 // цифры
  if (!hold(GAP_MS, true, true, 0, 0)) return;                  // ракета
  if (!hold(HOLD_MS, true, true, 0, 0)) return;                 // температура и облачность
  if (!hold(GAP_MS, false, false, 0, 0)) return;
  wxPhase = 1;                                                  // ветер: полоски, м/с, шевроны
  if (!hold(GAP_MS, true, true, 0, 0)) return;
  if (!bars(nBars(gWind, WIND_PER_BAR), 0)) return;
  if (!hold(GAP_MS, false, false, 0, 0)) return;
  wxPhase = 2;                                                  // влажность: капля, %, шевроны
  if (!hold(GAP_MS, true, true, 0, DROP)) return;
  if (!bars(nBars(gHum, HUM_PER_BAR), DROP)) return;
  if (!hold(GAP_MS, false, false, 0, 0)) return;
  wxPhase = 0;
  hold(GAP_MS, true, false, 0, 0);
}

void setup() {
  pinMode(TX_PIN, OUTPUT); digitalWrite(TX_PIN, LOW);
  pinMode(KEY_PIN, INPUT_PULLUP);      // без подтяжки дисплей перестаёт отвечать
  Serial.begin(115200);
  prefs.begin("vape", false);
  mode = prefs.getInt("mode", WEATHER) % NMODES;
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  configTime(3 * 3600, 0, "pool.ntp.org", "time.google.com");   // дата нужна для вчерашнего курса
  xTaskCreate(weatherTask, "weather", 10240, nullptr, 1, nullptr);
  xTaskCreate(fxTask, "fx", 10240, nullptr, 1, nullptr);
  // После включения питания дисплей спит, пока один раз не нажать его кнопку.
}

void loop() {
  if (pressed) { pressed = false; delay(300); }
  if (mode == OFF) { screenOff(); for (int i = 0; i < 50 && !pressed; i++) { pollKey(); delay(20); } return; }
  wxPhase = 0;
  if (!fresh()) { hold(1000, true, true, 0, 0); return; }
  if (mode == FX) cycleFx(); else cycle();
}

Итог

Дисплей, который обычно уходит в мусор вместе с сигаретой, оказался готовым индикатором с простым протоколом: три байта данных, контрольная сумма, обычный UART. На нём можно выводить погоду, заряд аккумулятора, загрузку процессора компьютера или показания любого датчика.