Часы-компьютер LILYGO
Увидел у друга на руках часы LiLYGO - захотел такие.
У часов есть лора, вайфай, gps - но делать гаджет для умного дома было скучно и неинтересно. Из желаемого - превратить часы в ультра полезный тренажёр.
Очень хотелось в часы засунуть карточки японских кандзи уровней N5,4,3. Что я и сделал. А ещё мне показалось что тренажёр азбуки морзе будет очень полезен в часах.
Форкнулся с официального репозитория LilyGo T-Watch Ultra
Напомню железо под капотом: ESP32-S3, круглый AMOLED 466×466, тач CST92xx, вибромотор DRV2605, LoRa SX1262, SD-карта, датчик шагов BHI260AP.
Базовые прошивки в examples показывают как работать с датчиками. Всё что нужно - там присутствует. Ну почти всё.
Прошивка AsciiClock (examples/AsciiClock)
Свой форк LilyGo T-Watch Ultra я опубликовал на гитхабе.
Я создал свою прошивку методом копипаста, ии-манки-кода. В лучших традициях так сказать.
Экраны: один `.ino`, много экранов LVGL
Прошивка — ~4500 строк `AsciiClock.ino` плюс модуль `morse_mod.inc` Навигация построена на enum-экранах:
enum Screen : uint8_t {
SCREEN_HOME = 0,
SCREEN_SETTINGS = 1,
SCREEN_ALARM = 2,
SCREEN_ALARM_RING = 3,
SCREEN_REC = 4,
SCREEN_VOLUME = 5,
SCREEN_TOUCH_CAL = 6,
SCREEN_KANJI = 7,
SCREEN_MORSE = 8
};
Каждый экран — отдельный `lv_obj_t *`, создаётся в `setup()`, переключается через `show_screen()`. Отрисовка текста и графики — не стандартные виджеты LVGL, а **собственный рендер в canvas**: цифры часов заданы как 5×5 ASCII-матрицы, иероглифы и морзе-код рисуются поблочно в `lv_draw_buf_t`.
Главный цикл минималистичен — вся логика в poll-функциях:
void loop() {
instance.loop();
feed_gps();
poll_boot_button();
audio_poll();
rec_poll();
morse_poll(millis());
// ... alarm, clock update, lv_timer_handler()
}
`morse_poll()` вызывается **каждый кадр**, даже когда экран морзе скрыт — при уходе с экрана он сбрасывает вибрацию и настройки тача. В режиме включенных экранов CPU поднимается до высокой частоты (`set_cpu_high(true)`), на домашнем экране с погашенным дисплеем — понижается для экономии (40 мегагерц).
Что ещё внутри
Помимо тренажёров: будильник с WAV с SD. Очень тихий. Планирую заменить динамик чтобы орал. Пока тихо шепчет. Диктофон, калибровка громкости, подсчёт шагов, настройки GPS/NFC/LoRa/Wi-Fi. Аудио — свой PCM-плеер на Ultra AudioOutputI2S.cpp тянет legacy I2S-драйвер.
Морзе напевами: мнемоника + вибрация + тач-ключ
Напевы - простой способ выучить морзе: каждой букве соответствует запоминающаяся фраза, слоги которой совпадают с ритмом точек и тире. Данные на SD в `/morse.csv`:
Буквы и цифры,Словесное обозначение,Слоговое обозначение,Код Морзе
А,айда,ай-да,.-
Б,баки текут,ба-ки-те-кут,-...
М,мама,ма-ма,--
Структура в прошивке:
struct MorseGlyph {
char letter[8];
char latin[8];
char verbal[48]; // «айда»
char syllable[48]; // «ай-да»
char code[16]; // «.-»
};
Четыре режима (MorsePage):
| Режим | Что делает |
| Menu| выбор: буквы или слова |
| Tune Learn | показывает мнемонику, проигрывает код вибрацией |
|Tune Train** | пользователь вводит код удержанием пальца |
| Word Train | то же для слов из /words.csv |
Проигрывание и приём
Вибромотор DRV2605 работает в RTP-режиме (real-time playback). Конечный автомат `morse_play_poll()` чередует фазы MARK (вибрация) и GAP (пауза) с таймингами ITU:
static constexpr uint32_t MORSE_DOT_MS = 110; // ~60 знаков/мин
static constexpr uint32_t MORSE_DASH_MS = MORSE_DOT_MS * 3;
static constexpr uint32_t MORSE_CHAR_GAP_MS = MORSE_DOT_MS * 3;
static constexpr uint32_t MORSE_WORD_GAP_MS = MORSE_DOT_MS * 7;
static constexpr uint32_t MORSE_DOT_THRESH_MS = MORSE_DOT_MS * 2;
В режиме Train палец на экране = ключ. Длительность удержания классифицируется: `< 220 мс` — точка, `≥ 220 мс` — тире, `< 25 мс` — шум (игнор), `> 990 мс` — ошибка. При неверном знаке **весь шаблон сбрасывается** — так проще закрепить ритм целиком, а не отдельные элементы.
**1. Тач CST92xx «отпускает» палец.** Контроллер на один кадр (~16 мс) сбрасывает PRESSED, хотя палец на экране. Без обработки длинное тире превращается в две точки. Решение — debounce отпускания 40 мс и сохранение `morse_key_t0` при «ложном» re-press:
static constexpr uint32_t MORSE_RELEASE_DEBOUNCE_MS = 40;
// Continuation of the same hold (touch dropped a frame) — keep t0.
if (morse_key_down && morse_release_at) {
morse_release_at = 0;
morse_vibe_set(true);
return;
}
Конфликт с LVGL. Long-press и scroll тача мешают кодированию. На время Train long-press увеличивается до 2,5 с, scroll limit — до 255 (фактически отключён):
static void morse_indev_tune(bool train) {
if (train && !morse_indev_tuned) {
lv_indev_set_long_press_time(indev, MORSE_LONG_PRESS_MS); // 2500
lv_indev_set_scroll_limit(indev, 255);
morse_indev_tuned = true;
}
// ...
}
Инкрементальная отрисовка.
Каждый введённый знак перерисовывает только свою ячейку (morse_dirty_from), а не весь canvas. Рисовать весь экран долго и медленно.
Тренажёр кандзи: CSV-карточки и атлас глифов на SD
Данные
База — kanji-jlpt-n5-n4-n3.csv (~700 иероглифов уровней N5–N3). Строка содержит уровень, кандзи, онёми/кунёми чтения и три примера слов с переводом. CSV не парсится целиком в RAM — при первом открытии строится индекс смещений строк:
static bool kanji_index_csv() {
while (f.available() && kanji_rows < KANJI_MAX_ROWS) {
kanji_off[kanji_rows] = (uint32_t)f.position();
kanji_read_line(f, line, sizeof(line));
kanji_rows++;
}
}
Карточка подгружается по seek(kanji_off[idx]) — случайный выбор через esp_random.
Навигация свайпом
Восемь «страниц» карточки (`kanji_page` 0…7):
0: инфо (уровень, он/кун)
1–3: примеры слов
4: рисование иероглифа
5–7: рисование слов из карточки
Свайп влево — следующая страница, кнопка shuffle — новая случайная карточка. Позиция последней карточки сохраняется в `/kanji_last.bin`.
Формат kanji_glyphs.bin
Глифы не в прошивке. Python-скрипт gen_kanji_glyphs.py рендерит Noto Sans CJK и пишет бинарный атлас:
Заголовок (24 байта):
magic[8] = "KGLF002\0"
glyph_size = 32 // текстовые глифы
glyph_bytes = 128 // 32×32 1bpp
glyph_count = N
big_size = 96 // крупные кандзи для карточки
big_bpp = 2 // 4 уровня серого
big_bytes = 2304 // 96×96 2bpp
big_count = M
Далее:
uint16_t cps[N] // codepoints текстовых глифов
uint8_t bits[N × 128] // растровые данные
uint16_t big_cps[M]
uint8_t big_bits[M × 2304]
При открытии тренажёра весь файл (~1–2 МБ) загружается в PSRAM одним блоком. Поиск глифа — бинарный по отсортированному массиву codepoints:
uint8_t *blob = (uint8_t *)heap_caps_malloc(sz, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (memcmp(blob, "KGLF002", 7) != 0) { /* ошибка */ }
// ...
return kanji_find_in(kanji_cps, kanji_bits, kanji_glyph_count, KANJI_GLYPH_BYTES, cp);
В flash остаётся только `kanji_glyphs.h` — размеры и ASCII-иконка кнопки «漢字» 48×24.
Рисование иероглифов
На страницах 4–7 можно рисовать стилусом. Штрихи пишутся в отдельный ink-буфер (kanji_ink), затем накладываются на canvas с альфа-смешиванием. Линии интерполируются с шагом 0,45 px; перерисовывается только затронутый прямоугольник (lv_obj_invalidate_area). Кнопка Hint показывает эталонный глиф 96×96 поверх ink-слоя. На больших иероглифах точность приемлемая. На маленьких так себе.
UTF-8 парсится вручную (utf8_next), отрисовка посимвольная через собственный kanji_draw_text() — стандартные шрифты LVGL не покрывают CJK на нужном размере без мегабайтных font-файлов.
Калибровка тача: аффинное преобразование
Поскольку тренажёр кандзи содержит ещё экран для их рисования - возникла потребность калибровки тача.
CST92xx на Ultra даёт сырые координаты с заметным смещением и масштабированием. Калибровка — три креста по углам экрана (с отступом 36 px), коэффициенты аффинного преобразования вычисляются через определители 3×3:
// X' = a·rx + b·ry + c
// Y' = d·rx + e·ry + f
touch_cal_a = det3(X0, y0, 1, X1, y1, 1, X2, y2, 1) / D;
// ... остальные коэффициенты
Результат сохраняется на сд карте в /touch_cal.bin (magic `TCA1` + 6 float). Применяется глобально в обработчике тача до передачи координат в LVGL. Без калибровки рисование кандзи и ввод морзе на краях экрана неточны.
Экран калибровки открывается свайпом влево с экрана громкости.
Энергопотребление и периферия
Отложенная инициализация
SD, NFC, GPS и LoRa **не инициализируются при старте** — общая SPI-шина, крупные SD-карты могут зависнуть на `begin()`:
instance.begin(NO_HW_SD | NO_HW_NFC | NO_HW_GPS | NO_HW_LORA);
// ...
set_gps(false);
set_nfc(false);
set_lora(false);
Модули включаются вручную в настройках. При выходе из light sleep restore_peripherals_after_sleep() синхронизирует состояние питания с флагами.
Light sleep
Кнопка BOOT (GPIO0) гасит дисплей и уводит часы в light sleep. Пробуждение — только BOOT или PWR, не тач (иначе ложные пробуждения). Датчик шагов BHI260AP остаётся под питанием (ALDO4), шаги продолжают считаться.
LoRa: задел и планы
На домашнем экране — статус LORA (зачёркнут, если выключен). В настройках переключатель вызывает:
if (lora_on) {
instance.powerControl(POWER_RADIO, true);
instance.initLoRa();
} else {
radio.sleep();
instance.powerControl(POWER_RADIO, false);
}
Пока LoRa — инфраструктура без протокола. Логичное развитие:
1. Полу-дуплексный чат морзе между двумя часами без интернета.
2. Пакетизация — SX1262: payload до 255 байт, но для морзе достаточно передавать посимвольно с тайм-слотами.
3. Синхронизация пауз — приёмник должен знать MORSE_DOT_MS отправителя или согласовывать скорость в handshake.
Для обучения это даст «живую» практику — не только вибрацию на запястье, но и реальный радиоканал.
Как попробовать
1. Прошить `examples/AsciiClock/AsciiClock.ino` на T-Watch Ultra.
2. На SD-карту (корень) положить из репозитория:
- `morse.csv`, `words.csv`
- `kanji-jlpt-n5-n4-n3.csv`, `kanji_glyphs.bin` (сгенерировать: `python3 gen_kanji_glyphs.py`)
3. Откалибровать тач (громкость → свайп влево).
Исходники — в репозитории LilyGoLib, ветка с примером: `examples/AsciiClock`.
В статье частично осталась md разметка - прошу понять и простить.
































































