homeserver: рендейм subserver→homeserver, документ деривации ключей, запрет пустого пароля

Основное (наша работа в этой сессии):
- Переименование «subserver» → «homeserver» по всему проекту: основной ESP32-скетч
  (папка shine_subserver_ui → shine_homeserver_ui, .ino, flash-скрипт, режим burn.sh
  homeserver-ui), скетч lvgl_nav_minimal_test (ключ homeserver.key:<имя>), spec-доки
  reference/*, формат PDA (терминология session_type=100 «Homeserver пользователя»),
  константа SESSION_TYPE_HOMESERVER в JS и Rust (значение 100 не менялось, формат не затронут),
  pending/future доки, AGENTS.md, DAO-док. Сохранены отдельный lvgl_subserver_touch_test и
  историческая пометка о рендейме в DERIVATION.md.
- Новый источник истины по деривации ключей: Dev_Docs/Keys/DERIVATION.md (Argon2id-секрет из
  пароля, формула Ed25519(SHA-256(base64(secret)|suffix)), суффиксы root/bch/dev/homeserver.key,
  Solana-ключ = dev.key). Уточнены роли root (главный/master) и dev (пополняемый кошелёк) в
  Dev_Docs/Keys/README.md.
- UI: убран легаси-путь пустого пароля (derivePasswordSeed и др.), deriveMasterSecretFromPassword
  бросает ошибку на пустом пароле, register-view блокирует пустой пароль; экран пополнения
  переведён на канонический device-адрес из preGeneratedKeyBundle (удалён расходящийся
  deriveWalletFromPassword).

Включены также параллельные правки Solana-аудита №3 (были в рабочем дереве, переплетены в lib.rs):
- shine_users: defense-in-depth «строгий список аккаунтов» (require!(it.next().is_none()))
  в init/update economy config и create/update user PDA, плюс описание в doc/programs/shine_users.md;
- Dev_Docs/audit/Solana-audit-3-by-Claude-12июня2026.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AidarKC
2026-06-12 21:16:12 +04:00
co-authored by Claude Opus 4.8
parent cf6a2830c8
commit 42dcf6970d
32 changed files with 517 additions and 251 deletions
@@ -1,4 +1,4 @@
# SHiNE ESP32 Subserver UI Nav Minimal Spec
# SHiNE ESP32 Homeserver UI Nav Minimal Spec
Минимальный навигационный прототип для `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
@@ -23,7 +23,7 @@
- `WIFI_SCREEN`
- `SERVER_SCREEN`
- `ACCOUNT_SCREEN`
- `ACCOUNT_SUBSERVER_SCREEN`
- `ACCOUNT_HOMESERVER_SCREEN`
- `ACCOUNT_SECRET_SCREEN`
- `SECRET_SHOW_SCREEN`
- `SECRET_GENERATE_*`
@@ -33,7 +33,7 @@
## HOME
Показывает:
- сверху слева значение сабсервера или `subserver not set`;
- сверху слева значение homeserver или `homeserver not set`;
- ниже значение логина или `login not set`;
- справа от строки логина индикатор статуса Solana-аккаунта:
- зелёный — все ключи совпадают;
@@ -51,7 +51,7 @@
- строка `SHiNE: <server> connected/account not configured/unavailable`;
- при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`;
- снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю.
- внизу на тёмной полосе подпись `SHiNE subserver (v.0.18)`.
- внизу на тёмной полосе подпись `SHiNE homeserver (v.0.18)`.
Строка Wi-Fi на `HOME`:
- `Wi-Fi (not configured) not configured`
@@ -65,11 +65,11 @@
Фоновая логика:
- пока открыт `HOME`, экран сам обновляется примерно раз в секунду;
- при наличии `login + secret + subserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `device key` и `subserver` session-запись типа `100`;
- при наличии `login + secret + homeserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC;
- сравниваются `root key`, `blockchain key`, `device key` и `homeserver` session-запись типа `100`;
- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE:
- авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`;
- session key = публичный `subserver key`;
- session key = публичный `homeserver key`;
- подтверждение создания сессии подписывается `device key`;
- heartbeat выполняется `Ping` раз в минуту.
@@ -164,26 +164,26 @@
- заголовок `ACCOUNT`;
- статусное сообщение;
- кнопку `Login (<value|not set>)`;
- кнопку `Subserver (<value|not set>)`;
- кнопку `Homeserver (<value|not set>)`;
- кнопку `Secret (<*****|not set>)`.
Переходы:
- свайп вправо -> `SETTINGS_MENU`
- `Login` -> `TEXT_EDIT_SCREEN`
- `Subserver` -> `ACCOUNT_SUBSERVER_SCREEN`
- `Homeserver` -> `ACCOUNT_HOMESERVER_SCREEN`
- `Secret` -> `ACCOUNT_SECRET_SCREEN`
## ACCOUNT_SUBSERVER_SCREEN
## ACCOUNT_HOMESERVER_SCREEN
Показывает:
- текущий `subserver`;
- рекомендацию оставить `subserver1`, если устройство одно;
- кнопку `USE SUBSERVER1`;
- текущий `homeserver`;
- рекомендацию оставить `homeserver1`, если устройство одно;
- кнопку `USE HOMESERVER1`;
- кнопку `EDIT MANUALLY`;
- кнопку `BACK`.
Переходы:
- `USE SUBSERVER1` -> сохраняет `subserver1` и возвращает в `ACCOUNT_SCREEN`
- `USE HOMESERVER1` -> сохраняет `homeserver1` и возвращает в `ACCOUNT_SCREEN`
- `EDIT MANUALLY` -> `TEXT_EDIT_SCREEN`
- свайп вправо -> `ACCOUNT_SCREEN`
@@ -212,8 +212,8 @@
- `Blockchain key priv (base58)`;
- `Device key (base58)`;
- `Device key priv (base58)`;
- `Subserver key (base58)`;
- `Subserver key priv (base58)`;
- `Homeserver key (base58)`;
- `Homeserver key priv (base58)`;
- для каждого поля показывается формула derivation;
- значения ключей показываются полными строками увеличенным шрифтом;
- кнопку `BACK`.
@@ -293,7 +293,7 @@
Используется `Preferences` (NVS памяти ESP32):
- `login`
- `subserver`
- `homeserver`
- `secret_set`
## Детали клавиатуры
@@ -312,7 +312,7 @@
- `DEL`
- `SAVE`
- `CANCEL`
- ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE subserver (v.0.18)`.
- ниже рамки клавиатурного блока остаётся отдельная тёмная полоса с версией `SHiNE homeserver (v.0.18)`.
## Жесты
@@ -329,7 +329,7 @@
- `WIFI_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `SERVER_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `ACCOUNT_SCREEN`: свайп вправо -> `SETTINGS_MENU`
- `ACCOUNT_SUBSERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `ACCOUNT_HOMESERVER_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `ACCOUNT_SECRET_SCREEN`: свайп вправо -> `ACCOUNT_SCREEN`
- `TEXT_EDIT_SCREEN`: свайп влево/вправо -> переключение страниц клавиатуры
- переключение страниц клавиатуры срабатывает только если свайп начался в зоне самой клавиатуры, а не по всему экрану редактора
@@ -1,8 +1,8 @@
# SHiNE ESP32 Subserver UI Spec
# SHiNE ESP32 Homeserver UI Spec
## Назначение
Этот документ описывает актуальный UI-прототип сабсервера `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Этот документ описывает актуальный UI-прототип homeserver `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Документ является источником истины для Arduino-скетча:
@@ -37,13 +37,13 @@
## Основная идея устройства
Устройство работает как отдельный сабсервер:
Устройство работает как отдельный homeserver:
- хранит секрет на самом устройстве;
- позволяет ввести логин, секрет и имя сабсервера;
- позволяет ввести логин, секрет и имя homeserver;
- показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как сабсервер;
- после выполнения условий даёт зарегистрировать устройство как homeserver;
- после регистрации может принимать входящие запросы на вход и на подпись.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
@@ -57,7 +57,7 @@
- `Wi-Fi SSID`;
- `Wi-Fi password`;
- `login`;
- `session/subserver name`;
- `session/homeserver name`;
- `master secret`;
- `wallet address`;
- `user pda address`;
@@ -142,7 +142,7 @@
- крупный статус регистрации;
- имя логина;
- имя сабсервера;
- имя homeserver;
- короткий статус Wi-Fi;
- короткий статус сервера;
- короткий статус баланса.
@@ -162,14 +162,14 @@
Если регистрация уже сделана:
- вместо призыва к регистрации показывается статус `Сабсервер активен`.
- вместо призыва к регистрации показывается статус `Homeserver активен`.
## Экран STATUS
Показывает сводку:
- логин;
- сабсервер;
- homeserver;
- есть ли секрет;
- зарегистрировано ли устройство;
- подключён ли Wi-Fi;
@@ -256,7 +256,7 @@
Показывает:
- логин;
- имя сабсервера;
- имя homeserver;
- статус секрета;
- короткий отпечаток секрета;
- статус регистрации;
@@ -266,7 +266,7 @@
- `Изменить логин`
- `Секрет`
- `Имя сабсервера`
- `Имя homeserver`
- `Сгенерировать`
- `Очистить`
- `Назад`
@@ -394,7 +394,7 @@ QR должен быть сканируемым, а не декоративны
- `SSID`
- `Пароль Wi-Fi`
- `Логин`
- `Имя сабсервера`
- `Имя homeserver`
- `API URL`
- `RPC URL`
- `WS URL`
@@ -432,13 +432,13 @@ QR должен быть сканируемым, а не декоративны
5. проверить или задать серверные адреса;
6. открыть `Аккаунт`;
7. ввести логин;
8. задать имя сабсервера;
8. задать имя homeserver;
9. сгенерировать секрет;
10. открыть `Кошелёк`;
11. при необходимости пополнить баланс;
12. вернуться на `HOME`;
13. нажать `Зарегистрировать`;
14. после подтверждения увидеть статус `Сабсервер активен`.
14. после подтверждения увидеть статус `Homeserver активен`.
Примечание:
@@ -14,7 +14,7 @@
- `hello` — базовый тест экрана (пример `01_HelloWorld`)
- `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU)
- `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости)
- `subserver-ui` — основной UI-прототип сабсервера SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы
- `homeserver-ui` — основной UI-прототип homeserver SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы
- `text-test` — диагностический экран рендера текста: default font, U8g2 ASCII, U8g2 кириллица, кнопки с подписями
- `gfx-text-test` — тот же тест рендера текста, но уже внутри новой папки `test_sketches/`
- `gfx-layout-test` — тест геометрии и нижних рядов кнопок
@@ -22,9 +22,9 @@
- `lvgl-interaction-test` — экран на `LVGL` с большим числом кнопок и сообщением о нажатой кнопке
- `lvgl-touch-debug-test` — точечная диагностика touch: сырые координаты, маркер точки и большая тест-кнопка `LVGL`
- `lvgl-official-based-test` — наш минимальный экран, но на максимально близкой к официальному `LVGL_Widgets` инициализации
- `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_subserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет
- `lvgl-subserver-touch-test` — гибрид: `LVGL`-интерфейс, но display/touch init и raw touch-read взяты из `shine_homeserver_ui`; подтверждено на устройстве, touch работает, зелёных линий по краям нет
- `lvgl-russian-font-test` — тест кастомного `LVGL`-шрифта с кириллицей: русские кнопки, длинные подписи и статусы
- `lvgl-nav-minimal-test` — новый минимальный UI-каркас сабсервера: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS
- `lvgl-nav-minimal-test` — новый минимальный UI-каркас homeserver: `HOME`, `SETTINGS_MENU`, `Wi-Fi`, `Server`, `Account`, свайпы, крупные кнопки и реальная настройка Wi-Fi с сохранением в NVS
Запуск:
@@ -32,7 +32,7 @@
- `./burn.sh audio`
- `./burn.sh hello`
- `./burn.sh simple`
- `./burn.sh subserver-ui`
- `./burn.sh homeserver-ui`
- `./burn.sh text-test`
- `./burn.sh gfx-text-test`
- `./burn.sh gfx-layout-test`
@@ -43,4 +43,4 @@
- `./burn.sh lvgl-subserver-touch-test`
- `./burn.sh lvgl-russian-font-test`
- `./burn.sh lvgl-nav-minimal-test`
- `./flash_shine_subserver_ui.sh` - автоматически находит USB-порт и заливает `shine_subserver_ui`
- `./flash_shine_homeserver_ui.sh` - автоматически находит USB-порт и заливает `shine_homeserver_ui`
@@ -34,7 +34,7 @@ case "${MODE}" in
audio) SKETCH_DIR="${DEMO_BASE}/examples/07_ES8311" ;;
simple) SKETCH_DIR="${ROOT_DIR}/simple_av_test" ;;
argon2) SKETCH_DIR="${ROOT_DIR}/argon2_sd_test" ;;
subserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_subserver_ui" ;;
homeserver-ui) SKETCH_DIR="${ROOT_DIR}/shine_homeserver_ui" ;;
text-test) SKETCH_DIR="${ROOT_DIR}/text_render_test" ;;
gfx-text-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_text_render_test" ;;
gfx-layout-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/gfx_button_layout_test" ;;
@@ -47,7 +47,7 @@ case "${MODE}" in
lvgl-nav-minimal-test) SKETCH_DIR="${ROOT_DIR}/test_sketches/lvgl_nav_minimal_test" ;;
*)
echo "Unknown mode: ${MODE}" >&2
echo "Use one of: hello, widgets, audio, simple, argon2, subserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2
echo "Use one of: hello, widgets, audio, simple, argon2, homeserver-ui, text-test, gfx-text-test, gfx-layout-test, lvgl-basic-test, lvgl-interaction-test, lvgl-touch-debug-test, lvgl-official-based-test, lvgl-subserver-touch-test, lvgl-russian-font-test, lvgl-nav-minimal-test" >&2
exit 2
;;
esac
@@ -43,9 +43,9 @@ fi
if [[ -z "${PORT}" ]]; then
echo "Не удалось автоматически найти USB-порт ESP32." >&2
echo "Подключите плату и проверьте 'arduino-cli board list'." >&2
echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_subserver_ui.sh" >&2
echo "Либо укажите порт вручную: PORT=/dev/ttyACM0 ./flash_shine_homeserver_ui.sh" >&2
exit 1
fi
echo "== Найден порт: ${PORT}"
PORT="${PORT}" "${ROOT_DIR}/burn.sh" subserver-ui
PORT="${PORT}" "${ROOT_DIR}/burn.sh" homeserver-ui
@@ -97,7 +97,7 @@ enum ActionId {
ACT_VERIFY_SERVERS,
ACT_SET_TEST_SERVERS,
ACT_EDIT_LOGIN,
ACT_EDIT_SUBSERVER,
ACT_EDIT_HOMESERVER,
ACT_GENERATE_SECRET,
ACT_CLEAR_ACCOUNT,
ACT_SHOW_QR,
@@ -137,7 +137,7 @@ enum EditTarget {
EDIT_SSID,
EDIT_WIFI_PASSWORD,
EDIT_LOGIN,
EDIT_SUBSERVER,
EDIT_HOMESERVER,
EDIT_API,
EDIT_RPC,
EDIT_WS,
@@ -174,7 +174,7 @@ struct AppData {
String wifiSsid;
String wifiPassword;
String login;
String subserverName;
String homeserverName;
String secret;
String walletAddress;
String userPdaAddress;
@@ -551,7 +551,7 @@ static bool canRegister() {
static String registrationSummary() {
if (gData.registered) {
return "Сабсервер активен";
return "Homeserver активен";
}
if (!gData.wifiReady) {
return "Нужен Wi-Fi";
@@ -1179,7 +1179,7 @@ static bool awaitTransactionConfirmation(const String &signatureB58, String &mes
return false;
}
static bool registerSubserverOnSolana(String &messageOut) {
static bool registerHomeserverOnSolana(String &messageOut) {
messageOut = "";
if (!gDerivedKeys.ready) {
if (!restoreDerivedKeysFromSecret()) {
@@ -1656,7 +1656,7 @@ static bool refreshWalletBalance(String &messageOut) {
static void seedRequests() {
gRequests[0].type = "Вход в сессию";
gRequests[0].actor = "Chrome / aidarkc";
gRequests[0].details = "Клиент просит подключиться к сабсерверу и открыть сессию без ввода пароля.";
gRequests[0].details = "Клиент просит подключиться к homeserverу и открыть сессию без ввода пароля.";
gRequests[0].status = "Ожидает";
gRequests[1].type = "Подпись сообщения";
@@ -1670,7 +1670,7 @@ static void loadDefaults() {
gData.wifiSsid = "";
gData.wifiPassword = "";
gData.login = "";
gData.subserverName = "subserver1";
gData.homeserverName = "homeserver1";
gData.secret = "";
gData.walletAddress = "";
gData.userPdaAddress = "";
@@ -1692,7 +1692,7 @@ static void saveData() {
gPrefs.putString("wifi_ssid", gData.wifiSsid);
gPrefs.putString("wifi_pass", gData.wifiPassword);
gPrefs.putString("login", gData.login);
gPrefs.putString("subserver", gData.subserverName);
gPrefs.putString("homeserver", gData.homeserverName);
gPrefs.putString("secret", gData.secret);
gPrefs.putString("wallet", gData.walletAddress);
gPrefs.putString("user_pda", gData.userPdaAddress);
@@ -1714,7 +1714,7 @@ static void loadData() {
gData.wifiSsid = gPrefs.getString("wifi_ssid", gData.wifiSsid);
gData.wifiPassword = gPrefs.getString("wifi_pass", gData.wifiPassword);
gData.login = gPrefs.getString("login", gData.login);
gData.subserverName = gPrefs.getString("subserver", gData.subserverName);
gData.homeserverName = gPrefs.getString("homeserver", gData.homeserverName);
gData.secret = gPrefs.getString("secret", gData.secret);
gData.walletAddress = gPrefs.getString("wallet", gData.walletAddress);
gData.userPdaAddress = gPrefs.getString("user_pda", gData.userPdaAddress);
@@ -1758,8 +1758,8 @@ static void generateSecretAndWallet() {
gData.registrationSignature = "";
gData.registered = false;
gData.online = false;
if (gData.subserverName.length() == 0) {
gData.subserverName = "subserver1";
if (gData.homeserverName.length() == 0) {
gData.homeserverName = "homeserver1";
}
saveData();
}
@@ -1815,7 +1815,7 @@ static String editTargetLabel() {
case EDIT_SSID: return "SSID";
case EDIT_WIFI_PASSWORD: return "Пароль Wi-Fi";
case EDIT_LOGIN: return "Логин";
case EDIT_SUBSERVER: return "Имя сабсервера";
case EDIT_HOMESERVER: return "Имя homeserver";
case EDIT_API: return "API URL";
case EDIT_RPC: return "RPC URL";
case EDIT_WS: return "WS URL";
@@ -1846,7 +1846,7 @@ static void drawHomeScreen() {
drawPanel(20, 92, 440, 98, C_PANEL, C_BORDER, 16);
drawText(36, 122, registrationSummary(), canRegister() || gData.registered ? C_ACCENT : C_WARN, (const uint8_t *)FONT_HEAD);
drawText(36, 152, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT, (const uint8_t *)FONT_BODY);
drawText(36, 174, "Сабсервер: " + gData.subserverName, C_MUTE, (const uint8_t *)FONT_BODY);
drawText(36, 174, "Homeserver: " + gData.homeserverName, C_MUTE, (const uint8_t *)FONT_BODY);
drawPanel(20, 204, 210, 82, C_CARD, C_BORDER, 12);
drawText(34, 232, "Wi-Fi", C_TEXT, (const uint8_t *)FONT_BODY);
@@ -1871,7 +1871,7 @@ static void drawStatusScreen() {
drawTopBar("Статус");
drawPanel(20, 92, 440, 286, C_PANEL, C_BORDER, 16);
drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT);
drawText(36, 148, "Сабсервер: " + gData.subserverName, C_TEXT);
drawText(36, 148, "Homeserver: " + gData.homeserverName, C_TEXT);
drawText(36, 174, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN);
drawText(36, 200, "Отпечаток: " + (gData.secretReady ? shortenValue(gData.secret) : "-"), C_MUTE, (const uint8_t *)FONT_SMALL);
drawText(36, 226, "Wi-Fi: " + boolText(gData.wifiReady, "готов", "не готов"), gData.wifiReady ? C_ACCENT : C_WARN);
@@ -1947,13 +1947,13 @@ static void drawAccountScreen() {
drawTopBar("Аккаунт");
drawPanel(20, 92, 440, 188, C_PANEL, C_BORDER, 16);
drawText(36, 122, "Логин: " + (gData.login.length() ? gData.login : "не задан"), C_TEXT);
drawText(36, 152, "Сабсервер: " + gData.subserverName, C_TEXT);
drawText(36, 152, "Homeserver: " + gData.homeserverName, C_TEXT);
drawText(36, 182, "Секрет: " + boolText(gData.secretReady, "сохранён", "не задан"), gData.secretReady ? C_ACCENT : C_WARN);
drawText(36, 212, "Кошелёк: " + (gData.walletAddress.length() ? shortenValue(gData.walletAddress, 10, 8) : "не создан"), C_MUTE, (const uint8_t *)FONT_SMALL);
drawText(36, 236, "Регистрация: " + boolText(gData.registered, "выполнена", "не выполнена"), gData.registered ? C_ACCENT : C_WARN);
drawText(36, 260, "PDA: " + registrationDetailsShort(), C_MUTE, (const uint8_t *)FONT_SMALL);
addButton(20, 300, 212, 48, ACT_EDIT_LOGIN, "Изменить логин");
addButton(248, 300, 212, 48, ACT_EDIT_SUBSERVER, "Имя сабсервера");
addButton(248, 300, 212, 48, ACT_EDIT_HOMESERVER, "Имя homeserver");
addButton(20, 360, 212, 48, ACT_GENERATE_SECRET, "Сгенерировать", true, C_OK);
addButton(248, 360, 212, 48, ACT_CLEAR_ACCOUNT, "Очистить", true, C_BUTTON2);
addButton(20, 420, 440, 36, ACT_BACK, "Назад");
@@ -2193,9 +2193,9 @@ static void applyEditValue() {
gData.registrationSignature = "";
gNotice = "Логин сохранён";
break;
case EDIT_SUBSERVER:
gData.subserverName = value.length() ? value : "subserver1";
gNotice = "Имя сабсервера сохранено";
case EDIT_HOMESERVER:
gData.homeserverName = value.length() ? value : "homeserver1";
gNotice = "Имя homeserver сохранено";
break;
case EDIT_API:
gData.apiUrl = value;
@@ -2351,7 +2351,7 @@ static void handleAction(ActionId action) {
}
if (action == ACT_CONFIRM_YES) {
if (gConfirmTarget == CONFIRM_REGISTER) {
registerSubserverOnSolana(gNotice);
registerHomeserverOnSolana(gNotice);
} else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) {
gData.secret = "";
gData.walletAddress = "";
@@ -2445,7 +2445,7 @@ static void handleAction(ActionId action) {
gNeedRedraw = true;
break;
case ACT_EDIT_LOGIN: openEdit(EDIT_LOGIN, gData.login, false); break;
case ACT_EDIT_SUBSERVER: openEdit(EDIT_SUBSERVER, gData.subserverName, false); break;
case ACT_EDIT_HOMESERVER: openEdit(EDIT_HOMESERVER, gData.homeserverName, false); break;
case ACT_GENERATE_SECRET:
generateSecretAndWallet();
gNotice = gData.secretReady ? "Секрет сгенерирован, device-кошелёк выведен из него" : "Не удалось сгенерировать секрет";
@@ -2,7 +2,7 @@
Набор отдельных диагностических скетчей для `Waveshare ESP32-S3-Touch-AMOLED-2.16`.
Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_subserver_ui`.
Скетчи в этой папке нужны для быстрой проверки конкретных гипотез без влияния на основной `shine_homeserver_ui`.
## Список
@@ -12,7 +12,7 @@
- `lvgl_interaction_test/` - расширенный тест `LVGL` с 9 кнопками, touch-вводом и статусом нажатия
- `lvgl_touch_debug_test/` - диагностика touch: сырые координаты, точка касания и одна большая кнопка `LVGL`
- `lvgl_official_based_test/` - минимальный наш экран поверх максимально близкой к официальному `05_LVGL_Widgets` инициализации
- `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_subserver_ui`; подтверждён на реальном устройстве
- `lvgl_subserver_touch_test/` - гибридный тест: `LVGL`-экран с инициализацией дисплея и чтением touch из `shine_homeserver_ui`; подтверждён на реальном устройстве
- `lvgl_russian_font_test/` - тест кастомного кириллического `LVGL`-шрифта с русскими кнопками, длинными строками и рабочим touch
- `lvgl_nav_minimal_test/` - новый минимальный навигационный каркас сабсервера на рабочем `LVGL + subserver touch`, расширенный настройкой Wi-Fi и сохранением в NVS
@@ -48,7 +48,7 @@
#define TEXT_EDIT_PANEL_Y 112
#define TEXT_EDIT_PANEL_W 460
#define TEXT_EDIT_PANEL_H 330
#define TEST_VERSION "SHiNE subserver (v.0.18)"
#define TEST_VERSION "SHiNE homeserver (v.0.18)"
static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm";
static const char *kShineUsersUserPdaSeedPrefix = "user_login=";
@@ -60,7 +60,7 @@ static const uint8_t kBlockTypeServerProfile = 30;
static const uint8_t kBlockTypeAccessServers = 40;
static const uint8_t kBlockTypeSessions = 50;
static const uint8_t kBlockTypeTrustedState = 70;
static const uint8_t kSessionTypeSubserver = 100;
static const uint8_t kSessionTypeHomeserver = 100;
enum Screen {
SCREEN_HOME,
@@ -68,7 +68,7 @@ enum Screen {
SCREEN_WIFI,
SCREEN_SERVER,
SCREEN_ACCOUNT,
SCREEN_ACCOUNT_SUBSERVER,
SCREEN_ACCOUNT_HOMESERVER,
SCREEN_ACCOUNT_SECRET,
SCREEN_SECRET_SHOW,
SCREEN_SECRET_GENERATE_INFO,
@@ -99,10 +99,10 @@ enum ActionId {
ACTION_SERVER_EDIT_SOLANA,
ACTION_SERVER_EDIT_SHINE,
ACTION_ACCOUNT_EDIT_LOGIN,
ACTION_ACCOUNT_EDIT_SUBSERVER,
ACTION_ACCOUNT_EDIT_HOMESERVER,
ACTION_ACCOUNT_EDIT_SECRET,
ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT,
ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL,
ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT,
ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL,
ACTION_SECRET_SHOW,
ACTION_SECRET_MANUAL,
ACTION_SECRET_GENERATE,
@@ -129,7 +129,7 @@ enum EditContext {
EDIT_CONTEXT_SOLANA_RPC,
EDIT_CONTEXT_SHINE_SERVER,
EDIT_CONTEXT_LOGIN,
EDIT_CONTEXT_SUBSERVER,
EDIT_CONTEXT_HOMESERVER,
EDIT_CONTEXT_SECRET_MANUAL,
EDIT_CONTEXT_SECRET_GENERATE_PASSWORD,
};
@@ -216,7 +216,7 @@ static String gSolanaRpcUrl = "https://api.devnet.solana.com";
static String gShineServerUrl = "https://shineup.me";
static String gServerStatusMessage = "Edit RPC or shine host";
static String gLoginValue;
static String gSubserverValue = "subserver1";
static String gHomeserverValue = "homeserver1";
static bool gSecretConfigured = false;
static String gSecretBase58;
static uint8_t gSecretBytes[32] = {};
@@ -257,8 +257,8 @@ static String gBlockchainPubB58;
static String gBlockchainPrivB58;
static String gDevicePubB58;
static String gDevicePrivB58;
static String gSubserverPubB58;
static String gSubserverPrivB58;
static String gHomeserverPubB58;
static String gHomeserverPrivB58;
static EditContext gEditContext = EDIT_CONTEXT_NONE;
static Screen gEditReturnScreen = SCREEN_HOME;
@@ -305,7 +305,7 @@ static void restoreTextareaFromEditValue();
static void refreshDerivedKeys();
static void clearDerivedKeys();
static String loginDisplayValue();
static String subserverDisplayValue();
static String homeserverDisplayValue();
static String homeSecretStatus();
static String secretButtonValue();
static void clearSecretValue();
@@ -687,13 +687,13 @@ static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &p
return hostOut.length() > 0 && portOut > 0;
}
static String subserverKeySuffix() {
String name = gSubserverValue;
static String homeserverKeySuffix() {
String name = gHomeserverValue;
name.trim();
if (name.isEmpty()) {
name = "subserver1";
name = "homeserver1";
}
return String("subserver.key:") + name;
return String("homeserver.key:") + name;
}
static void deriveKeyPairFromSecretSuffix(const uint8_t *secret32, const String &suffix, String &pubB58, String &privB58) {
@@ -718,8 +718,8 @@ static void clearDerivedKeys() {
gBlockchainPrivB58 = "";
gDevicePubB58 = "";
gDevicePrivB58 = "";
gSubserverPubB58 = "";
gSubserverPrivB58 = "";
gHomeserverPubB58 = "";
gHomeserverPrivB58 = "";
}
static void refreshDerivedKeys() {
@@ -730,7 +730,7 @@ static void refreshDerivedKeys() {
deriveKeyPairFromSecretSuffix(gSecretBytes, "root.key", gRootPubB58, gRootPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "bch.key", gBlockchainPubB58, gBlockchainPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, "dev.key", gDevicePubB58, gDevicePrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, subserverKeySuffix(), gSubserverPubB58, gSubserverPrivB58);
deriveKeyPairFromSecretSuffix(gSecretBytes, homeserverKeySuffix(), gHomeserverPubB58, gHomeserverPrivB58);
}
static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) {
@@ -1302,20 +1302,20 @@ static void refreshAccountPdaStatus() {
mismatch = "blockchain key mismatch";
} else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) {
mismatch = "device key mismatch";
} else if (gSubserverValue.isEmpty()) {
mismatch = "subserver not set";
} else if (gHomeserverValue.isEmpty()) {
mismatch = "homeserver not set";
} else {
bool foundSession = false;
bool sessionMismatch = false;
for (const auto &session : pdaState.sessions) {
if (session.sessionType == kSessionTypeSubserver && session.sessionName == gSubserverValue) {
if (session.sessionType == kSessionTypeHomeserver && session.sessionName == gHomeserverValue) {
foundSession = true;
if (gSubserverPubB58.isEmpty()) {
if (gHomeserverPubB58.isEmpty()) {
sessionMismatch = true;
} else {
uint8_t subserverPub[32] = {};
if (!base58ToFixed32(gSubserverPubB58, subserverPub)
|| memcmp(subserverPub, session.sessionPubKey32, 32) != 0) {
uint8_t homeserverPub[32] = {};
if (!base58ToFixed32(gHomeserverPubB58, homeserverPub)
|| memcmp(homeserverPub, session.sessionPubKey32, 32) != 0) {
sessionMismatch = true;
}
}
@@ -1323,9 +1323,9 @@ static void refreshAccountPdaStatus() {
}
}
if (!foundSession) {
mismatch = "subserver not in PDA";
mismatch = "homeserver not in PDA";
} else if (sessionMismatch) {
mismatch = "subserver key mismatch";
mismatch = "homeserver key mismatch";
}
}
@@ -1565,7 +1565,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
errorOut = "Wi-Fi disconnected";
return false;
}
if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) {
if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
errorOut = "account not configured";
return false;
}
@@ -1605,7 +1605,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
uint8_t subPub[32] = {};
uint8_t subSec[64] = {};
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)
|| !deriveSeedKeypairFromBase58(gSubserverPrivB58, subSeed, subPub, subSec)) {
|| !deriveSeedKeypairFromBase58(gHomeserverPrivB58, subSeed, subPub, subSec)) {
errorOut = "local key derive failed";
return false;
}
@@ -1634,7 +1634,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
+ "\",\"sessionKey\":\"" + jsonEscape(sessionKey)
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
+ ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
+ "\",\"clientInfo\":\"ESP32 subserver\"}";
+ "\",\"clientInfo\":\"ESP32 homeserver\"}";
String loginResp;
if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) {
if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) {
@@ -1691,7 +1691,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
+ ",\"authNonce\":\"" + jsonEscape(authNonce)
+ "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
+ "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
+ "\",\"clientInfo\":\"ESP32 subserver\"}";
+ "\",\"clientInfo\":\"ESP32 homeserver\"}";
String createResp;
if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) {
errorOut = "CreateAuthSession failed";
@@ -1710,7 +1710,7 @@ static bool ensureShineSessionAuthenticated(String &errorOut) {
static void manageShineConnection() {
String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl;
if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) {
if (gLoginValue.isEmpty() || !gSecretConfigured || gHomeserverValue.isEmpty()) {
gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured";
clearShineSessionState(false);
return;
@@ -1802,7 +1802,7 @@ static void loadPrefs() {
gSolanaRpcUrl = gPrefs.getString("solana_rpc", "https://api.devnet.solana.com");
gShineServerUrl = gPrefs.getString("shine_server", "https://shineup.me");
gLoginValue = gPrefs.getString("login", "");
gSubserverValue = gPrefs.getString("subserver", "subserver1");
gHomeserverValue = gPrefs.getString("homeserver", "homeserver1");
gSecretConfigured = gPrefs.getBool("secret_set", false);
gSecretBase58 = gPrefs.getString("secret_b58", "");
if (gSecretConfigured && gPrefs.getBytesLength("secret_bytes") == 32) {
@@ -1850,7 +1850,7 @@ static void saveServerPrefs() {
static void saveAccountPrefs() {
gPrefs.putString("login", gLoginValue);
gPrefs.putString("subserver", gSubserverValue);
gPrefs.putString("homeserver", gHomeserverValue);
gPrefs.putBool("secret_set", gSecretConfigured);
gPrefs.putString("secret_b58", gSecretBase58);
if (gSecretConfigured) {
@@ -1925,8 +1925,8 @@ static String loginDisplayValue() {
return gLoginValue.isEmpty() ? "login not set" : gLoginValue;
}
static String subserverDisplayValue() {
return gSubserverValue.isEmpty() ? "subserver not set" : gSubserverValue;
static String homeserverDisplayValue() {
return gHomeserverValue.isEmpty() ? "homeserver not set" : gHomeserverValue;
}
static String homeSecretStatus() {
@@ -2234,13 +2234,13 @@ static void applyEditorValue() {
return;
}
if (gEditContext == EDIT_CONTEXT_SUBSERVER) {
if (gEditContext == EDIT_CONTEXT_HOMESERVER) {
value.trim();
gSubserverValue = value;
gHomeserverValue = value;
refreshDerivedKeys();
saveAccountPrefs();
markAccountStateDirty();
gAccountStatusMessage = gSubserverValue.isEmpty() ? "Subserver cleared" : "Subserver saved";
gAccountStatusMessage = gHomeserverValue.isEmpty() ? "Homeserver cleared" : "Homeserver saved";
showScreen(SCREEN_ACCOUNT);
return;
}
@@ -2465,8 +2465,8 @@ static void actionButtonCb(lv_event_t *event) {
gLoginValue,
false);
break;
case ACTION_ACCOUNT_EDIT_SUBSERVER:
showScreen(SCREEN_ACCOUNT_SUBSERVER);
case ACTION_ACCOUNT_EDIT_HOMESERVER:
showScreen(SCREEN_ACCOUNT_HOMESERVER);
break;
case ACTION_ACCOUNT_EDIT_SECRET:
showScreen(SCREEN_ACCOUNT_SECRET);
@@ -2506,20 +2506,20 @@ static void actionButtonCb(lv_event_t *event) {
case ACTION_SECRET_GENERATE_CANCEL_NO:
showScreen(SCREEN_SECRET_GENERATE_RUNNING);
break;
case ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT:
gSubserverValue = "subserver1";
case ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT:
gHomeserverValue = "homeserver1";
refreshDerivedKeys();
saveAccountPrefs();
markAccountStateDirty();
gAccountStatusMessage = "Subserver set to subserver1";
gAccountStatusMessage = "Homeserver set to homeserver1";
showScreen(SCREEN_ACCOUNT);
break;
case ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL:
openEditor(EDIT_CONTEXT_SUBSERVER,
case ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL:
openEditor(EDIT_CONTEXT_HOMESERVER,
SCREEN_ACCOUNT,
"EDIT SUBSERVER",
"EDIT HOMESERVER",
"",
gSubserverValue,
gHomeserverValue,
false);
break;
case ACTION_BACK_SECRET_MENU:
@@ -2616,17 +2616,17 @@ static void makeVersionTag() {
static void drawHome() {
setRootStyle();
lv_obj_t *subserver = lv_label_create(gRoot);
lv_label_set_text(subserver, subserverDisplayValue().c_str());
lv_obj_set_style_text_font(subserver, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(subserver, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(subserver, LV_ALIGN_TOP_LEFT, 24, 18);
lv_obj_t *homeserver = lv_label_create(gRoot);
lv_label_set_text(homeserver, homeserverDisplayValue().c_str());
lv_obj_set_style_text_font(homeserver, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(homeserver, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(homeserver, LV_ALIGN_TOP_LEFT, 24, 18);
lv_obj_t *login = lv_label_create(gRoot);
lv_label_set_text(login, loginDisplayValue().c_str());
lv_obj_set_style_text_font(login, &lv_font_montserrat_22, 0);
lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0);
lv_obj_align_to(login, subserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6);
lv_obj_align_to(login, homeserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6);
lv_obj_t *accountDot = lv_obj_create(gRoot);
lv_obj_set_size(accountDot, 14, 14);
@@ -2793,23 +2793,23 @@ static void drawAccountScreen() {
showMessageAt(gAccountStatusMessage, 56);
String loginButton = String("Login (") + (gLoginValue.isEmpty() ? "not set" : gLoginValue) + ")";
String subserverButton = String("Subserver (") + (gSubserverValue.isEmpty() ? "not set" : gSubserverValue) + ")";
String homeserverButton = String("Homeserver (") + (gHomeserverValue.isEmpty() ? "not set" : gHomeserverValue) + ")";
String secretButton = String("Secret (") + secretButtonValue() + ")";
makeButton(loginButton.c_str(), 22, 118, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_LOGIN, &lv_font_montserrat_20);
makeButton(subserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SUBSERVER, &lv_font_montserrat_20);
makeButton(homeserverButton.c_str(), 22, 222, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_HOMESERVER, &lv_font_montserrat_20);
makeButton(secretButton.c_str(), 22, 326, 436, 84, 0x355C7D, ACTION_ACCOUNT_EDIT_SECRET, &lv_font_montserrat_20);
makeBody("Swipe right to return to Settings.", 420, 420);
makeVersionTag();
}
static void drawAccountSubserverScreen() {
static void drawAccountHomeserverScreen() {
setRootStyle();
makeTitle("SUBSERVER", 18, &lv_font_montserrat_24);
showMessageAt(String("Current: ") + subserverDisplayValue(), 56);
makeBody("If you only use one subserver, keep the default name subserver1.", 98, 420);
makeButton("USE SUBSERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_SUBSERVER_USE_DEFAULT, &lv_font_montserrat_22);
makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_SUBSERVER_EDIT_MANUAL, &lv_font_montserrat_22);
makeTitle("HOMESERVER", 18, &lv_font_montserrat_24);
showMessageAt(String("Current: ") + homeserverDisplayValue(), 56);
makeBody("If you only use one homeserver, keep the default name homeserver1.", 98, 420);
makeButton("USE HOMESERVER1", 22, 202, 436, 84, 0x2A9D8F, ACTION_ACCOUNT_HOMESERVER_USE_DEFAULT, &lv_font_montserrat_22);
makeButton("EDIT MANUALLY", 22, 306, 436, 84, 0x355C7D, ACTION_ACCOUNT_HOMESERVER_EDIT_MANUAL, &lv_font_montserrat_22);
makeButton("BACK", 140, 402, 200, 54, 0x5A6570, ACTION_BACK_ACCOUNT, &lv_font_montserrat_20);
makeVersionTag();
}
@@ -2876,8 +2876,8 @@ static void drawSecretShowScreen() {
addKeyBlock("Blockchain key priv (base58)", "sha256(base64(secret)|bch.key)", gBlockchainPrivB58);
addKeyBlock("Device key (base58)", "pub from sha256(base64(secret)|dev.key)", gDevicePubB58);
addKeyBlock("Device key priv (base58)", "sha256(base64(secret)|dev.key)", gDevicePrivB58);
addKeyBlock("Subserver key (base58)", String("pub from sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPubB58);
addKeyBlock("Subserver key priv (base58)", String("sha256(base64(secret)|") + subserverKeySuffix() + ")", gSubserverPrivB58);
addKeyBlock("Homeserver key (base58)", String("pub from sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPubB58);
addKeyBlock("Homeserver key priv (base58)", String("sha256(base64(secret)|") + homeserverKeySuffix() + ")", gHomeserverPrivB58);
} else {
showMessageAt("Secret not set", 96);
}
@@ -3084,8 +3084,8 @@ static void rebuildScreen() {
case SCREEN_ACCOUNT:
drawAccountScreen();
break;
case SCREEN_ACCOUNT_SUBSERVER:
drawAccountSubserverScreen();
case SCREEN_ACCOUNT_HOMESERVER:
drawAccountHomeserverScreen();
break;
case SCREEN_ACCOUNT_SECRET:
drawAccountSecretScreen();
@@ -3219,7 +3219,7 @@ static void handleSwipe(SwipeDirection swipe) {
case SCREEN_ACCOUNT:
handleAccountSwipe(swipe);
break;
case SCREEN_ACCOUNT_SUBSERVER:
case SCREEN_ACCOUNT_HOMESERVER:
case SCREEN_ACCOUNT_SECRET:
handleAccountSubscreenSwipe(swipe);
break;
@@ -4,7 +4,7 @@
#include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp>
// Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_subserver_ui.
// Подтверждено на устройстве: LVGL-рендер работает вместе с touch-путём из shine_homeserver_ui.
#define PIN_LCD_CS 12
#define PIN_LCD_SCLK 38
@@ -146,7 +146,7 @@ static void createUi() {
lv_obj_align(gVersionLabel, LV_ALIGN_TOP_MID, 0, 12);
lv_obj_t *subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Touch path comes from shine_subserver_ui. Tap buttons and watch status.");
lv_label_set_text(subtitle, "Touch path comes from shine_homeserver_ui. Tap buttons and watch status.");
lv_obj_set_width(subtitle, 436);
lv_label_set_long_mode(subtitle, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_14, 0);