diff --git a/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md new file mode 100644 index 0000000..0505c21 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-07_1650_esp32_subserver_ui_прототип.md @@ -0,0 +1,26 @@ +# ESP32 UI-прототип сабсервера SHiNE + +- краткое описание фичи: + для `Waveshare ESP32-S3-Touch-AMOLED-2.16` добавлен новый интерактивный UI-скетч сабсервера `SHiNE` с хранением данных в `NVS`, PIN-блокировкой, настройками `Wi-Fi`, настройками серверов, кошельком, экраном `QR/URI`, живой Solana-регистрацией и экраном входящих запросов. В текущей версии `Wi-Fi` подключается реально, адреса `API/RPC/WS` проверяются реально, баланс кошелька читается из `Solana RPC`, а регистрация отправляет `create_user_pda` в `shine_users`. + +- что именно проверять: + 1. Прошить режим `subserver-ui` и дождаться старта экрана блокировки. + 2. Проверить, что русский текст в заголовках, кнопках и статусах отображается корректно, без кракозябр и замены на английский. + 3. Ввести PIN `1234` и убедиться, что открывается главный экран. + 4. Открыть `Подключение -> Wi-Fi`, ввести `SSID` и пароль, нажать `Проверить`, дождаться реального подключения, затем перезагрузить устройство и проверить, что значения сохранились. + 5. Открыть `Подключение -> Серверы`, проверить или изменить `API/RPC/WS`, нажать `Проверить` и убедиться, что показываются реальные статусы доступности, затем перезагрузить устройство и проверить сохранение значений. + 6. Открыть `Аккаунт`, ввести логин, имя сабсервера и нажать `Сгенерировать`; проверить, что появились секрет и адрес кошелька, а после перезагрузки они не исчезают. + 7. Открыть `Кошелёк`, нажать `Проверить` и убедиться, что баланс реально читается из `Solana RPC`; затем открыть `QR и URI` и проверить, что QR-код отрисовывается и сканируется как `solana:`-ссылка. + 8. При необходимости отдельно проверить тестовые кнопки `+/- SOL`: они меняют локальный баланс для UX-сценариев, но после следующей реальной RPC-проверки баланс должен вернуться к сетевому значению. + 9. Вернуться на главный экран и проверить, что до выполнения всех условий кнопка регистрации недоступна, а после выполнения становится доступной. + 10. Выполнить регистрацию и убедиться, что статус меняется на `Сабсервер активен`, онлайн-статус становится активным, а на экране появляются краткие отпечатки `PDA/TX`. + 11. После регистрации проверить через `Solana`/UI проекта, что `user_pda` для этого логина реально создана и соответствует `device`-адресу устройства. + 12. Открыть `Запросы`, поочерёдно открыть оба демонстрационных запроса и проверить, что кнопки `Разрешить` и `Отклонить` меняют их статус. + 13. Открыть `Настройки`, сменить PIN, затем заблокировать/перезагрузить устройство и проверить вход с новым PIN. + 14. Выполнить `Полный сброс` и убедиться, что все поля, секрет, баланс, онлайн и регистрация очищаются. + +- ожидаемый результат: + новый `ESP32`-скетч стабильно запускается, показывает нормальный русский интерфейс, сохраняет данные во внутренней памяти устройства, реально подключается к `Wi-Fi`, реально проверяет `API/RPC/WS`, реально читает баланс из `Solana RPC`, рисует рабочий `QR` для `solana:`-URI и позволяет вручную пройти полный сценарий on-chain регистрации сабсервера. + +- статус: + pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md new file mode 100644 index 0000000..66e4021 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-08_1150_esp32_auto_flash_script.md @@ -0,0 +1,13 @@ +# ESP32 авто-прошивка shine_subserver_ui + +- краткое описание фичи: + добавлен исполняемый скрипт `flash_shine_subserver_ui.sh`, который автоматически ищет USB-порт `ESP32` и запускает заливку прошивки `shine_subserver_ui` без ручного указания `PORT`. +- что именно проверять: + 1. Подключить плату `ESP32` по USB. + 2. Перейти в папку `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/`. + 3. Запустить `./flash_shine_subserver_ui.sh`. + 4. Убедиться, что скрипт сам показывает найденный порт и успешно запускает compile/upload. +- ожидаемый результат: + скрипт без ручного ввода порта находит `ESP32`, печатает найденный `/dev/ttyACM*` или `/dev/ttyUSB*` и заливает `shine_subserver_ui`. +- статус: + pending diff --git a/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md b/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md new file mode 100644 index 0000000..f29c8d7 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-08_1245_esp32_pin_button_labels.md @@ -0,0 +1,12 @@ +# ESP32 PIN-клавиатура: подписи кнопок + +- краткое описание фичи: + в UI-скетче `shine_subserver_ui` изменена отрисовка подписей кнопок. Вместо малого шрифта теперь используется более стабильный шрифт с явным центрированием текста внутри кнопок, чтобы на экране ввода PIN и других экранах не пропадали цифры и надписи. +- что именно проверять: + 1. Включить устройство и дождаться экрана ввода PIN. + 2. Убедиться, что на всех серых кнопках видны цифры `0-9`, `Отмена` и `OK`. + 3. Открыть другие экраны с кнопками (`Главный экран`, `Wi-Fi`, `Серверы`, `Настройки`) и убедиться, что подписи отображаются и не уезжают за границы кнопок. +- ожидаемый результат: + подписи кнопок стабильно видны сразу после старта, текст визуально центрирован, пустых серых кнопок без цифр и названий нет. +- статус: + pending diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_spec.md new file mode 100644 index 0000000..97ccafe --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_spec.md @@ -0,0 +1,478 @@ +# SHiNE ESP32 Subserver UI Spec + +## Назначение + +Этот документ описывает актуальный UI-прототип сабсервера `SHiNE` для платы `Waveshare ESP32-S3-Touch-AMOLED-2.16`. + +Документ является источником истины для Arduino-скетча: + +- если меняется этот документ, должен меняться и скетч; +- если меняется скетч, должен обновляться и этот документ; +- экраны, кнопки, поля, статусы, переходы и тексты не должны расходиться. + +## Текущий объём реализации + +Текущая реализация является интерактивным прототипом экрана устройства, пригодным для ручной проверки на железе. + +Что уже входит в прототип: + +- локальный UI на тач-экране; +- хранение настроек и секретов во внутренней памяти `ESP32` через `NVS`; +- русский текст на экране через `UTF-8` + кириллический шрифт `U8g2`; +- экран пополнения с реальным `solana:` URI и рисованием QR-кода; +- реальное подключение к `Wi-Fi` по сохранённым `SSID/паролю`; +- реальная проверка доступности `API`, `RPC` и `WS`-адресов; +- реальное чтение баланса кошелька из `Solana RPC`; +- проверка обязательных условий перед регистрацией; +- живая on-chain регистрация серверного `user_pda` в `shine_users` через `device key` устройства; +- прототип входящих запросов с подтверждением и отклонением; +- PIN-блокировка; +- базовые настройки, статус и главный экран; +- сохранение `PDA` и `tx signature` после успешной регистрации. + +Что пока считается именно прототипом, а не финальной интеграцией: + +- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети; +- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения. + +## Основная идея устройства + +Устройство работает как отдельный сабсервер: + +- хранит секрет на самом устройстве; +- позволяет ввести логин, секрет и имя сабсервера; +- показывает адрес кошелька устройства; +- позволяет пополнить баланс перед регистрацией; +- после выполнения условий даёт зарегистрировать устройство как сабсервер; +- после регистрации может принимать входящие запросы на вход и на подпись. + +`SD`-карта не нужна для постоянного хранения секрета в этом прототипе. +Основное сохранение идёт во внутреннюю flash-память через `NVS`. + +## Данные, которые хранятся на устройстве + +Прототип хранит: + +- `PIN`; +- `Wi-Fi SSID`; +- `Wi-Fi password`; +- `login`; +- `session/subserver name`; +- `master secret`; +- `wallet address`; +- `user pda address`; +- `registration signature`; +- `balance`; +- `server api url`; +- `server rpc url`; +- `server ws url`; +- флаги: + `wifiReady`, `serversReady`, `secretReady`, `registered`, `online`. + +## Правила готовности к регистрации + +Кнопка регистрации доступна только если одновременно выполнены условия: + +1. настроен и подтверждён `Wi-Fi`; +2. заполнены и подтверждены серверные адреса; +3. задан логин; +4. сгенерирован или введён секрет; +5. баланс кошелька не меньше `0.20 SOL`; +6. устройство ещё не зарегистрировано. + +Если хотя бы одно условие не выполнено, главный экран показывает, чего именно не хватает. + +## Экранная модель + +В прототипе используются следующие экраны: + +1. `LOCK` +2. `HOME` +3. `STATUS` +4. `CONNECTION` +5. `WIFI_EDIT` +6. `SERVERS` +7. `ACCOUNT` +8. `WALLET` +9. `WALLET_QR` +10. `REQUESTS` +11. `REQUEST_DETAIL` +12. `SETTINGS` +13. `PIN_EDIT` +14. `TEXT_INPUT` +15. `CONFIRM` + +## Общие правила интерфейса + +- Верхняя строка всегда показывает краткий статус устройства: + `PIN`, `Wi-Fi`, `сервер`, `регистрация`. +- Основной язык прототипа: русский. +- Для вывода текста используется `UTF-8`. +- Для кириллицы используется `U8g2`-шрифт с поддержкой `Cyrillic`. +- Кнопки крупные, с тач-ориентированным размером. +- Опасные действия подтверждаются отдельным диалогом. +- После изменения данных конфигурация сразу сохраняется в `NVS`. + +## Экран LOCK + +Назначение: + +- блокировка устройства после запуска; +- вход по PIN. + +Отображается: + +- заголовок `SHiNE Device`; +- статус `Устройство заблокировано`; +- поле ввода PIN в виде маски; +- кнопки цифровой клавиатуры; +- кнопки `Стереть` и `Открыть`. + +Поведение: + +- если PIN введён верно, открывается `HOME`; +- если PIN неверный, показывается ошибка `Неверный PIN`. + +## Экран HOME + +Это основной экран устройства. + +Показывает: + +- крупный статус регистрации; +- имя логина; +- имя сабсервера; +- короткий статус Wi-Fi; +- короткий статус сервера; +- короткий статус баланса. + +Нижние кнопки: + +- `Статус` +- `Подключение` +- `Аккаунт` +- `Кошелёк` +- `Запросы` +- `Настройки` + +Дополнительная большая кнопка: + +- `Зарегистрировать` + +Если регистрация уже сделана: + +- вместо призыва к регистрации показывается статус `Сабсервер активен`. + +## Экран STATUS + +Показывает сводку: + +- логин; +- сабсервер; +- есть ли секрет; +- зарегистрировано ли устройство; +- подключён ли Wi-Fi; +- доступны ли серверы; +- хватает ли баланса; +- находится ли устройство онлайн; +- краткий отпечаток `PDA` или `tx`. + +Кнопки: + +- `Назад` +- `Обновить статус` + +`Обновить статус` в прототипе не делает сеть, а просто перерисовывает текущие вычисленные состояния. + +## Экран CONNECTION + +Показывает: + +- `Wi-Fi`: готов / не готов; +- `Серверы`: готовы / не готовы; +- `Онлайн`: да / нет. + +Кнопки: + +- `Wi-Fi` +- `Серверы` +- `Подключить` +- `Отключить` +- `Назад` + +Поведение: + +- `Подключить` переводит устройство в `online=true`, если `Wi-Fi` реально подключён и серверы реально проверены; +- `Отключить` переводит устройство в `online=false`. + +## Экран WIFI_EDIT + +Поля: + +- `SSID` +- `Пароль` + +Кнопки: + +- `Изменить SSID` +- `Изменить пароль` +- `Проверить` +- `Сбросить` +- `Назад` + +Поведение: + +- `Проверить` делает реальную попытку подключения к `Wi-Fi`; +- `Сбросить` очищает обе строки и ставит `wifiReady=false`. + +## Экран SERVERS + +Поля: + +- `API URL` +- `RPC URL` +- `WS URL` + +Кнопки: + +- `Изменить API` +- `Изменить RPC` +- `Изменить WS` +- `Проверить` +- `Тестовые` +- `Назад` + +Поведение: + +- `Тестовые` подставляет дефолтные тестовые значения; +- `Проверить` делает реальные сетевые проверки: + - `API URL` должен отвечать по `HTTP/HTTPS`; + - `RPC URL` должен отвечать на `Solana JSON-RPC`; + - `WS URL` должен принимать `TCP/TLS`-соединение. + +## Экран ACCOUNT + +Показывает: + +- логин; +- имя сабсервера; +- статус секрета; +- короткий отпечаток секрета; +- статус регистрации; +- короткий отпечаток `PDA` или `tx`. + +Кнопки: + +- `Изменить логин` +- `Секрет` +- `Имя сабсервера` +- `Сгенерировать` +- `Очистить` +- `Назад` + +Поведение: + +- `Сгенерировать` создаёт новый `master secret` и пересчитывает из него `device`-кошелёк; +- `Очистить` удаляет секрет, адрес кошелька, `PDA`, `tx`, регистрацию и онлайн-статус; +- логин приводится к нижнему регистру и trim. + +## Экран WALLET + +Показывает: + +- адрес кошелька устройства; +- баланс в `SOL`; +- минимально рекомендуемую сумму для регистрации; +- статус `Хватает / Не хватает`. + +Кнопки: + +- `QR и URI` +- `+0.10 SOL` +- `+0.25 SOL` +- `-0.10 SOL` +- `Проверить` +- `Назад` + +Поведение: + +- кнопки пополнения/уменьшения нужны для теста сценариев; +- `Проверить` читает реальный баланс из `Solana RPC`; +- адрес кошелька должен совпадать с `device key`, вычисленным из сохранённого `master secret`; +- отрицательный баланс не допускается. + +## Экран WALLET_QR + +Показывает: + +- QR-код для строки вида: + `solana:?amount=0.20&label=SHiNE%20Register`; +- адрес кошелька; +- сумму; +- текст URI. + +Кнопки: + +- `Назад` + +QR должен быть сканируемым, а не декоративным. + +## Экран REQUESTS + +Показывает список демонстрационных запросов: + +- `Вход в сессию` +- `Подпись сообщения` + +Для каждого запроса: + +- тип; +- источник; +- короткий статус. + +Кнопки: + +- `Открыть запрос 1` +- `Открыть запрос 2` +- `Назад` + +## Экран REQUEST_DETAIL + +Показывает детали выбранного запроса: + +- тип запроса; +- кто запросил; +- время; +- описание; +- отпечаток/идентификатор. + +Кнопки: + +- `Разрешить` +- `Отклонить` +- `Назад` + +Поведение: + +- после разрешения или отклонения запрос помечается обработанным; +- экран списка отражает новый статус. + +## Экран SETTINGS + +Показывает: + +- текущий PIN; +- базовые флаги безопасности; +- технические действия прототипа. + +Кнопки: + +- `Сменить PIN` +- `Сбросить онлайн` +- `Полный сброс` +- `Назад` + +`Полный сброс` очищает весь локальный конфиг и возвращает устройство к стартовому состоянию. + +## Экран PIN_EDIT + +Используется для ввода нового PIN. + +Правила: + +- допустимы только цифры; +- длина PIN: 4..8 символов; +- после сохранения новый PIN немедленно пишется в `NVS`. + +## Экран TEXT_INPUT + +Это общий экран редактирования текстовых полей. + +Используется для: + +- `SSID` +- `Пароль Wi-Fi` +- `Логин` +- `Имя сабсервера` +- `API URL` +- `RPC URL` +- `WS URL` + +Состав экрана: + +- заголовок поля; +- текущее значение; +- программная клавиатура; +- кнопки `Стереть`, `OK`, `Отмена`. + +## Экран CONFIRM + +Это модальный экран подтверждения. + +Используется для: + +- регистрации; +- очистки секрета; +- полного сброса. + +Кнопки: + +- `Подтвердить` +- `Отмена` + +## Сценарий первой настройки + +Ожидаемый путь пользователя: + +1. разблокировать устройство PIN-кодом; +2. открыть `Подключение -> Wi-Fi`; +3. ввести `SSID` и пароль, нажать `Проверить`; +4. открыть `Подключение -> Серверы`; +5. проверить или задать серверные адреса; +6. открыть `Аккаунт`; +7. ввести логин; +8. задать имя сабсервера; +9. сгенерировать секрет; +10. открыть `Кошелёк`; +11. при необходимости пополнить баланс; +12. вернуться на `HOME`; +13. нажать `Зарегистрировать`; +14. после подтверждения увидеть статус `Сабсервер активен`. + +Примечание: + +- устройство реально отправляет `create_user_pda` в `shine_users`, а после подтверждения сохраняет `PDA` и `tx signature`. + +## Сценарий входящего запроса + +1. открыть `Запросы`; +2. выбрать один из запросов; +3. прочитать детали; +4. нажать `Разрешить` или `Отклонить`; +5. убедиться, что статус запроса изменился. + +## Технические требования к кириллице + +Для корректного русского текста скетч обязан: + +- хранить строковые литералы в `UTF-8`; +- вызывать `gfx->setUTF8Print(true)`; +- использовать шрифт `U8g2` с поддержкой `Cyrillic`; +- не полагаться на стандартный `ASCII`-шрифт `Arduino_GFX` для русского текста. + +Если эти условия нарушены, UI считается сломанным даже при правильной логике экранов. + +## Критерии ручной проверки + +Минимально нужно проверить: + +1. устройство загружается и показывает экран блокировки; +2. русский текст отображается без кракозябр; +3. ввод по экранной клавиатуре работает; +4. после перезагрузки сохранённые поля остаются в памяти; +5. секрет и адрес кошелька сохраняются на устройстве; +6. экран `QR и URI` рисует читаемый QR-код; +7. регистрация блокируется, пока условия не выполнены; +8. после выполнения условий регистрация становится доступной; +9. список запросов и экран подтверждения работают; +10. полный сброс действительно очищает сохранённое состояние. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md index 8790f3d..df0fc01 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/README.md @@ -10,6 +10,7 @@ - `hello` — базовый тест экрана (пример `01_HelloWorld`) - `simple` — простой кастомный тест: экран + touch + запись/проигрывание + наклон (IMU) - `argon2` — генерация masterSecret через Argon2id с SD-картой как памятью (тест скорости) +- `subserver-ui` — основной UI-прототип сабсервера SHiNE: NVS, PIN, Wi-Fi, серверы, кошелёк, QR, запросы Запуск: @@ -17,3 +18,5 @@ - `./burn.sh audio` - `./burn.sh hello` - `./burn.sh simple` +- `./burn.sh subserver-ui` +- `./flash_shine_subserver_ui.sh` - автоматически находит USB-порт и заливает `shine_subserver_ui` diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh index 3b9cb55..f4ad16b 100755 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/burn.sh @@ -16,9 +16,10 @@ 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" ;; *) echo "Unknown mode: ${MODE}" >&2 - echo "Use one of: hello, widgets, audio, simple, argon2" >&2 + echo "Use one of: hello, widgets, audio, simple, argon2, subserver-ui" >&2 exit 2 ;; esac diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_subserver_ui.sh b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_subserver_ui.sh new file mode 100755 index 0000000..609b664 --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/flash_shine_subserver_ui.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +detect_port_from_arduino_cli() { + local line + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + [[ "${line}" == Port* ]] && continue + if [[ "${line}" == /dev/* ]]; then + awk '{print $1}' <<<"${line}" + return 0 + fi + done < <(arduino-cli board list 2>/dev/null || true) + return 1 +} + +detect_port_from_dev() { + local candidates=() + local path + for path in /dev/ttyACM* /dev/ttyUSB*; do + [[ -e "${path}" ]] || continue + candidates+=("${path}") + done + + if [[ "${#candidates[@]}" -eq 1 ]]; then + printf '%s\n' "${candidates[0]}" + return 0 + fi + + return 1 +} + +PORT="${PORT:-}" +if [[ -z "${PORT}" ]]; then + PORT="$(detect_port_from_arduino_cli || true)" +fi +if [[ -z "${PORT}" ]]; then + PORT="$(detect_port_from_dev || true)" +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 + exit 1 +fi + +echo "== Найден порт: ${PORT}" +PORT="${PORT}" "${ROOT_DIR}/burn.sh" subserver-ui diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/qrcode_bridge.c b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/qrcode_bridge.c new file mode 100644 index 0000000..da0e1f5 --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/qrcode_bridge.c @@ -0,0 +1 @@ +#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/qrcodegen.c" diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/shine_subserver_ui.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/shine_subserver_ui.ino new file mode 100644 index 0000000..a628271 --- /dev/null +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/shine_subserver_ui/shine_subserver_ui.ino @@ -0,0 +1,2455 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" { +#include "../../official-demo/examples/Arduino-v3.3.5/libraries/lvgl/src/extra/libs/qrcode/qrcodegen.h" +} + +#define PIN_LCD_CS 12 +#define PIN_LCD_SCLK 38 +#define PIN_LCD_D0 4 +#define PIN_LCD_D1 5 +#define PIN_LCD_D2 6 +#define PIN_LCD_D3 7 +#define PIN_LCD_RST 2 +#define PIN_I2C_SDA 15 +#define PIN_I2C_SCL 14 +#define PIN_TP_INT 11 + +#define DISP_W 480 +#define DISP_H 480 + +#define C_BG 0x0841u +#define C_PANEL 0x1082u +#define C_CARD 0x18C3u +#define C_BORDER 0x39C7u +#define C_TEXT 0xFFFFu +#define C_MUTE 0xBDF7u +#define C_ACCENT 0x5D5Fu +#define C_OK 0x3666u +#define C_WARN 0xECA0u +#define C_BAD 0xD145u +#define C_BUTTON 0x2145u +#define C_BUTTON2 0x3186u + +#define FONT_HEAD u8g2_font_10x20_t_cyrillic +#define FONT_BODY u8g2_font_9x15_t_cyrillic +#define FONT_SMALL u8g2_font_6x13_t_cyrillic + +Arduino_DataBus *gBus = new Arduino_ESP32QSPI( + PIN_LCD_CS, PIN_LCD_SCLK, PIN_LCD_D0, PIN_LCD_D1, PIN_LCD_D2, PIN_LCD_D3); +Arduino_CO5300 *gfx = new Arduino_CO5300( + gBus, PIN_LCD_RST, 0, DISP_W, DISP_H, 0, 0, 0, 0); + +TouchDrvCST92xx gTouch; +Preferences gPrefs; + +enum ScreenId { + SCR_LOCK, + SCR_HOME, + SCR_STATUS, + SCR_CONNECTION, + SCR_WIFI_EDIT, + SCR_SERVERS, + SCR_ACCOUNT, + SCR_WALLET, + SCR_WALLET_QR, + SCR_REQUESTS, + SCR_REQUEST_DETAIL, + SCR_SETTINGS, + SCR_PIN_EDIT, + SCR_TEXT_INPUT, + SCR_CONFIRM +}; + +enum ActionId { + ACT_NONE = 0, + ACT_HOME, + ACT_BACK, + ACT_STATUS, + ACT_CONNECTION, + ACT_ACCOUNT, + ACT_WALLET, + ACT_REQUESTS, + ACT_SETTINGS, + ACT_REFRESH, + ACT_WIFI, + ACT_SERVERS, + ACT_CONNECT, + ACT_DISCONNECT, + ACT_EDIT_SSID, + ACT_EDIT_WIFI_PASSWORD, + ACT_VERIFY_WIFI, + ACT_RESET_WIFI, + ACT_EDIT_API, + ACT_EDIT_RPC, + ACT_EDIT_WS, + ACT_VERIFY_SERVERS, + ACT_SET_TEST_SERVERS, + ACT_EDIT_LOGIN, + ACT_EDIT_SUBSERVER, + ACT_GENERATE_SECRET, + ACT_CLEAR_ACCOUNT, + ACT_SHOW_QR, + ACT_BALANCE_PLUS_010, + ACT_BALANCE_PLUS_025, + ACT_BALANCE_MINUS_010, + ACT_CHECK_BALANCE, + ACT_REGISTER, + ACT_OPEN_REQ_0, + ACT_OPEN_REQ_1, + ACT_APPROVE_REQUEST, + ACT_REJECT_REQUEST, + ACT_CHANGE_PIN, + ACT_RESET_ONLINE, + ACT_FULL_RESET, + ACT_CONFIRM_YES, + ACT_CONFIRM_NO, + ACT_DIGIT_0, + ACT_DIGIT_1, + ACT_DIGIT_2, + ACT_DIGIT_3, + ACT_DIGIT_4, + ACT_DIGIT_5, + ACT_DIGIT_6, + ACT_DIGIT_7, + ACT_DIGIT_8, + ACT_DIGIT_9, + ACT_INPUT_BACKSPACE, + ACT_INPUT_OK, + ACT_INPUT_CANCEL, + ACT_KB_SHIFT, + ACT_KB_SPACE +}; + +enum EditTarget { + EDIT_NONE = 0, + EDIT_SSID, + EDIT_WIFI_PASSWORD, + EDIT_LOGIN, + EDIT_SUBSERVER, + EDIT_API, + EDIT_RPC, + EDIT_WS, + EDIT_NEW_PIN, + EDIT_UNLOCK_PIN +}; + +enum ConfirmTarget { + CONFIRM_NONE = 0, + CONFIRM_REGISTER, + CONFIRM_CLEAR_ACCOUNT, + CONFIRM_FULL_RESET +}; + +struct Button { + int16_t x; + int16_t y; + int16_t w; + int16_t h; + ActionId action; + bool enabled; + char label[40]; +}; + +struct RequestItem { + String type; + String actor; + String details; + String status; +}; + +struct AppData { + String pin; + String wifiSsid; + String wifiPassword; + String login; + String subserverName; + String secret; + String walletAddress; + String userPdaAddress; + String registrationSignature; + String apiUrl; + String rpcUrl; + String wsUrl; + uint16_t balanceCentiSol; + bool wifiReady; + bool serversReady; + bool secretReady; + bool registered; + bool online; +}; + +struct RuntimeNetState { + String wifiStatus; + String localIp; + String apiStatus; + String rpcStatus; + String wsStatus; + String serverVersion; + int32_t rssi; +}; + +static ScreenId gScreen = SCR_LOCK; +static ScreenId gPrevScreen = SCR_HOME; +static EditTarget gEditTarget = EDIT_NONE; +static ConfirmTarget gConfirmTarget = CONFIRM_NONE; +static AppData gData; +static Button gButtons[32]; +static uint8_t gButtonCount = 0; +static RequestItem gRequests[2]; +static int gCurrentRequest = 0; +static String gNotice; +static String gEditBuffer; +static String gLockBuffer; +static RuntimeNetState gNetState; +static bool gKeyboardAlt = false; +static bool gNeedRedraw = true; +static bool gTouchDown = false; +static int16_t gTouchStartX = 0; +static int16_t gTouchStartY = 0; +static int16_t gTouchLastX = 0; +static int16_t gTouchLastY = 0; + +struct DerivedKeyState { + bool ready; + uint8_t masterSecret[32]; + uint8_t rootPub[32]; + uint8_t rootSk[64]; + uint8_t blockchainPub[32]; + uint8_t blockchainSk[64]; + uint8_t devicePub[32]; + uint8_t deviceSk[64]; +}; + +static DerivedKeyState gDerivedKeys = {}; + +static const char *kSystemProgramId = "11111111111111111111111111111111"; +static const char *kEd25519ProgramId = "Ed25519SigVerify111111111111111111111111111"; +static const char *kSysvarInstructionsId = "Sysvar1nstructions1111111111111111111111111"; +static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; +static const char *kShineLoginGuardProgramId = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; +static const char *kShinePaymentsProgramId = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymFbom2EudW"; +static const char *kUsersSeedPrefix = "user_login="; +static const char *kUsersEconomyConfigSeed = "shine_users_economy_config"; +static const char *kPaymentsInflowSeed = "shine_payments_inflow_vault"; +static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; +static const char *kLastBlockPrefix = "SHiNE_LAST_BLOCK"; +static const uint8_t kBlockTypeRootKey = 1; +static const uint8_t kBlockTypeDeviceKey = 2; +static const uint8_t kBlockTypeBlockchainRegistry = 3; +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 char *KB_ALPHA[4] = { + "qwertyuiop", + "asdfghjkl", + "zxcvbnm", + "" +}; + +static const char *KB_ALT[4] = { + "1234567890", + "-_.:@/+?&=", + "[]()%#,;!*", + "" +}; + +static void clearButtons() { + gButtonCount = 0; +} + +static int utf8Len(const String &s) { + int count = 0; + for (size_t i = 0; i < s.length(); i++) { + uint8_t c = (uint8_t)s[i]; + if ((c & 0xC0) != 0x80) { + count++; + } + } + return count; +} + +static String trimCopy(const String &value) { + String copy = value; + copy.trim(); + return copy; +} + +static bool startsWithAny(const String &value, const char *a, const char *b) { + return value.startsWith(a) || value.startsWith(b); +} + +static String normalizeLogin(const String &value) { + String out = trimCopy(value); + out.toLowerCase(); + return out; +} + +static void setFont(const uint8_t *font) { + gfx->setTextSize(1); + gfx->setFont(font); +} + +static void drawText(int x, int y, const String &text, uint16_t color, const uint8_t *font = (const uint8_t *)FONT_BODY) { + setFont(font); + gfx->setTextColor(color); + gfx->setCursor(x, y); + gfx->print(text); +} + +static void drawTextCentered(int x, int y, int w, int h, const String &text, uint16_t color, const uint8_t *font = (const uint8_t *)FONT_BODY) { + setFont(font); + gfx->setTextColor(color); + int16_t x1 = 0; + int16_t y1 = 0; + uint16_t textW = 0; + uint16_t textH = 0; + gfx->getTextBounds(text, 0, 0, &x1, &y1, &textW, &textH); + int textX = x + (w - (int)textW) / 2 - x1; + int textY = y + (h - (int)textH) / 2 - y1; + gfx->setCursor(textX, textY); + gfx->print(text); +} + +static void drawWrappedText(int x, int y, int maxChars, int lineHeight, const String &text, uint16_t color, const uint8_t *font = (const uint8_t *)FONT_BODY) { + setFont(font); + gfx->setTextColor(color); + String line; + int lineY = y; + for (size_t i = 0; i < text.length(); i++) { + char c = text[i]; + if (c == '\n') { + gfx->setCursor(x, lineY); + gfx->print(line); + line = ""; + lineY += lineHeight; + continue; + } + line += c; + if (utf8Len(line) >= maxChars && c == ' ') { + gfx->setCursor(x, lineY); + gfx->print(line); + line = ""; + lineY += lineHeight; + } + } + if (line.length() > 0) { + gfx->setCursor(x, lineY); + gfx->print(line); + } +} + +static void drawPanel(int x, int y, int w, int h, uint16_t fill = C_PANEL, uint16_t border = C_BORDER, int radius = 10) { + gfx->fillRoundRect(x, y, w, h, radius, fill); + gfx->drawRoundRect(x, y, w, h, radius, border); +} + +static void addButton(int x, int y, int w, int h, ActionId action, const String &label, bool enabled = true, uint16_t fill = C_BUTTON) { + if (gButtonCount >= 32) { + return; + } + Button &btn = gButtons[gButtonCount++]; + btn.x = x; + btn.y = y; + btn.w = w; + btn.h = h; + btn.action = action; + btn.enabled = enabled; + memset(btn.label, 0, sizeof(btn.label)); + label.substring(0, sizeof(btn.label) - 1).toCharArray(btn.label, sizeof(btn.label)); + uint16_t fillColor = enabled ? fill : C_CARD; + drawPanel(x, y, w, h, fillColor, enabled ? C_BORDER : C_BORDER, 12); + drawTextCentered(x, y, w, h, label, enabled ? C_TEXT : C_MUTE, (const uint8_t *)FONT_BODY); +} + +static void drawStatusChip(int x, int y, int w, const String &label, bool ok) { + uint16_t color = ok ? C_OK : C_BAD; + drawPanel(x, y, w, 28, color, color, 8); + drawText(x + 8, y + 19, label, C_TEXT, (const uint8_t *)FONT_SMALL); +} + +static String shortenValue(const String &value, int keepStart = 6, int keepEnd = 4) { + if ((int)value.length() <= keepStart + keepEnd + 3) { + return value; + } + return value.substring(0, keepStart) + "..." + value.substring(value.length() - keepEnd); +} + +static String repeatChar(char c, int count) { + String out; + for (int i = 0; i < count; i++) { + out += c; + } + return out; +} + +static bool canRegister() { + return gData.wifiReady && + gData.serversReady && + isValidLoginValue(gData.login) && + gData.secretReady && + gData.balanceCentiSol >= 20 && + !gData.registered; +} + +static String registrationSummary() { + if (gData.registered) { + return "Сабсервер активен"; + } + if (!gData.wifiReady) { + return "Нужен Wi-Fi"; + } + if (!gData.serversReady) { + return "Нужны серверы"; + } + if (gData.login.length() == 0) { + return "Нужен логин"; + } + if (!isValidLoginValue(gData.login)) { + return "Логин невалиден"; + } + if (!gData.secretReady) { + return "Нужен секрет"; + } + if (gData.balanceCentiSol < 20) { + return "Нужно >= 0.20 SOL"; + } + return "Можно регистрировать"; +} + +static String registrationDetailsShort() { + if (gData.userPdaAddress.length() > 0) { + return shortenValue(gData.userPdaAddress, 8, 6); + } + if (gData.registrationSignature.length() > 0) { + return shortenValue(gData.registrationSignature, 8, 6); + } + return "ещё не отправлялась"; +} + +static String boolText(bool value, const String &yesText = "Да", const String &noText = "Нет") { + return value ? yesText : noText; +} + +static String balanceText() { + char buf[32]; + snprintf(buf, sizeof(buf), "%u.%02u SOL", gData.balanceCentiSol / 100, gData.balanceCentiSol % 100); + return String(buf); +} + +static void drawTopBar(const String &title) { + gfx->fillScreen(C_BG); + drawText(20, 26, title, C_TEXT, (const uint8_t *)FONT_HEAD); + drawStatusChip(20, 38, 82, gData.wifiReady ? "Wi-Fi OK" : "Wi-Fi нет", gData.wifiReady); + drawStatusChip(110, 38, 82, gData.serversReady ? "RPC OK" : "RPC нет", gData.serversReady); + drawStatusChip(200, 38, 92, gData.registered ? "Рег OK" : "Не рег", gData.registered); + drawStatusChip(300, 38, 92, gData.online ? "Онлайн" : "Офлайн", gData.online); + drawStatusChip(400, 38, 60, "PIN", true); + gfx->drawFastHLine(20, 74, 440, C_BORDER); +} + +static void drawNoticeBox() { + if (gNotice.length() == 0) { + return; + } + drawPanel(20, 420, 440, 42, C_CARD, C_BORDER, 10); + drawWrappedText(32, 444, 52, 16, gNotice, C_MUTE, (const uint8_t *)FONT_SMALL); +} + +static const char *base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +static String bytesToBase58(const uint8_t *data, size_t len) { + uint8_t tmp[80] = {}; + int tmpLen = 0; + for (size_t i = 0; i < len; i++) { + int carry = data[i]; + for (int j = 0; j < tmpLen; j++) { + carry += tmp[j] << 8; + tmp[j] = carry % 58; + carry /= 58; + } + while (carry > 0) { + tmp[tmpLen++] = carry % 58; + carry /= 58; + } + } + int zeroes = 0; + while (zeroes < (int)len && data[zeroes] == 0) { + zeroes++; + } + String out; + for (int i = 0; i < zeroes; i++) { + out += '1'; + } + for (int i = tmpLen - 1; i >= 0; i--) { + out += base58Alphabet[tmp[i]]; + } + return out; +} + +static bool base58ToBytes(const String &value, std::vector &out) { + out.clear(); + String clean = trimCopy(value); + if (clean.length() == 0) { + return true; + } + std::vector digits; + digits.reserve(clean.length()); + for (size_t i = 0; i < clean.length(); i++) { + char c = clean[i]; + const char *ptr = strchr(base58Alphabet, c); + if (!ptr) { + return false; + } + int carry = (int)(ptr - base58Alphabet); + for (size_t j = 0; j < digits.size(); j++) { + int valueAcc = digits[j] * 58 + carry; + digits[j] = (uint8_t)(valueAcc & 0xFF); + carry = valueAcc >> 8; + } + while (carry > 0) { + digits.push_back((uint8_t)(carry & 0xFF)); + carry >>= 8; + } + } + int zeroes = 0; + while (zeroes < clean.length() && clean[zeroes] == '1') { + zeroes++; + } + out.assign(zeroes, 0); + for (int i = (int)digits.size() - 1; i >= 0; i--) { + out.push_back(digits[(size_t)i]); + } + return true; +} + +static String randomBase58(size_t byteCount) { + uint8_t data[64]; + if (byteCount > sizeof(data)) { + byteCount = sizeof(data); + } + for (size_t i = 0; i < byteCount; i++) { + data[i] = (uint8_t)esp_random(); + } + return bytesToBase58(data, byteCount); +} + +static String base64Encode(const uint8_t *data, size_t len) { + size_t outLen = 0; + size_t outSize = ((len + 2) / 3) * 4 + 4; + std::vector out(outSize); + if (mbedtls_base64_encode(out.data(), out.size(), &outLen, data, len) != 0) { + return ""; + } + return String((const char *)out.data()).substring(0, outLen); +} + +static bool sha256Raw(const uint8_t *data, size_t len, uint8_t out32[32]) { + return crypto_hash_sha256(out32, data, (unsigned long long)len) == 0; +} + +static bool sha256String(const String &text, uint8_t out32[32]) { + return sha256Raw((const uint8_t *)text.c_str(), text.length(), out32); +} + +static bool isValidLoginValue(const String &login) { + if (login.length() == 0 || login.length() > 20) { + return false; + } + for (size_t i = 0; i < login.length(); i++) { + char c = login[i]; + bool ok = (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '_'; + if (!ok) { + return false; + } + } + return true; +} + +static void shortVecEncode(size_t value, std::vector &out) { + while (true) { + uint8_t elem = (uint8_t)(value & 0x7F); + value >>= 7; + if (value == 0) { + out.push_back(elem); + return; + } + out.push_back(elem | 0x80); + } +} + +static void pushU32LE(std::vector &out, uint32_t value) { + out.push_back((uint8_t)(value & 0xFF)); + out.push_back((uint8_t)((value >> 8) & 0xFF)); + out.push_back((uint8_t)((value >> 16) & 0xFF)); + out.push_back((uint8_t)((value >> 24) & 0xFF)); +} + +static void pushU64LE(std::vector &out, uint64_t value) { + for (int i = 0; i < 8; i++) { + out.push_back((uint8_t)((value >> (8 * i)) & 0xFF)); + } +} + +static void pushStrU8(std::vector &out, const String &value) { + out.push_back((uint8_t)value.length()); + for (size_t i = 0; i < value.length(); i++) { + out.push_back((uint8_t)value[i]); + } +} + +static void pushFixed(std::vector &out, const uint8_t *data, size_t len) { + out.insert(out.end(), data, data + len); +} + +static bool deriveKeysFromMasterSecret(const uint8_t masterSecret[32]) { + memset(&gDerivedKeys, 0, sizeof(gDerivedKeys)); + memcpy(gDerivedKeys.masterSecret, masterSecret, 32); + String secretB64 = base64Encode(masterSecret, 32); + if (secretB64.length() == 0) { + return false; + } + const char *suffixes[3] = {"root.key", "bch.key", "dev.key"}; + uint8_t *pubs[3] = {gDerivedKeys.rootPub, gDerivedKeys.blockchainPub, gDerivedKeys.devicePub}; + uint8_t *sks[3] = {gDerivedKeys.rootSk, gDerivedKeys.blockchainSk, gDerivedKeys.deviceSk}; + for (int i = 0; i < 3; i++) { + String material = secretB64 + "|" + suffixes[i]; + uint8_t seed[32]; + if (!sha256String(material, seed)) { + return false; + } + if (crypto_sign_seed_keypair(pubs[i], sks[i], seed) != 0) { + return false; + } + } + gDerivedKeys.ready = true; + return true; +} + +static bool restoreDerivedKeysFromSecret() { + gDerivedKeys.ready = false; + if (gData.secret.length() == 0) { + gData.secretReady = false; + gData.walletAddress = ""; + return true; + } + std::vector secretBytes; + if (!base58ToBytes(gData.secret, secretBytes) || secretBytes.size() != 32) { + gData.secretReady = false; + gData.walletAddress = ""; + return false; + } + if (!deriveKeysFromMasterSecret(secretBytes.data())) { + gData.secretReady = false; + gData.walletAddress = ""; + return false; + } + gData.secretReady = true; + gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); + return true; +} + +static bool deriveFreshSecretAndWallet() { + uint8_t secret[32]; + for (size_t i = 0; i < sizeof(secret); i++) { + secret[i] = (uint8_t)esp_random(); + } + if (!deriveKeysFromMasterSecret(secret)) { + return false; + } + gData.secret = bytesToBase58(secret, sizeof(secret)); + gData.walletAddress = bytesToBase58(gDerivedKeys.devicePub, 32); + gData.secretReady = true; + return true; +} + +static bool base58ToFixed32(const char *value, uint8_t out[32]) { + std::vector raw; + if (!base58ToBytes(String(value), raw) || raw.size() != 32) { + return false; + } + memcpy(out, raw.data(), 32); + return true; +} + +static bool findProgramAddress(const std::vector> &seeds, const char *programIdB58, uint8_t out[32]) { + uint8_t programId[32]; + if (!base58ToFixed32(programIdB58, programId)) { + return false; + } + for (int bump = 255; bump >= 0; bump--) { + crypto_hash_sha256_state st; + crypto_hash_sha256_init(&st); + for (const auto &seed : seeds) { + crypto_hash_sha256_update(&st, seed.data(), (unsigned long long)seed.size()); + } + uint8_t bumpByte = (uint8_t)bump; + crypto_hash_sha256_update(&st, &bumpByte, 1); + crypto_hash_sha256_update(&st, programId, 32); + crypto_hash_sha256_update(&st, (const unsigned char *)kProgramDerivedAddressMarker, strlen(kProgramDerivedAddressMarker)); + crypto_hash_sha256_final(&st, out); + if (crypto_core_ed25519_is_valid_point(out) == 0) { + return true; + } + } + return false; +} + +static std::vector buildLastBlockStateBytes(const String &login, const String &blockchainName) { + std::vector out; + out.reserve(80); + pushFixed(out, (const uint8_t *)kLastBlockPrefix, strlen(kLastBlockPrefix)); + pushStrU8(out, login); + pushStrU8(out, blockchainName); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushU64LE(out, 0); + return out; +} + +static std::vector buildUnsignedCreateRecord( + const String &login, + const String &blockchainName, + const String &serverAddress, + const uint8_t rootPub[32], + const uint8_t devicePub[32], + const uint8_t blockchainPub[32], + const uint8_t lastBlockSignature[64], + uint64_t createdAtMs) { + std::vector out; + out.reserve(512); + pushFixed(out, (const uint8_t *)"SHiNE", 5); + out.push_back(1); + out.push_back(0); + out.push_back(0); + out.push_back(0); + pushU64LE(out, createdAtMs); + pushU64LE(out, createdAtMs); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushStrU8(out, login); + out.push_back(7); + + out.push_back(kBlockTypeRootKey); + out.push_back(0); + pushFixed(out, rootPub, 32); + + out.push_back(kBlockTypeDeviceKey); + out.push_back(0); + pushFixed(out, devicePub, 32); + + out.push_back(kBlockTypeBlockchainRegistry); + out.push_back(0); + out.push_back(1); + out.push_back(1); + pushStrU8(out, blockchainName); + pushFixed(out, blockchainPub, 32); + pushU64LE(out, 100000); + pushU64LE(out, 0); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushFixed(out, lastBlockSignature, 64); + out.push_back(0); + + out.push_back(kBlockTypeServerProfile); + out.push_back(0); + out.push_back(1); + out.push_back(1); + out.push_back(0); + pushStrU8(out, serverAddress); + out.push_back(0); + + out.push_back(kBlockTypeAccessServers); + out.push_back(0); + out.push_back(0); + + out.push_back(kBlockTypeSessions); + out.push_back(0); + out.push_back(1); + out.push_back(0); + + out.push_back(kBlockTypeTrustedState); + out.push_back(0); + out.push_back(0); + + uint16_t recordLen = (uint16_t)(out.size() + 64); + out[7] = (uint8_t)(recordLen & 0xFF); + out[8] = (uint8_t)((recordLen >> 8) & 0xFF); + return out; +} + +static std::vector buildCreateInstructionData( + const String &login, + const String &blockchainName, + const String &serverAddress, + const uint8_t rootPub[32], + const uint8_t devicePub[32], + const uint8_t blockchainPub[32], + const uint8_t lastBlockSignature[64], + const uint8_t rootSignature[64], + uint64_t createdAtMs) { + std::vector out; + out.reserve(512); + out.push_back(3); + pushStrU8(out, login); + pushFixed(out, rootPub, 32); + pushU64LE(out, createdAtMs); + pushU64LE(out, 0); + pushFixed(out, devicePub, 32); + pushFixed(out, blockchainPub, 32); + pushStrU8(out, blockchainName); + pushU64LE(out, 0); + pushU32LE(out, 0); + out.insert(out.end(), 32, 0); + pushFixed(out, lastBlockSignature, 64); + out.push_back(0); + out.push_back(1); + out.push_back(0); + pushStrU8(out, serverAddress); + out.push_back(0); + out.push_back(0); + out.push_back(1); + out.push_back(0); + out.push_back(0); + pushFixed(out, rootSignature, 64); + return out; +} + +static std::vector buildEd25519InstructionData(const uint8_t signature[64], const uint8_t publicKey[32], const uint8_t messageHash[32]) { + const uint16_t sigOff = 16; + const uint16_t pkOff = sigOff + 64; + const uint16_t msgOff = pkOff + 32; + std::vector out(msgOff + 32, 0); + out[0] = 1; + out[1] = 0; + out[2] = (uint8_t)(sigOff & 0xFF); + out[3] = (uint8_t)((sigOff >> 8) & 0xFF); + out[6] = (uint8_t)(pkOff & 0xFF); + out[7] = (uint8_t)((pkOff >> 8) & 0xFF); + out[10] = (uint8_t)(msgOff & 0xFF); + out[11] = (uint8_t)((msgOff >> 8) & 0xFF); + out[12] = 32; + out[4] = out[8] = out[14] = 0xFF; + out[5] = out[9] = out[15] = 0xFF; + memcpy(out.data() + sigOff, signature, 64); + memcpy(out.data() + pkOff, publicKey, 32); + memcpy(out.data() + msgOff, messageHash, 32); + return out; +} + +static String buildBaseRpcRequest(const char *method, const String ¶msJson) { + return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + String(method) + "\",\"params\":" + paramsJson + "}"; +} + +static bool rpcCall(const char *method, const String ¶msJson, String &payloadOut) { + int code = -1; + if (!httpPostJson(gData.rpcUrl, buildBaseRpcRequest(method, paramsJson), code, payloadOut)) { + return false; + } + return code >= 200 && code < 300; +} + +static bool rpcResponseHasError(const String &payload) { + return payload.indexOf("\"error\"") >= 0; +} + +static bool getLatestBlockhashBytes(uint8_t out[32], String &blockhashB58, String &messageOut) { + String payload; + if (!rpcCall("getLatestBlockhash", "[{\"commitment\":\"confirmed\"}]", payload)) { + messageOut = "RPC не вернул blockhash"; + return false; + } + blockhashB58 = jsonStringField(payload, "blockhash"); + if (blockhashB58.length() == 0) { + messageOut = "В ответе нет blockhash"; + return false; + } + std::vector raw; + if (!base58ToBytes(blockhashB58, raw) || raw.size() != 32) { + messageOut = "Некорректный blockhash"; + return false; + } + memcpy(out, raw.data(), 32); + return true; +} + +static bool pdaAlreadyExists(const String &login, String &pdaAddress, String &messageOut) { + uint8_t userPda[32]; + std::vector> seeds = { + std::vector((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)), + std::vector((const uint8_t *)login.c_str(), (const uint8_t *)login.c_str() + login.length()) + }; + if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) { + messageOut = "Не удалось вычислить user PDA"; + return false; + } + pdaAddress = bytesToBase58(userPda, 32); + String payload; + if (!rpcCall("getAccountInfo", "[\"" + pdaAddress + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]", payload)) { + messageOut = "Не удалось проверить PDA"; + return false; + } + if (payload.indexOf("\"value\":null") >= 0) { + return false; + } + if (payload.indexOf("\"value\"") >= 0) { + messageOut = "Такой логин уже зарегистрирован"; + return true; + } + messageOut = "Непонятный ответ getAccountInfo"; + return false; +} + +static std::vector buildLegacyMessage( + const uint8_t recentBlockhash[32], + const uint8_t devicePub[32], + const uint8_t userPda[32], + const uint8_t inflowVault[32], + const uint8_t economyConfig[32], + const std::vector &edRootData, + const std::vector &edBchData, + const std::vector &createData) { + uint8_t systemProgram[32]; + uint8_t ed25519Program[32]; + uint8_t sysvarInstructions[32]; + uint8_t usersProgram[32]; + uint8_t loginGuardProgram[32]; + base58ToFixed32(kSystemProgramId, systemProgram); + base58ToFixed32(kEd25519ProgramId, ed25519Program); + base58ToFixed32(kSysvarInstructionsId, sysvarInstructions); + base58ToFixed32(kShineUsersProgramId, usersProgram); + base58ToFixed32(kShineLoginGuardProgramId, loginGuardProgram); + + std::vector> accountKeys; + accountKeys.emplace_back(devicePub, devicePub + 32); + accountKeys.emplace_back(userPda, userPda + 32); + accountKeys.emplace_back(inflowVault, inflowVault + 32); + accountKeys.emplace_back(systemProgram, systemProgram + 32); + accountKeys.emplace_back(sysvarInstructions, sysvarInstructions + 32); + accountKeys.emplace_back(economyConfig, economyConfig + 32); + accountKeys.emplace_back(loginGuardProgram, loginGuardProgram + 32); + accountKeys.emplace_back(ed25519Program, ed25519Program + 32); + accountKeys.emplace_back(usersProgram, usersProgram + 32); + + std::vector msg; + msg.reserve(512); + msg.push_back(1); + msg.push_back(0); + msg.push_back(6); + shortVecEncode(accountKeys.size(), msg); + for (const auto &key : accountKeys) { + msg.insert(msg.end(), key.begin(), key.end()); + } + msg.insert(msg.end(), recentBlockhash, recentBlockhash + 32); + shortVecEncode(3, msg); + + msg.push_back(7); + msg.push_back(0); + shortVecEncode(edRootData.size(), msg); + msg.insert(msg.end(), edRootData.begin(), edRootData.end()); + + msg.push_back(7); + msg.push_back(0); + shortVecEncode(edBchData.size(), msg); + msg.insert(msg.end(), edBchData.begin(), edBchData.end()); + + msg.push_back(8); + msg.push_back(7); + msg.push_back(0); + msg.push_back(1); + msg.push_back(3); + msg.push_back(2); + msg.push_back(4); + msg.push_back(5); + msg.push_back(6); + shortVecEncode(createData.size(), msg); + msg.insert(msg.end(), createData.begin(), createData.end()); + return msg; +} + +static bool signMessageEd25519(const std::vector &message, const uint8_t secretKey[64], uint8_t signature[64]) { + return crypto_sign_ed25519_detached(signature, nullptr, message.data(), (unsigned long long)message.size(), secretKey) == 0; +} + +static String encodeTransactionBase64(const uint8_t signature[64], const std::vector &message) { + std::vector tx; + tx.reserve(1 + 64 + message.size()); + shortVecEncode(1, tx); + pushFixed(tx, signature, 64); + tx.insert(tx.end(), message.begin(), message.end()); + return base64Encode(tx.data(), tx.size()); +} + +static bool awaitTransactionConfirmation(const String &signatureB58, String &messageOut) { + for (int attempt = 0; attempt < 15; attempt++) { + String payload; + if (!rpcCall("getSignatureStatuses", "[[\"" + signatureB58 + "\"],{\"searchTransactionHistory\":true}]", payload)) { + delay(1000); + continue; + } + if (payload.indexOf("\"err\":null") >= 0 && + (payload.indexOf("\"confirmationStatus\":\"confirmed\"") >= 0 || + payload.indexOf("\"confirmationStatus\":\"finalized\"") >= 0)) { + return true; + } + if (payload.indexOf("\"err\":{") >= 0 || payload.indexOf("\"err\":\"") >= 0) { + messageOut = "Транзакция отклонена сетью"; + return false; + } + delay(1000); + } + messageOut = "RPC не подтвердил транзакцию вовремя"; + return false; +} + +static bool registerSubserverOnSolana(String &messageOut) { + messageOut = ""; + if (!gDerivedKeys.ready) { + if (!restoreDerivedKeysFromSecret()) { + messageOut = "Секрет повреждён, пересоздайте аккаунт"; + return false; + } + } + if (!isValidLoginValue(gData.login)) { + messageOut = "Логин должен быть a-z, 0-9, _ и длиной 1..20"; + return false; + } + if (WiFi.status() != WL_CONNECTED) { + messageOut = "Сначала подключите Wi-Fi"; + return false; + } + if (gData.wsUrl.length() == 0) { + messageOut = "Сначала задайте WS URL"; + return false; + } + String existingPda; + String pdaCheckMessage; + if (pdaAlreadyExists(gData.login, existingPda, pdaCheckMessage)) { + gData.userPdaAddress = existingPda; + gData.registered = true; + gData.online = true; + saveData(); + messageOut = "PDA уже существует для этого логина"; + return true; + } + if (pdaCheckMessage == "Не удалось вычислить user PDA" || pdaCheckMessage == "Не удалось проверить PDA" || pdaCheckMessage == "Непонятный ответ getAccountInfo") { + messageOut = pdaCheckMessage; + return false; + } + + uint8_t userPda[32]; + uint8_t economyConfig[32]; + uint8_t inflowVault[32]; + if (!findProgramAddress({ + std::vector((const uint8_t *)kUsersSeedPrefix, (const uint8_t *)kUsersSeedPrefix + strlen(kUsersSeedPrefix)), + std::vector((const uint8_t *)gData.login.c_str(), (const uint8_t *)gData.login.c_str() + gData.login.length()) + }, kShineUsersProgramId, userPda) || + !findProgramAddress({ + std::vector((const uint8_t *)kUsersEconomyConfigSeed, (const uint8_t *)kUsersEconomyConfigSeed + strlen(kUsersEconomyConfigSeed)) + }, kShineUsersProgramId, economyConfig) || + !findProgramAddress({ + std::vector((const uint8_t *)kPaymentsInflowSeed, (const uint8_t *)kPaymentsInflowSeed + strlen(kPaymentsInflowSeed)) + }, kShinePaymentsProgramId, inflowVault)) { + messageOut = "Не удалось вычислить обязательные PDA"; + return false; + } + + String blockchainName = gData.login + "-001"; + std::vector lastBlockState = buildLastBlockStateBytes(gData.login, blockchainName); + uint8_t lastBlockHash[32]; + uint8_t lastBlockSignature[64]; + if (!sha256Raw(lastBlockState.data(), lastBlockState.size(), lastBlockHash) || + crypto_sign_ed25519_detached(lastBlockSignature, nullptr, lastBlockHash, 32, gDerivedKeys.blockchainSk) != 0) { + messageOut = "Не удалось подписать LastBlockState"; + return false; + } + + uint64_t createdAtMs = (uint64_t)millis() + 1704067200000ULL; + std::vector unsignedRecord = buildUnsignedCreateRecord( + gData.login, blockchainName, gData.wsUrl, + gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, + lastBlockSignature, createdAtMs); + uint8_t unsignedHash[32]; + uint8_t rootSignature[64]; + if (!sha256Raw(unsignedRecord.data(), unsignedRecord.size(), unsignedHash) || + crypto_sign_ed25519_detached(rootSignature, nullptr, unsignedHash, 32, gDerivedKeys.rootSk) != 0) { + messageOut = "Не удалось подписать PDA-запись"; + return false; + } + + std::vector createData = buildCreateInstructionData( + gData.login, blockchainName, gData.wsUrl, + gDerivedKeys.rootPub, gDerivedKeys.devicePub, gDerivedKeys.blockchainPub, + lastBlockSignature, rootSignature, createdAtMs); + std::vector edRootData = buildEd25519InstructionData(rootSignature, gDerivedKeys.rootPub, unsignedHash); + std::vector edBchData = buildEd25519InstructionData(lastBlockSignature, gDerivedKeys.blockchainPub, lastBlockHash); + + uint8_t recentBlockhash[32]; + String recentBlockhash58; + if (!getLatestBlockhashBytes(recentBlockhash, recentBlockhash58, messageOut)) { + return false; + } + + std::vector message = buildLegacyMessage( + recentBlockhash, + gDerivedKeys.devicePub, + userPda, + inflowVault, + economyConfig, + edRootData, + edBchData, + createData); + uint8_t txSignature[64]; + if (!signMessageEd25519(message, gDerivedKeys.deviceSk, txSignature)) { + messageOut = "Не удалось подписать Solana-транзакцию"; + return false; + } + String txBase64 = encodeTransactionBase64(txSignature, message); + String signatureB58 = bytesToBase58(txSignature, 64); + + String payload; + if (!rpcCall("sendTransaction", "[\"" + txBase64 + "\",{\"encoding\":\"base64\",\"preflightCommitment\":\"confirmed\"}]", payload)) { + messageOut = "RPC не принял транзакцию"; + return false; + } + if (rpcResponseHasError(payload)) { + messageOut = "RPC вернул ошибку sendTransaction"; + return false; + } + if (!awaitTransactionConfirmation(signatureB58, messageOut)) { + return false; + } + + gData.userPdaAddress = bytesToBase58(userPda, 32); + gData.registrationSignature = signatureB58; + gData.registered = true; + gData.online = true; + saveData(); + messageOut = "Solana-регистрация подтверждена"; + return true; +} + +static String defaultApiUrl() { + return "https://shineup.me/api"; +} + +static String defaultRpcUrl() { + return "https://api.devnet.solana.com"; +} + +static String defaultWsUrl() { + return "wss://shineup.me/ws"; +} + +static void resetRuntimeNetState() { + gNetState.wifiStatus = "не проверялся"; + gNetState.localIp = "-"; + gNetState.apiStatus = "не проверялся"; + gNetState.rpcStatus = "не проверялся"; + gNetState.wsStatus = "не проверялся"; + gNetState.serverVersion = "-"; + gNetState.rssi = 0; +} + +static void syncWifiRuntimeState() { + wl_status_t status = WiFi.status(); + if (status == WL_CONNECTED) { + gNetState.wifiStatus = "подключён"; + gNetState.localIp = WiFi.localIP().toString(); + gNetState.rssi = WiFi.RSSI(); + } else { + gData.wifiReady = false; + gData.online = false; + gNetState.wifiStatus = "отключён"; + gNetState.localIp = "-"; + gNetState.rssi = 0; + } +} + +static bool isHttpUrl(const String &url) { + return startsWithAny(url, "http://", "https://"); +} + +static bool isWsUrl(const String &url) { + return startsWithAny(url, "ws://", "wss://"); +} + +static bool parseUrlHostPort(const String &url, String &hostOut, uint16_t &portOut, bool &secureOut) { + secureOut = false; + portOut = 0; + int schemeEnd = url.indexOf("://"); + if (schemeEnd <= 0) { + return false; + } + String scheme = url.substring(0, schemeEnd); + secureOut = (scheme == "https" || scheme == "wss"); + int hostStart = schemeEnd + 3; + int slash = url.indexOf('/', hostStart); + String hostPort = slash >= 0 ? url.substring(hostStart, slash) : url.substring(hostStart); + int atPos = hostPort.lastIndexOf('@'); + if (atPos >= 0) { + hostPort = hostPort.substring(atPos + 1); + } + int colon = hostPort.lastIndexOf(':'); + if (colon > 0 && hostPort.indexOf(']') < 0) { + hostOut = hostPort.substring(0, colon); + portOut = (uint16_t)hostPort.substring(colon + 1).toInt(); + } else { + hostOut = hostPort; + portOut = secureOut ? 443 : 80; + } + hostOut.trim(); + return hostOut.length() > 0 && portOut > 0; +} + +static bool httpGetReachable(const String &url, int &statusCode, String &payload) { + statusCode = -1; + payload = ""; + if (!isHttpUrl(url)) { + return false; + } + HTTPClient http; + WiFiClientSecure secureClient; + WiFiClient plainClient; + bool secure = url.startsWith("https://"); + if (secure) { + secureClient.setInsecure(); + if (!http.begin(secureClient, url)) { + return false; + } + } else { + if (!http.begin(plainClient, url)) { + return false; + } + } + http.setTimeout(5000); + statusCode = http.GET(); + if (statusCode > 0) { + payload = http.getString(); + } + http.end(); + return statusCode > 0; +} + +static bool httpPostJson(const String &url, const String &body, int &statusCode, String &payload) { + statusCode = -1; + payload = ""; + if (!isHttpUrl(url)) { + return false; + } + HTTPClient http; + WiFiClientSecure secureClient; + WiFiClient plainClient; + bool secure = url.startsWith("https://"); + if (secure) { + secureClient.setInsecure(); + if (!http.begin(secureClient, url)) { + return false; + } + } else { + if (!http.begin(plainClient, url)) { + return false; + } + } + http.setTimeout(7000); + http.addHeader("Content-Type", "application/json"); + statusCode = http.POST(body); + if (statusCode > 0) { + payload = http.getString(); + } + http.end(); + return statusCode > 0; +} + +static bool tcpProbeWsEndpoint(const String &url) { + if (!isWsUrl(url)) { + return false; + } + String host; + uint16_t port = 0; + bool secure = false; + if (!parseUrlHostPort(url, host, port, secure)) { + return false; + } + if (secure) { + WiFiClientSecure client; + client.setInsecure(); + bool ok = client.connect(host.c_str(), port, 4000); + client.stop(); + return ok; + } + WiFiClient client; + bool ok = client.connect(host.c_str(), port, 4000); + client.stop(); + return ok; +} + +static String jsonStringField(const String &json, const String &field) { + String needle = "\"" + field + "\""; + int keyPos = json.indexOf(needle); + if (keyPos < 0) { + return ""; + } + int colon = json.indexOf(':', keyPos + needle.length()); + if (colon < 0) { + return ""; + } + int firstQuote = json.indexOf('"', colon + 1); + if (firstQuote < 0) { + return ""; + } + int endQuote = firstQuote + 1; + while (true) { + endQuote = json.indexOf('"', endQuote); + if (endQuote < 0) { + return ""; + } + if (json[endQuote - 1] != '\\') { + break; + } + endQuote++; + } + return json.substring(firstQuote + 1, endQuote); +} + +static bool jsonInt64Field(const String &json, const String &field, uint64_t &valueOut) { + String needle = "\"" + field + "\""; + int keyPos = json.indexOf(needle); + if (keyPos < 0) { + return false; + } + int colon = json.indexOf(':', keyPos + needle.length()); + if (colon < 0) { + return false; + } + int pos = colon + 1; + while (pos < (int)json.length() && (json[pos] == ' ' || json[pos] == '\n' || json[pos] == '\r' || json[pos] == '\t')) { + pos++; + } + int start = pos; + while (pos < (int)json.length() && isDigit((unsigned char)json[pos])) { + pos++; + } + if (pos == start) { + return false; + } + valueOut = strtoull(json.substring(start, pos).c_str(), nullptr, 10); + return true; +} + +static bool connectWifiNow(String &messageOut) { + messageOut = ""; + gData.wifiReady = false; + gData.online = false; + syncWifiRuntimeState(); + if (gData.wifiSsid.length() == 0 || gData.wifiPassword.length() == 0) { + messageOut = "Нужны SSID и пароль"; + gNetState.wifiStatus = "нет данных"; + return false; + } + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + WiFi.begin(gData.wifiSsid.c_str(), gData.wifiPassword.c_str()); + unsigned long startedAt = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startedAt < 15000) { + delay(250); + } + syncWifiRuntimeState(); + if (WiFi.status() == WL_CONNECTED) { + gData.wifiReady = true; + messageOut = "Wi-Fi подключён"; + return true; + } + WiFi.disconnect(true, false); + syncWifiRuntimeState(); + gNetState.wifiStatus = "ошибка подключения"; + messageOut = "Не удалось подключиться к Wi-Fi"; + return false; +} + +static void disconnectWifiNow() { + WiFi.disconnect(true, false); + gData.wifiReady = false; + gData.online = false; + gData.serversReady = false; + syncWifiRuntimeState(); + gNetState.apiStatus = "отключён"; + gNetState.rpcStatus = "отключён"; + gNetState.wsStatus = "отключён"; + gNetState.serverVersion = "-"; +} + +static bool probeApiServer(String &messageOut) { + messageOut = ""; + int code = -1; + String payload; + if (!httpGetReachable(gData.apiUrl, code, payload)) { + gNetState.apiStatus = "нет ответа"; + messageOut = "API недоступен"; + return false; + } + gNetState.apiStatus = "HTTP " + String(code); + return true; +} + +static bool probeRpcServer(String &messageOut) { + messageOut = ""; + int code = -1; + String payload; + String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getHealth\"}"; + if (!httpPostJson(gData.rpcUrl, req, code, payload)) { + gNetState.rpcStatus = "нет ответа"; + messageOut = "RPC недоступен"; + return false; + } + bool ok = payload.indexOf("\"result\":\"ok\"") >= 0 || payload.indexOf("\"healthy\"") >= 0 || code == 200; + gNetState.rpcStatus = ok ? "RPC OK" : ("HTTP " + String(code)); + return ok; +} + +static bool probeWsServer(String &messageOut) { + messageOut = ""; + bool ok = tcpProbeWsEndpoint(gData.wsUrl); + gNetState.wsStatus = ok ? "сокет открыт" : "нет сокета"; + if (!ok) { + messageOut = "WS недоступен"; + } + return ok; +} + +static bool refreshServerReadiness(String &messageOut) { + messageOut = ""; + if (WiFi.status() != WL_CONNECTED) { + gData.serversReady = false; + messageOut = "Сначала подключите Wi-Fi"; + return false; + } + String apiMsg; + String rpcMsg; + String wsMsg; + bool apiOk = probeApiServer(apiMsg); + bool rpcOk = probeRpcServer(rpcMsg); + bool wsOk = probeWsServer(wsMsg); + gData.serversReady = apiOk && rpcOk && wsOk; + if (gData.serversReady) { + messageOut = "Серверы доступны"; + } else if (apiMsg.length() > 0) { + messageOut = apiMsg; + } else if (rpcMsg.length() > 0) { + messageOut = rpcMsg; + } else if (wsMsg.length() > 0) { + messageOut = wsMsg; + } else { + messageOut = "Не все серверы доступны"; + } + return gData.serversReady; +} + +static bool refreshWalletBalance(String &messageOut) { + messageOut = ""; + if (WiFi.status() != WL_CONNECTED) { + messageOut = "Сначала подключите Wi-Fi"; + return false; + } + if (gData.walletAddress.length() == 0) { + messageOut = "Сначала создайте кошелёк"; + return false; + } + int code = -1; + String payload; + String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getBalance\",\"params\":[\"" + gData.walletAddress + "\",{\"commitment\":\"confirmed\"}]}"; + if (!httpPostJson(gData.rpcUrl, req, code, payload)) { + gNetState.rpcStatus = "нет ответа"; + messageOut = "Не удалось прочитать баланс"; + return false; + } + uint64_t lamports = 0; + if (!jsonInt64Field(payload, "value", lamports)) { + gNetState.rpcStatus = "плохой ответ"; + messageOut = "RPC вернул непонятный баланс"; + return false; + } + gData.balanceCentiSol = (uint16_t)((lamports * 100ULL) / 1000000000ULL); + gNetState.rpcStatus = "баланс обновлён"; + messageOut = "Баланс обновлён"; + saveData(); + return true; +} + +static void seedRequests() { + gRequests[0].type = "Вход в сессию"; + gRequests[0].actor = "Chrome / aidarkc"; + gRequests[0].details = "Клиент просит подключиться к сабсерверу и открыть сессию без ввода пароля."; + gRequests[0].status = "Ожидает"; + + gRequests[1].type = "Подпись сообщения"; + gRequests[1].actor = "Wallet bridge"; + gRequests[1].details = "Нужно подписать тестовое сообщение для подключения браузерного кошелька."; + gRequests[1].status = "Ожидает"; +} + +static void loadDefaults() { + gData.pin = "1234"; + gData.wifiSsid = ""; + gData.wifiPassword = ""; + gData.login = ""; + gData.subserverName = "subserver1"; + gData.secret = ""; + gData.walletAddress = ""; + gData.userPdaAddress = ""; + gData.registrationSignature = ""; + gData.apiUrl = defaultApiUrl(); + gData.rpcUrl = defaultRpcUrl(); + gData.wsUrl = defaultWsUrl(); + gData.balanceCentiSol = 0; + gData.wifiReady = false; + gData.serversReady = false; + gData.secretReady = false; + gData.registered = false; + gData.online = false; + resetRuntimeNetState(); +} + +static void saveData() { + gPrefs.putString("pin", gData.pin); + gPrefs.putString("wifi_ssid", gData.wifiSsid); + gPrefs.putString("wifi_pass", gData.wifiPassword); + gPrefs.putString("login", gData.login); + gPrefs.putString("subserver", gData.subserverName); + gPrefs.putString("secret", gData.secret); + gPrefs.putString("wallet", gData.walletAddress); + gPrefs.putString("user_pda", gData.userPdaAddress); + gPrefs.putString("reg_sig", gData.registrationSignature); + gPrefs.putString("api", gData.apiUrl); + gPrefs.putString("rpc", gData.rpcUrl); + gPrefs.putString("ws", gData.wsUrl); + gPrefs.putUShort("bal", gData.balanceCentiSol); + gPrefs.putBool("wifi_ok", gData.wifiReady); + gPrefs.putBool("srv_ok", gData.serversReady); + gPrefs.putBool("secret_ok", gData.secretReady); + gPrefs.putBool("registered", gData.registered); + gPrefs.putBool("online", gData.online); +} + +static void loadData() { + loadDefaults(); + gData.pin = gPrefs.getString("pin", gData.pin); + 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.secret = gPrefs.getString("secret", gData.secret); + gData.walletAddress = gPrefs.getString("wallet", gData.walletAddress); + gData.userPdaAddress = gPrefs.getString("user_pda", gData.userPdaAddress); + gData.registrationSignature = gPrefs.getString("reg_sig", gData.registrationSignature); + gData.apiUrl = gPrefs.getString("api", gData.apiUrl); + gData.rpcUrl = gPrefs.getString("rpc", gData.rpcUrl); + gData.wsUrl = gPrefs.getString("ws", gData.wsUrl); + gData.balanceCentiSol = gPrefs.getUShort("bal", gData.balanceCentiSol); + gData.wifiReady = gPrefs.getBool("wifi_ok", gData.wifiReady); + gData.serversReady = gPrefs.getBool("srv_ok", gData.serversReady); + gData.secretReady = gPrefs.getBool("secret_ok", gData.secretReady); + gData.registered = gPrefs.getBool("registered", gData.registered); + gData.online = gPrefs.getBool("online", gData.online); + restoreDerivedKeysFromSecret(); + if (gData.registered && gData.userPdaAddress.length() == 0) { + gData.registered = false; + gData.online = false; + } +} + +static void fullResetData() { + gPrefs.clear(); + loadDefaults(); + gLockBuffer = ""; + gEditBuffer = ""; + gEditTarget = EDIT_NONE; + gConfirmTarget = CONFIRM_NONE; + gNotice = "Устройство сброшено"; + seedRequests(); + saveData(); +} + +static void generateSecretAndWallet() { + if (!deriveFreshSecretAndWallet()) { + gData.secret = ""; + gData.walletAddress = ""; + gData.secretReady = false; + return; + } + gData.userPdaAddress = ""; + gData.registrationSignature = ""; + gData.registered = false; + gData.online = false; + if (gData.subserverName.length() == 0) { + gData.subserverName = "subserver1"; + } + saveData(); +} + +static ActionId digitAction(int digit) { + return (ActionId)(ACT_DIGIT_0 + digit); +} + +static void drawNumericPad(int yStart, bool withCancel = false) { + int index = 1; + int x0 = 44; + int y = yStart; + for (int row = 0; row < 3; row++) { + for (int col = 0; col < 3; col++) { + int x = x0 + col * 128; + addButton(x, y, 92, 54, digitAction(index), String(index), true, C_BUTTON); + index++; + } + y += 60; + } + addButton(44, y, 92, 54, withCancel ? ACT_INPUT_CANCEL : ACT_INPUT_BACKSPACE, withCancel ? "Отмена" : "Стереть", true, C_BUTTON2); + addButton(172, y, 92, 54, ACT_DIGIT_0, "0", true, C_BUTTON); + addButton(300, y, 92, 54, ACT_INPUT_OK, "OK", true, C_OK); +} + +static void drawKeyboard() { + const char **rows = gKeyboardAlt ? KB_ALT : KB_ALPHA; + int keyW = 40; + int keyH = 42; + int gap = 4; + int y = 232; + for (int row = 0; row < 3; row++) { + String letters = rows[row]; + int totalW = letters.length() * (keyW + gap) - gap; + int x = (DISP_W - totalW) / 2; + for (int i = 0; i < letters.length(); i++) { + String key = letters.substring(i, i + 1); + ActionId act = (ActionId)(1000 + key[0]); + addButton(x, y, keyW, keyH, act, key, true, C_BUTTON); + x += keyW + gap; + } + y += keyH + gap; + } + addButton(20, 370, 84, 44, ACT_KB_SHIFT, gKeyboardAlt ? "abc" : "123", true, C_BUTTON2); + addButton(112, 370, 124, 44, ACT_KB_SPACE, "Пробел", true, C_BUTTON2); + addButton(244, 370, 92, 44, ACT_INPUT_BACKSPACE, "Стереть", true, C_BUTTON2); + addButton(344, 370, 116, 44, ACT_INPUT_OK, "Сохранить", true, C_OK); + addButton(20, 420, 440, 40, ACT_INPUT_CANCEL, "Отмена", true, C_CARD); +} + +static String editTargetLabel() { + switch (gEditTarget) { + case EDIT_SSID: return "SSID"; + case EDIT_WIFI_PASSWORD: return "Пароль Wi-Fi"; + case EDIT_LOGIN: return "Логин"; + case EDIT_SUBSERVER: return "Имя сабсервера"; + case EDIT_API: return "API URL"; + case EDIT_RPC: return "RPC URL"; + case EDIT_WS: return "WS URL"; + case EDIT_NEW_PIN: return "Новый PIN"; + case EDIT_UNLOCK_PIN: return "PIN"; + default: return "Поле"; + } +} + +static void drawLockScreen() { + clearButtons(); + drawTopBar("SHiNE Device"); + drawPanel(20, 96, 440, 110, C_PANEL, C_BORDER, 14); + drawText(36, 126, "Устройство заблокировано", C_TEXT, (const uint8_t *)FONT_BODY); + String masked; + for (int i = 0; i < gLockBuffer.length(); i++) { + masked += "*"; + } + drawPanel(36, 142, 408, 46, C_CARD, C_BORDER, 10); + drawText(52, 172, masked.length() ? masked : "Введите PIN", masked.length() ? C_TEXT : C_MUTE, (const uint8_t *)FONT_BODY); + drawNoticeBox(); + drawNumericPad(220, true); +} + +static void drawHomeScreen() { + clearButtons(); + drawTopBar("Главный экран"); + 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); + + drawPanel(20, 204, 210, 82, C_CARD, C_BORDER, 12); + drawText(34, 232, "Wi-Fi", C_TEXT, (const uint8_t *)FONT_BODY); + drawText(34, 258, gData.wifiReady ? gData.wifiSsid : "Не настроен", gData.wifiReady ? C_ACCENT : C_WARN, (const uint8_t *)FONT_SMALL); + + drawPanel(250, 204, 210, 82, C_CARD, C_BORDER, 12); + drawText(264, 232, "Кошелёк", C_TEXT, (const uint8_t *)FONT_BODY); + drawText(264, 258, balanceText(), gData.balanceCentiSol >= 20 ? C_ACCENT : C_WARN, (const uint8_t *)FONT_SMALL); + + addButton(20, 300, 136, 52, ACT_STATUS, "Статус"); + addButton(172, 300, 136, 52, ACT_CONNECTION, "Подключение"); + addButton(324, 300, 136, 52, ACT_ACCOUNT, "Аккаунт"); + addButton(20, 362, 136, 52, ACT_WALLET, "Кошелёк"); + addButton(172, 362, 136, 52, ACT_REQUESTS, "Запросы"); + addButton(324, 362, 136, 52, ACT_SETTINGS, "Настройки"); + addButton(20, 422, 440, 38, ACT_REGISTER, gData.registered ? "Устройство уже зарегистрировано" : "Зарегистрировать", canRegister(), canRegister() ? C_OK : C_CARD); + drawNoticeBox(); +} + +static void drawStatusScreen() { + clearButtons(); + 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, 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); + drawText(36, 252, "Серверы: " + boolText(gData.serversReady, "готовы", "не готовы"), gData.serversReady ? C_ACCENT : C_WARN); + drawText(36, 278, "Баланс: " + balanceText(), gData.balanceCentiSol >= 20 ? C_ACCENT : C_WARN); + drawText(36, 304, "Регистрация: " + boolText(gData.registered, "выполнена", "не выполнена"), gData.registered ? C_ACCENT : C_WARN); + drawText(36, 330, "Онлайн: " + boolText(gData.online), gData.online ? C_ACCENT : C_WARN); + drawText(36, 356, "PDA/TX: " + registrationDetailsShort(), C_MUTE, (const uint8_t *)FONT_SMALL); + addButton(20, 396, 212, 48, ACT_BACK, "Назад"); + addButton(248, 396, 212, 48, ACT_REFRESH, "Обновить статус", true, C_BUTTON2); + drawNoticeBox(); +} + +static void drawConnectionScreen() { + clearButtons(); + drawTopBar("Подключение"); + drawPanel(20, 92, 440, 176, C_PANEL, C_BORDER, 16); + drawText(36, 124, "Wi-Fi: " + boolText(gData.wifiReady, "готов", "не готов"), gData.wifiReady ? C_ACCENT : C_WARN); + drawText(36, 154, "Серверы: " + boolText(gData.serversReady, "готовы", "не готовы"), gData.serversReady ? C_ACCENT : C_WARN); + drawText(36, 184, "Онлайн: " + boolText(gData.online, "подключён", "отключён"), gData.online ? C_ACCENT : C_WARN); + drawText(36, 214, "IP: " + gNetState.localIp, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(36, 236, "API: " + gNetState.apiStatus, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(36, 258, "RPC: " + gNetState.rpcStatus, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(36, 280, "WS: " + gNetState.wsStatus, C_MUTE, (const uint8_t *)FONT_SMALL); + addButton(20, 286, 212, 48, ACT_WIFI, "Wi-Fi"); + addButton(248, 286, 212, 48, ACT_SERVERS, "Серверы"); + addButton(20, 346, 212, 48, ACT_CONNECT, "Подключить", gData.wifiReady && gData.serversReady, C_OK); + addButton(248, 346, 212, 48, ACT_DISCONNECT, "Отключить", gData.online, C_BUTTON2); + addButton(20, 412, 440, 40, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static void drawWifiScreen() { + clearButtons(); + drawTopBar("Wi-Fi"); + drawPanel(20, 92, 440, 174, C_PANEL, C_BORDER, 16); + drawText(36, 122, "SSID: " + (gData.wifiSsid.length() ? gData.wifiSsid : "не задан"), C_TEXT); + drawText(36, 152, String("Пароль: ") + (gData.wifiPassword.length() ? "сохранён" : "не задан"), C_TEXT); + drawText(36, 182, "Статус: " + gNetState.wifiStatus, gData.wifiReady ? C_ACCENT : C_WARN); + drawText(36, 208, "IP: " + gNetState.localIp, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(36, 228, "RSSI: " + String(gNetState.rssi) + " dBm", C_MUTE, (const uint8_t *)FONT_SMALL); + drawWrappedText(36, 248, 45, 16, "Проверка делает реальное подключение по сохранённым SSID и паролю.", C_MUTE, (const uint8_t *)FONT_SMALL); + addButton(20, 286, 212, 48, ACT_EDIT_SSID, "Изменить SSID"); + addButton(248, 286, 212, 48, ACT_EDIT_WIFI_PASSWORD, "Изменить пароль"); + addButton(20, 346, 212, 48, ACT_VERIFY_WIFI, "Проверить", true, C_OK); + addButton(248, 346, 212, 48, ACT_RESET_WIFI, "Сбросить", true, C_BUTTON2); + addButton(20, 412, 440, 40, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static void drawServersScreen() { + clearButtons(); + drawTopBar("Серверы"); + drawPanel(20, 92, 440, 202, C_PANEL, C_BORDER, 16); + drawText(36, 120, "API: " + shortenValue(gData.apiUrl, 24, 10), C_TEXT, (const uint8_t *)FONT_SMALL); + drawText(36, 150, "RPC: " + shortenValue(gData.rpcUrl, 24, 10), C_TEXT, (const uint8_t *)FONT_SMALL); + drawText(36, 180, "WS: " + shortenValue(gData.wsUrl, 24, 10), C_TEXT, (const uint8_t *)FONT_SMALL); + drawText(36, 212, "Статус: " + boolText(gData.serversReady, "готовы", "не готовы"), gData.serversReady ? C_ACCENT : C_WARN); + drawText(36, 238, "API: " + gNetState.apiStatus, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(36, 258, "RPC: " + gNetState.rpcStatus, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(36, 278, "WS: " + gNetState.wsStatus, C_MUTE, (const uint8_t *)FONT_SMALL); + addButton(20, 310, 136, 46, ACT_EDIT_API, "Изменить API"); + addButton(172, 310, 136, 46, ACT_EDIT_RPC, "Изменить RPC"); + addButton(324, 310, 136, 46, ACT_EDIT_WS, "Изменить WS"); + addButton(20, 368, 212, 46, ACT_VERIFY_SERVERS, "Проверить", true, C_OK); + addButton(248, 368, 212, 46, ACT_SET_TEST_SERVERS, "Тестовые", true, C_BUTTON2); + addButton(20, 420, 440, 36, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static void drawAccountScreen() { + clearButtons(); + 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, 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(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, "Назад"); + drawNoticeBox(); +} + +static void drawWalletScreen() { + clearButtons(); + drawTopBar("Кошелёк"); + drawPanel(20, 92, 440, 182, C_PANEL, C_BORDER, 16); + drawText(36, 122, "Адрес: " + (gData.walletAddress.length() ? shortenValue(gData.walletAddress, 14, 10) : "не создан"), C_TEXT, (const uint8_t *)FONT_SMALL); + drawText(36, 154, "Баланс: " + balanceText(), gData.balanceCentiSol >= 20 ? C_ACCENT : C_WARN); + drawText(36, 184, "Минимум для регистрации: 0.20 SOL", C_TEXT, (const uint8_t *)FONT_SMALL); + drawText(36, 214, "Статус: " + String(gData.balanceCentiSol >= 20 ? "Хватает" : "Не хватает"), gData.balanceCentiSol >= 20 ? C_ACCENT : C_WARN); + drawWrappedText(36, 242, 48, 16, "Проверить читает реальный баланс из Solana RPC. Кнопки +/- оставлены как локальный тестовый режим.", C_MUTE, (const uint8_t *)FONT_SMALL); + addButton(20, 292, 212, 46, ACT_SHOW_QR, "QR и URI", gData.walletAddress.length() > 0, C_OK); + addButton(248, 292, 212, 46, ACT_CHECK_BALANCE, "Проверить"); + addButton(20, 350, 136, 46, ACT_BALANCE_PLUS_010, "+0.10 SOL"); + addButton(172, 350, 136, 46, ACT_BALANCE_PLUS_025, "+0.25 SOL", true, C_OK); + addButton(324, 350, 136, 46, ACT_BALANCE_MINUS_010, "-0.10 SOL", gData.balanceCentiSol > 0, C_BUTTON2); + addButton(20, 420, 440, 36, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static String walletUri() { + if (gData.walletAddress.length() == 0) { + return ""; + } + return "solana:" + gData.walletAddress + "?amount=0.20&label=SHiNE%20Register"; +} + +static void drawQrCodeBlock(int x, int y, int size, const String &payload) { + uint8_t qr[qrcodegen_BUFFER_LEN_MAX]; + uint8_t tmp[qrcodegen_BUFFER_LEN_MAX]; + memset(qr, 0, sizeof(qr)); + memset(tmp, 0, sizeof(tmp)); + bool ok = qrcodegen_encodeText(payload.c_str(), tmp, qr, qrcodegen_Ecc_MEDIUM, 1, 10, qrcodegen_Mask_AUTO, true); + drawPanel(x, y, size, size, 0xFFFFu, 0xFFFFu, 8); + if (!ok) { + drawText(x + 18, y + size / 2, "QR ERROR", C_BAD, (const uint8_t *)FONT_BODY); + return; + } + int qrSize = qrcodegen_getSize(qr); + int scale = (size - 20) / qrSize; + if (scale < 1) { + scale = 1; + } + int margin = (size - qrSize * scale) / 2; + gfx->fillRect(x + 4, y + 4, size - 8, size - 8, 0xFFFFu); + for (int yy = 0; yy < qrSize; yy++) { + for (int xx = 0; xx < qrSize; xx++) { + if (qrcodegen_getModule(qr, xx, yy)) { + gfx->fillRect(x + margin + xx * scale, y + margin + yy * scale, scale, scale, 0x0000u); + } + } + } +} + +static void drawWalletQrScreen() { + clearButtons(); + drawTopBar("QR и URI"); + String uri = walletUri(); + drawQrCodeBlock(36, 96, 240, uri); + drawPanel(292, 96, 152, 240, C_PANEL, C_BORDER, 14); + drawWrappedText(306, 126, 14, 18, "Пополнение\nдля\nрегистрации", C_TEXT, (const uint8_t *)FONT_BODY); + drawText(306, 226, "Сумма", C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(306, 248, "0.20 SOL", C_ACCENT, (const uint8_t *)FONT_BODY); + drawText(306, 286, "Баланс", C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(306, 308, balanceText(), C_TEXT, (const uint8_t *)FONT_BODY); + drawPanel(20, 350, 440, 92, C_PANEL, C_BORDER, 14); + drawWrappedText(34, 378, 52, 16, uri, C_TEXT, (const uint8_t *)FONT_SMALL); + addButton(20, 446, 440, 28, ACT_BACK, "Назад"); +} + +static void drawRequestsScreen() { + clearButtons(); + drawTopBar("Запросы"); + for (int i = 0; i < 2; i++) { + int y = 96 + i * 112; + drawPanel(20, y, 440, 94, C_PANEL, C_BORDER, 14); + drawText(34, y + 28, gRequests[i].type, C_TEXT, (const uint8_t *)FONT_BODY); + drawText(34, y + 54, gRequests[i].actor, C_MUTE, (const uint8_t *)FONT_SMALL); + drawText(34, y + 74, "Статус: " + gRequests[i].status, gRequests[i].status == "Ожидает" ? C_WARN : C_ACCENT, (const uint8_t *)FONT_SMALL); + addButton(330, y + 26, 110, 42, i == 0 ? ACT_OPEN_REQ_0 : ACT_OPEN_REQ_1, "Открыть"); + } + addButton(20, 420, 440, 36, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static void drawRequestDetailScreen() { + clearButtons(); + RequestItem &req = gRequests[gCurrentRequest]; + drawTopBar("Детали запроса"); + drawPanel(20, 92, 440, 248, C_PANEL, C_BORDER, 16); + drawText(36, 120, req.type, C_TEXT, (const uint8_t *)FONT_HEAD); + drawText(36, 152, "Источник: " + req.actor, C_TEXT, (const uint8_t *)FONT_BODY); + drawText(36, 180, "Статус: " + req.status, req.status == "Ожидает" ? C_WARN : C_ACCENT, (const uint8_t *)FONT_BODY); + drawWrappedText(36, 214, 48, 18, req.details, C_MUTE, (const uint8_t *)FONT_BODY); + addButton(20, 360, 136, 48, ACT_APPROVE_REQUEST, "Разрешить", req.status == "Ожидает", C_OK); + addButton(172, 360, 136, 48, ACT_REJECT_REQUEST, "Отклонить", req.status == "Ожидает", C_BUTTON2); + addButton(324, 360, 136, 48, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static void drawSettingsScreen() { + clearButtons(); + drawTopBar("Настройки"); + drawPanel(20, 92, 440, 176, C_PANEL, C_BORDER, 16); + drawText(36, 122, "PIN: " + repeatChar('*', gData.pin.length()), C_TEXT); + drawText(36, 152, "Онлайн после старта: " + boolText(gData.online), C_TEXT); + drawText(36, 182, "Русский UI: активен", C_ACCENT); + drawWrappedText(36, 214, 48, 16, "Русский текст рисуется через UTF-8 и кириллический U8g2-шрифт. Это обязательная часть прототипа.", C_MUTE, (const uint8_t *)FONT_SMALL); + addButton(20, 286, 212, 48, ACT_CHANGE_PIN, "Сменить PIN"); + addButton(248, 286, 212, 48, ACT_RESET_ONLINE, "Сбросить онлайн", true, C_BUTTON2); + addButton(20, 346, 440, 48, ACT_FULL_RESET, "Полный сброс", true, C_BAD); + addButton(20, 420, 440, 36, ACT_BACK, "Назад"); + drawNoticeBox(); +} + +static void drawTextInputScreen() { + clearButtons(); + drawTopBar("Редактирование"); + drawPanel(20, 92, 440, 126, C_PANEL, C_BORDER, 16); + drawText(36, 122, editTargetLabel(), C_TEXT, (const uint8_t *)FONT_HEAD); + drawPanel(36, 142, 408, 50, C_CARD, C_BORDER, 10); + String shown = gEditBuffer.length() ? gEditBuffer : "Введите значение"; + drawWrappedText(50, 172, 34, 16, shown, gEditBuffer.length() ? C_TEXT : C_MUTE, (const uint8_t *)FONT_SMALL); + drawKeyboard(); +} + +static void drawPinEditScreen() { + clearButtons(); + drawTopBar("Новый PIN"); + drawPanel(20, 92, 440, 112, C_PANEL, C_BORDER, 16); + drawText(36, 122, "Введите новый PIN", C_TEXT, (const uint8_t *)FONT_HEAD); + String masked; + for (int i = 0; i < gEditBuffer.length(); i++) { + masked += "*"; + } + drawPanel(36, 146, 408, 42, C_CARD, C_BORDER, 10); + drawText(52, 174, masked.length() ? masked : "4-8 цифр", masked.length() ? C_TEXT : C_MUTE, (const uint8_t *)FONT_BODY); + drawNoticeBox(); + drawNumericPad(220, true); +} + +static void drawConfirmScreen() { + clearButtons(); + drawTopBar("Подтверждение"); + drawPanel(40, 140, 400, 180, C_PANEL, C_BORDER, 18); + String title = "Подтвердить"; + String text = "Выполнить действие?"; + if (gConfirmTarget == CONFIRM_REGISTER) { + title = "Регистрация"; + text = "Отправить create_user_pda в Solana через device key этого устройства?"; + } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { + title = "Очистка"; + text = "Удалить секрет, кошелёк и статус регистрации?"; + } else if (gConfirmTarget == CONFIRM_FULL_RESET) { + title = "Полный сброс"; + text = "Очистить весь локальный конфиг устройства?"; + } + drawText(64, 180, title, C_TEXT, (const uint8_t *)FONT_HEAD); + drawWrappedText(64, 218, 34, 18, text, C_MUTE, (const uint8_t *)FONT_BODY); + addButton(64, 270, 144, 42, ACT_CONFIRM_YES, "Подтвердить", true, C_OK); + addButton(232, 270, 144, 42, ACT_CONFIRM_NO, "Отмена", true, C_BUTTON2); +} + +static void redrawScreen() { + gNeedRedraw = false; + if (gScreen == SCR_LOCK) { + drawLockScreen(); + return; + } + switch (gScreen) { + case SCR_HOME: drawHomeScreen(); break; + case SCR_STATUS: drawStatusScreen(); break; + case SCR_CONNECTION: drawConnectionScreen(); break; + case SCR_WIFI_EDIT: drawWifiScreen(); break; + case SCR_SERVERS: drawServersScreen(); break; + case SCR_ACCOUNT: drawAccountScreen(); break; + case SCR_WALLET: drawWalletScreen(); break; + case SCR_WALLET_QR: drawWalletQrScreen(); break; + case SCR_REQUESTS: drawRequestsScreen(); break; + case SCR_REQUEST_DETAIL: drawRequestDetailScreen(); break; + case SCR_SETTINGS: drawSettingsScreen(); break; + case SCR_PIN_EDIT: drawPinEditScreen(); break; + case SCR_TEXT_INPUT: drawTextInputScreen(); break; + case SCR_CONFIRM: drawConfirmScreen(); break; + default: drawHomeScreen(); break; + } +} + +static void openScreen(ScreenId screen) { + if (gScreen != SCR_LOCK && screen != SCR_CONFIRM && screen != SCR_TEXT_INPUT && screen != SCR_PIN_EDIT) { + gPrevScreen = gScreen; + } + gScreen = screen; + gNeedRedraw = true; +} + +static void openEdit(EditTarget target, const String &startValue, bool alt = false) { + gEditTarget = target; + gEditBuffer = startValue; + gKeyboardAlt = alt; + openScreen(target == EDIT_NEW_PIN ? SCR_PIN_EDIT : SCR_TEXT_INPUT); +} + +static void openConfirm(ConfirmTarget target) { + gConfirmTarget = target; + openScreen(SCR_CONFIRM); +} + +static void applyEditValue() { + String value = trimCopy(gEditBuffer); + switch (gEditTarget) { + case EDIT_SSID: + gData.wifiSsid = value; + gData.wifiReady = false; + gData.online = false; + gNetState.wifiStatus = "нужно проверить"; + gNetState.localIp = "-"; + gNotice = "SSID сохранён"; + break; + case EDIT_WIFI_PASSWORD: + gData.wifiPassword = gEditBuffer; + gData.wifiReady = false; + gData.online = false; + gNetState.wifiStatus = "нужно проверить"; + gNetState.localIp = "-"; + gNotice = "Пароль Wi-Fi сохранён"; + break; + case EDIT_LOGIN: + gData.login = normalizeLogin(value); + gData.registered = false; + gData.online = false; + gData.userPdaAddress = ""; + gData.registrationSignature = ""; + gNotice = "Логин сохранён"; + break; + case EDIT_SUBSERVER: + gData.subserverName = value.length() ? value : "subserver1"; + gNotice = "Имя сабсервера сохранено"; + break; + case EDIT_API: + gData.apiUrl = value; + gData.serversReady = false; + gData.online = false; + gNetState.apiStatus = "нужно проверить"; + gNotice = "API URL сохранён"; + break; + case EDIT_RPC: + gData.rpcUrl = value; + gData.serversReady = false; + gData.online = false; + gNetState.rpcStatus = "нужно проверить"; + gNotice = "RPC URL сохранён"; + break; + case EDIT_WS: + gData.wsUrl = value; + gData.serversReady = false; + gData.online = false; + gNetState.wsStatus = "нужно проверить"; + gNotice = "WS URL сохранён"; + break; + case EDIT_NEW_PIN: + if (gEditBuffer.length() < 4 || gEditBuffer.length() > 8) { + gNotice = "PIN должен быть длиной 4-8"; + return; + } + gData.pin = gEditBuffer; + gNotice = "PIN обновлён"; + break; + default: + break; + } + saveData(); + gEditTarget = EDIT_NONE; + gEditBuffer = ""; + openScreen(gPrevScreen); +} + +static bool handleCustomKeyAction(ActionId action) { + if ((int)action >= 1000) { + char c = (char)((int)action - 1000); + gEditBuffer += c; + gNeedRedraw = true; + return true; + } + return false; +} + +static void handleAction(ActionId action) { + if (handleCustomKeyAction(action)) { + return; + } + + if (gScreen == SCR_LOCK) { + if (action >= ACT_DIGIT_0 && action <= ACT_DIGIT_9) { + if (gLockBuffer.length() < 8) { + gLockBuffer += String((int)action - (int)ACT_DIGIT_0); + } + gNotice = ""; + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_CANCEL) { + gLockBuffer = ""; + gNotice = ""; + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_BACKSPACE) { + if (gLockBuffer.length() > 0) { + gLockBuffer.remove(gLockBuffer.length() - 1); + } + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_OK) { + if (gLockBuffer == gData.pin) { + gLockBuffer = ""; + gNotice = ""; + openScreen(SCR_HOME); + } else { + gLockBuffer = ""; + gNotice = "Неверный PIN"; + gNeedRedraw = true; + } + return; + } + } + + if (gScreen == SCR_TEXT_INPUT) { + if (action == ACT_KB_SHIFT) { + gKeyboardAlt = !gKeyboardAlt; + gNeedRedraw = true; + return; + } + if (action == ACT_KB_SPACE) { + gEditBuffer += " "; + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_BACKSPACE) { + if (gEditBuffer.length() > 0) { + gEditBuffer.remove(gEditBuffer.length() - 1); + } + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_CANCEL) { + gEditTarget = EDIT_NONE; + gEditBuffer = ""; + openScreen(gPrevScreen); + return; + } + if (action == ACT_INPUT_OK) { + applyEditValue(); + return; + } + } + + if (gScreen == SCR_PIN_EDIT) { + if (action >= ACT_DIGIT_0 && action <= ACT_DIGIT_9) { + if (gEditBuffer.length() < 8) { + gEditBuffer += String((int)action - (int)ACT_DIGIT_0); + } + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_BACKSPACE) { + if (gEditBuffer.length() > 0) { + gEditBuffer.remove(gEditBuffer.length() - 1); + } + gNeedRedraw = true; + return; + } + if (action == ACT_INPUT_CANCEL) { + gEditTarget = EDIT_NONE; + gEditBuffer = ""; + openScreen(gPrevScreen); + return; + } + if (action == ACT_INPUT_OK) { + applyEditValue(); + return; + } + } + + if (gScreen == SCR_CONFIRM) { + if (action == ACT_CONFIRM_NO) { + gConfirmTarget = CONFIRM_NONE; + openScreen(gPrevScreen); + return; + } + if (action == ACT_CONFIRM_YES) { + if (gConfirmTarget == CONFIRM_REGISTER) { + registerSubserverOnSolana(gNotice); + } else if (gConfirmTarget == CONFIRM_CLEAR_ACCOUNT) { + gData.secret = ""; + gData.walletAddress = ""; + gData.userPdaAddress = ""; + gData.registrationSignature = ""; + gData.secretReady = false; + gData.registered = false; + gData.online = false; + gNotice = "Секрет и регистрация очищены"; + saveData(); + } else if (gConfirmTarget == CONFIRM_FULL_RESET) { + fullResetData(); + } + gConfirmTarget = CONFIRM_NONE; + openScreen(SCR_HOME); + return; + } + } + + switch (action) { + case ACT_HOME: openScreen(SCR_HOME); break; + case ACT_BACK: openScreen(gPrevScreen == gScreen ? SCR_HOME : gPrevScreen); break; + case ACT_STATUS: openScreen(SCR_STATUS); break; + case ACT_CONNECTION: openScreen(SCR_CONNECTION); break; + case ACT_ACCOUNT: openScreen(SCR_ACCOUNT); break; + case ACT_WALLET: openScreen(SCR_WALLET); break; + case ACT_REQUESTS: openScreen(SCR_REQUESTS); break; + case ACT_SETTINGS: openScreen(SCR_SETTINGS); break; + case ACT_REFRESH: + syncWifiRuntimeState(); + gNotice = "Статус обновлён"; + gNeedRedraw = true; + break; + case ACT_WIFI: openScreen(SCR_WIFI_EDIT); break; + case ACT_SERVERS: openScreen(SCR_SERVERS); break; + case ACT_CONNECT: + if (WiFi.status() == WL_CONNECTED && gData.serversReady) { + gData.online = true; + gNotice = "Устройство переведено в онлайн"; + saveData(); + } else { + gNotice = "Сначала проверьте Wi-Fi и серверы"; + } + gNeedRedraw = true; + break; + case ACT_DISCONNECT: + gData.online = false; + gNotice = "Онлайн отключён"; + saveData(); + gNeedRedraw = true; + break; + case ACT_EDIT_SSID: openEdit(EDIT_SSID, gData.wifiSsid, false); break; + case ACT_EDIT_WIFI_PASSWORD: openEdit(EDIT_WIFI_PASSWORD, gData.wifiPassword, true); break; + case ACT_VERIFY_WIFI: + connectWifiNow(gNotice); + saveData(); + gNeedRedraw = true; + break; + case ACT_RESET_WIFI: + gData.wifiSsid = ""; + gData.wifiPassword = ""; + disconnectWifiNow(); + gNotice = "Wi-Fi очищен"; + saveData(); + gNeedRedraw = true; + break; + case ACT_EDIT_API: openEdit(EDIT_API, gData.apiUrl, true); break; + case ACT_EDIT_RPC: openEdit(EDIT_RPC, gData.rpcUrl, true); break; + case ACT_EDIT_WS: openEdit(EDIT_WS, gData.wsUrl, true); break; + case ACT_VERIFY_SERVERS: + if (gData.apiUrl.length() == 0 || gData.rpcUrl.length() == 0 || gData.wsUrl.length() == 0) { + gData.serversReady = false; + gNotice = "Заполните все адреса"; + } else { + refreshServerReadiness(gNotice); + } + saveData(); + gNeedRedraw = true; + break; + case ACT_SET_TEST_SERVERS: + gData.apiUrl = defaultApiUrl(); + gData.rpcUrl = defaultRpcUrl(); + gData.wsUrl = defaultWsUrl(); + gData.serversReady = false; + gData.online = false; + gNetState.apiStatus = "нужно проверить"; + gNetState.rpcStatus = "нужно проверить"; + gNetState.wsStatus = "нужно проверить"; + gNotice = "Подставлены тестовые серверы"; + saveData(); + 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_GENERATE_SECRET: + generateSecretAndWallet(); + gNotice = gData.secretReady ? "Секрет сгенерирован, device-кошелёк выведен из него" : "Не удалось сгенерировать секрет"; + gNeedRedraw = true; + break; + case ACT_CLEAR_ACCOUNT: + openConfirm(CONFIRM_CLEAR_ACCOUNT); + break; + case ACT_SHOW_QR: + if (gData.walletAddress.length() > 0) { + openScreen(SCR_WALLET_QR); + } + break; + case ACT_BALANCE_PLUS_010: + gData.balanceCentiSol += 10; + gNotice = "Баланс увеличен"; + saveData(); + gNeedRedraw = true; + break; + case ACT_BALANCE_PLUS_025: + gData.balanceCentiSol += 25; + gNotice = "Баланс увеличен"; + saveData(); + gNeedRedraw = true; + break; + case ACT_BALANCE_MINUS_010: + if (gData.balanceCentiSol >= 10) { + gData.balanceCentiSol -= 10; + } else { + gData.balanceCentiSol = 0; + } + gNotice = "Баланс уменьшен"; + saveData(); + gNeedRedraw = true; + break; + case ACT_CHECK_BALANCE: + if (!refreshWalletBalance(gNotice)) { + if (gData.balanceCentiSol >= 20) { + gNotice += " Средств хватает."; + } + } + gNeedRedraw = true; + break; + case ACT_REGISTER: + if (canRegister()) { + openConfirm(CONFIRM_REGISTER); + } else { + gNotice = "Пока не выполнены все условия регистрации"; + gNeedRedraw = true; + } + break; + case ACT_OPEN_REQ_0: + gCurrentRequest = 0; + openScreen(SCR_REQUEST_DETAIL); + break; + case ACT_OPEN_REQ_1: + gCurrentRequest = 1; + openScreen(SCR_REQUEST_DETAIL); + break; + case ACT_APPROVE_REQUEST: + gRequests[gCurrentRequest].status = "Разрешён"; + gNotice = "Запрос разрешён"; + gNeedRedraw = true; + break; + case ACT_REJECT_REQUEST: + gRequests[gCurrentRequest].status = "Отклонён"; + gNotice = "Запрос отклонён"; + gNeedRedraw = true; + break; + case ACT_CHANGE_PIN: + openEdit(EDIT_NEW_PIN, "", false); + break; + case ACT_RESET_ONLINE: + gData.online = false; + gNotice = "Онлайн сброшен"; + saveData(); + gNeedRedraw = true; + break; + case ACT_FULL_RESET: + openConfirm(CONFIRM_FULL_RESET); + break; + default: + break; + } +} + +static ActionId actionAt(int16_t x, int16_t y) { + for (int i = gButtonCount - 1; i >= 0; i--) { + Button &btn = gButtons[i]; + if (!btn.enabled) { + continue; + } + if (x >= btn.x && x < btn.x + btn.w && y >= btn.y && y < btn.y + btn.h) { + return btn.action; + } + } + return ACT_NONE; +} + +static void pollTouch() { + int16_t x = 0; + int16_t y = 0; + bool touching = gTouch.getPoint(&x, &y, 1) > 0; + if (touching && !gTouchDown) { + gTouchDown = true; + gTouchStartX = x; + gTouchStartY = y; + gTouchLastX = x; + gTouchLastY = y; + return; + } + if (touching && gTouchDown) { + gTouchLastX = x; + gTouchLastY = y; + return; + } + if (!touching && gTouchDown) { + gTouchDown = false; + int dx = abs(gTouchLastX - gTouchStartX); + int dy = abs(gTouchLastY - gTouchStartY); + if (dx < 14 && dy < 14) { + ActionId action = actionAt(gTouchLastX, gTouchLastY); + if (action != ACT_NONE) { + handleAction(action); + } + } + } +} + +void setup() { + Serial.begin(115200); + sodium_init(); + Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); + + gfx->begin(); + gBus->writeC8D8(0x36, 0xA0); + gfx->setBrightness(220); + gfx->fillScreen(C_BG); + gfx->setUTF8Print(true); + + gTouch.setPins(PIN_TP_INT, -1); + gTouch.begin(Wire, CST92XX_SLAVE_ADDRESS, PIN_I2C_SDA, PIN_I2C_SCL); + gTouch.setMaxCoordinates(DISP_W, DISP_H); + gTouch.setSwapXY(true); + gTouch.setMirrorXY(true, false); + + gPrefs.begin("shine-ui", false); + loadData(); + syncWifiRuntimeState(); + if (WiFi.status() != WL_CONNECTED) { + gData.online = false; + if (gData.wifiReady) { + gNetState.wifiStatus = "нужно проверить"; + gNetState.localIp = "-"; + } + } + seedRequests(); + gNotice = "Русский UTF-8 UI активен"; + gNeedRedraw = true; +} + +void loop() { + syncWifiRuntimeState(); + pollTouch(); + if (gNeedRedraw) { + redrawScreen(); + } + delay(16); +} diff --git a/VERSION.properties b/VERSION.properties index 5f66ce5..5d0ad21 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.135 -server.version=1.2.127 +client.version=1.2.138 +server.version=1.2.130