diff --git a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md index cde144d..6984a83 100644 --- a/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md +++ b/Dev_Docs/Pending_Features/2026-06-08_1940_esp32_nav_minimal_test.md @@ -3,14 +3,31 @@ - Краткое описание: минимальный UI-прототип для сабсервера на базе `LVGL + subserver touch`, с Wi-Fi flow, серверными адресами и общим экраном редактирования текста. - Что проверять: - стартует экран `HOME`; - - на `HOME` видны реальное значение сабсервера или `subserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE subserver (v.0.18)`; + - на `HOME` видны реальное значение сабсервера или `subserver not set`, реальное значение логина или `login not set`, при отсутствии секрета строка `secret not set`, а также `STATUS`, верхний правый блок с процентом батареи, иконкой батареи и индикатором Wi-Fi, кнопка баланса, строка `SHiNE: ...`, кнопка `SETTINGS` уменьшенной ширины у правого края и нижняя подпись `SHiNE subserver (v.0.18)`; + - справа от строки логина виден индикатор статуса Solana-аккаунта: + - зелёный, если ключи совпали; + - красный, если mismatch; + - белый контур, если пользователь не найден; + - если статус не зелёный, рядом выводится краткое текстовое пояснение; - строка Wi-Fi на `HOME` корректно показывает одно из состояний: - `Wi-Fi (not configured) not configured` - `Wi-Fi () disconnected` - `Wi-Fi () connected` + - строка `SHiNE:` корректно показывает одно из состояний: + - `connected` + - `account not configured` + - `unavailable` - пока открыт `HOME`, статус сам обновляется без перехода на другие экраны; + - баланс обновляется кнопкой по нажатию; + - если логин зарегистрирован и секрет/сабсервер заданы, устройство: + - читает `user_pda` через Solana RPC; + - сверяет `root`, `blockchain`, `device` и `subserver` session type `100`; + - поднимает WebSocket-сессию с сервером SHiNE; + - шлёт `Ping` раз в минуту; - кнопка `SETTINGS` открывает `SETTINGS_MENU`; - свайп влево на `HOME` открывает `SETTINGS_MENU`; + - если пользователь не найден в Solana PDA, слева снизу появляется `REGISTER ACCOUNT`; + - `REGISTER ACCOUNT` открывает экран-заглушку; - в `SETTINGS_MENU` сначала видны только `Wi-Fi` и `Server`; - обе видимые карточки меню одного цвета; - свайп вверх показывает `Server` и `Account`; @@ -83,4 +100,5 @@ - свайп вправо из `ACCOUNT_SUBSERVER_SCREEN` и `ACCOUNT_SECRET_SCREEN` возвращает в `ACCOUNT_SCREEN`; - если во время реального свайпа палец проходит по кнопке, это не должно открывать кнопку как обычный `click`. - Ожидаемый результат: новый скетч даёт чистый навигационный каркас и уже умеет настраивать Wi-Fi и серверные адреса на самой ESP32. +- Дополнительно ожидается: `HOME` уже показывает реальный Solana/WS-статус сабсервера, а отсутствие пользователя в Solana заметно сразу без перехода в настройки. - Статус: pending diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md index 82a42fc..5261000 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/reference/shine_subserver_ui_nav_minimal_spec.md @@ -7,19 +7,17 @@ Этот прототип проверяет базовую механику экранов, крупных кнопок, свайпов, первичную настройку Wi-Fi и настройку серверных адресов через общий экран редактирования текста. На этом этапе отсутствуют: -- логика серверной проверки доступности; - логин/пароль учётной записи SHiNE; - PIN; - кошелёк; - QR; -- баланс; - регистрация; -- PDA и транзакции; +- PDA update/create транзакции; - входящие запросы. ## Экраны -Прототип содержит 8 экранов: +Прототип содержит 10 экранов: - `HOME` - `SETTINGS_MENU` - `WIFI_SCREEN` @@ -27,13 +25,21 @@ - `ACCOUNT_SCREEN` - `ACCOUNT_SUBSERVER_SCREEN` - `ACCOUNT_SECRET_SCREEN` +- `SECRET_SHOW_SCREEN` +- `SECRET_GENERATE_*` - `TEXT_EDIT_SCREEN` +- `REGISTER_ACCOUNT_PLACEHOLDER` ## HOME Показывает: - сверху слева значение сабсервера или `subserver not set`; - ниже значение логина или `login not set`; +- справа от строки логина индикатор статуса Solana-аккаунта: + - зелёный — все ключи совпадают; + - красный — есть mismatch; + - белый контур — пользователь не найден в Solana PDA; +- рядом с индикатором краткий текст ошибки, если статус не зелёный; - третьей строкой `secret not set`, если секрет ещё не помечен как установленный; - сверху справа один ряд индикаторов: - процент батареи; @@ -42,6 +48,8 @@ - по центру крупный текст `STATUS`; - одна строка Wi-Fi вида `Wi-Fi () connected/disconnected`; - кнопка баланса вида `Balance: ` или `Balance: failed to load`, по нажатию выполняет повторный запрос; +- строка `SHiNE: connected/account not configured/unavailable`; +- при отсутствии пользователя в Solana PDA слева снизу появляется кнопка `REGISTER ACCOUNT`; - снизу кнопку `SETTINGS`, уменьшенную примерно до половины ширины экрана и сдвинутую к правому краю. - внизу на тёмной полосе подпись `SHiNE subserver (v.0.18)`. @@ -51,9 +59,20 @@ - `Wi-Fi () connected` Переходы: +- кнопка `REGISTER ACCOUNT` -> `REGISTER_ACCOUNT_PLACEHOLDER`, только если пользователь не найден; - кнопка `SETTINGS` -> `SETTINGS_MENU`; - свайп влево -> `SETTINGS_MENU`. +Фоновая логика: +- пока открыт `HOME`, экран сам обновляется примерно раз в секунду; +- при наличии `login + secret + subserver` и Wi-Fi устройство читает Solana `user_pda` напрямую через RPC; +- сравниваются `root key`, `blockchain key`, `device key` и `subserver` session-запись типа `100`; +- для строки `SHiNE:` устройство держит отдельную WebSocket-сессию с сервером SHiNE: + - авторизация через `AuthChallenge/CreateAuthSession` или `SessionChallenge/SessionLogin`; + - session key = публичный `subserver key`; + - подтверждение создания сессии подписывается `device key`; + - heartbeat выполняется `Ping` раз в минуту. + ## SETTINGS_MENU Показывает вертикальное меню из 3 пунктов: @@ -217,6 +236,15 @@ - кнопки `SAVE`, `CANCEL`, `DEL`, `CLR`; - большую экранную клавиатуру. +## REGISTER_ACCOUNT_PLACEHOLDER + +Временный экран-заглушка регистрации. + +Показывает: +- заголовок `REGISTER ACCOUNT`; +- сообщение, что регистрационный flow пока не реализован; +- кнопку `BACK`. + ## Клавиатура Клавиатура единая для всех текстовых вводов. diff --git a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino index 7858ea3..1640db1 100644 --- a/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino +++ b/ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/test-device/test_sketches/lvgl_nav_minimal_test/lvgl_nav_minimal_test.ino @@ -10,6 +10,8 @@ #include #include #include +#include +#include #define XPOWERS_CHIP_AXP2101 #include "XPowersLib.h" #include "shine_secret_generation.h" @@ -37,12 +39,29 @@ #define WIFI_RECONNECT_FAST_MS 10000 #define WIFI_RECONNECT_SLOW_MS 30000 #define WIFI_RECONNECT_SLOW_AFTER_MS 90000 +#define HOME_REFRESH_MS 1000 +#define ACCOUNT_CHECK_RETRY_MS 15000 +#define SHINE_PING_INTERVAL_MS 60000 +#define SHINE_RECONNECT_MS 10000 +#define SHINE_RPC_TIMEOUT_MS 9000 #define TEXT_EDIT_PANEL_X 10 #define TEXT_EDIT_PANEL_Y 112 #define TEXT_EDIT_PANEL_W 460 #define TEXT_EDIT_PANEL_H 330 #define TEST_VERSION "SHiNE subserver (v.0.18)" +static const char *kShineUsersProgramId = "FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm"; +static const char *kShineUsersUserPdaSeedPrefix = "user_login="; +static const char *kProgramDerivedAddressMarker = "ProgramDerivedAddress"; +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 uint8_t kSessionTypeSubserver = 100; + enum Screen { SCREEN_HOME, SCREEN_SETTINGS_MENU, @@ -56,6 +75,7 @@ enum Screen { SCREEN_SECRET_GENERATE_RUNNING, SCREEN_SECRET_GENERATE_CANCEL_CONFIRM, SCREEN_TEXT_EDIT, + SCREEN_REGISTER_ACCOUNT_PLACEHOLDER, }; enum SwipeDirection { @@ -93,6 +113,7 @@ enum ActionId { ACTION_BACK_SECRET_MENU, ACTION_BACK_ACCOUNT, ACTION_REFRESH_BALANCE, + ACTION_REGISTER_ACCOUNT, ACTION_EDITOR_SAVE, ACTION_EDITOR_CANCEL, }; @@ -118,6 +139,39 @@ enum KeyboardMode { KEYBOARD_MODE_SYMBOLS, }; +enum AccountPdaStatus { + ACCOUNT_PDA_UNKNOWN, + ACCOUNT_PDA_OK, + ACCOUNT_PDA_NOT_FOUND, + ACCOUNT_PDA_MISMATCH, +}; + +struct ShinePdaSessionRecord { + uint8_t sessionType = 0; + uint8_t sessionVersion = 0; + String sessionName; + uint8_t sessionPubKey32[32] = {}; +}; + +struct ShinePdaUserState { + bool found = false; + String login; + bool isServer = false; + String serverAddress; + uint8_t rootKey32[32] = {}; + uint8_t deviceKey32[32] = {}; + uint8_t blockchainKey32[32] = {}; + std::vector sessions; +}; + +struct SimpleWebSocketClient { + WiFiClientSecure client; + bool connected = false; + String host; + String path; + uint16_t port = 0; +}; + static const char *kMenuItems[] = {"Wi-Fi", "Server", "Account"}; static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]); @@ -175,6 +229,21 @@ static unsigned long gWifiDisconnectedSinceMs = 0; static unsigned long gWifiLastReconnectAttemptMs = 0; static wl_status_t gLastWifiStatus = WL_IDLE_STATUS; static bool gPowerReady = false; +static AccountPdaStatus gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; +static String gAccountPdaStatusMessage = "Account not checked"; +static bool gAccountCheckPending = true; +static unsigned long gLastAccountCheckMs = 0; +static bool gShowRegisterAccountButton = false; +static String gShineStatusLine = "SHiNE: account not configured"; +static String gShineSessionId; +static String gShineSessionKey; +static String gShineStoragePwd; +static bool gShineAuthenticated = false; +static unsigned long gLastShineAttemptMs = 0; +static unsigned long gLastShinePingMs = 0; +static uint32_t gWsRequestCounter = 1; +static int64_t gShineServerTimeOffsetMs = 0; +static SimpleWebSocketClient gShineWs; struct DerivedKeyInfo { String title; @@ -245,6 +314,36 @@ static void initPowerManagement(); static int batteryPercentValue(); static int wifiSignalLevel(); static void drawTopStatusIndicators(); +static void markAccountStateDirty(); +static void clearShineSessionState(bool clearStoredSession); +static void saveShineSessionPrefs(); +static String normalizeLoginValue(const String &value); +static bool base58ToFixed32(const String &value, uint8_t out[32]); +static bool base64DecodeStd(const String &value, std::vector &out); +static String bytesToBase64String(const uint8_t *data, size_t len); +static String jsonEscape(const String &value); +static bool jsonStringField(const String &json, const String &field, String &valueOut); +static bool jsonBoolField(const String &json, const String &field, bool &valueOut); +static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut); +static String shineWsUrl(); +static String shineHomeLine(); +static String balanceHomeLine(); +static uint64_t shineNowMs(); +static String buildSessionKeyStringFromPublicBase64(const String &pubB64); +static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); +static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]); +static bool findProgramAddress(const std::vector> &seeds, const char *programIdB58, uint8_t out32[32]); +static bool readShineUserPda(const String &login, ShinePdaUserState &outState, String &errorOut); +static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut); +static void refreshAccountPdaStatus(); +static void manageAccountPdaRefresh(); +static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut); +static void closeWebSocket(SimpleWebSocketClient &ws); +static bool wsSendText(SimpleWebSocketClient &ws, const String &payload); +static bool wsReadTextFrame(SimpleWebSocketClient &ws, String &messageOut, uint32_t timeoutMs); +static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const String &payloadJson, String &responseOut, uint32_t timeoutMs = SHINE_RPC_TIMEOUT_MS); +static bool ensureShineSessionAuthenticated(String &errorOut); +static void manageShineConnection(); static void lvglFlushCb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *colorP) { uint32_t w = area->x2 - area->x1 + 1; @@ -430,6 +529,164 @@ static String base58From32(const uint8_t *data32) { return String(out); } +static bool base58ToFixed32(const String &value, uint8_t out[32]) { + size_t outLen = 32; + String error; + if (!shineSecretBase58Decode(value.c_str(), out, &outLen, 32, error)) { + return false; + } + return outLen == 32; +} + +static bool base64DecodeStd(const String &value, std::vector &out) { + out.clear(); + String normalized = value; + normalized.trim(); + if (normalized.isEmpty()) { + return false; + } + size_t maxLen = ((normalized.length() + 3) / 4) * 3 + 4; + out.resize(maxLen); + size_t outLen = 0; + if (mbedtls_base64_decode(out.data(), out.size(), &outLen, + reinterpret_cast(normalized.c_str()), + normalized.length()) != 0) { + out.clear(); + return false; + } + out.resize(outLen); + return true; +} + +static String bytesToBase64String(const uint8_t *data, size_t len) { + return base64Std(data, len); +} + +static String normalizeLoginValue(const String &value) { + String out = value; + out.trim(); + out.toLowerCase(); + return out; +} + +static String jsonEscape(const String &value) { + String out; + out.reserve(value.length() + 8); + for (size_t i = 0; i < value.length(); ++i) { + char ch = value.charAt(i); + switch (ch) { + case '\\': out += "\\\\"; break; + case '"': out += "\\\""; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: out += ch; break; + } + } + return out; +} + +static bool jsonStringField(const String &json, const String &field, String &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 quote = json.indexOf('"', colon + 1); + if (quote < 0) { + return false; + } + String out; + bool escape = false; + for (int i = quote + 1; i < (int)json.length(); ++i) { + char ch = json.charAt(i); + if (escape) { + switch (ch) { + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + default: out += ch; break; + } + escape = false; + continue; + } + if (ch == '\\') { + escape = true; + continue; + } + if (ch == '"') { + valueOut = out; + return true; + } + out += ch; + } + return false; +} + +static bool jsonBoolField(const String &json, const String &field, bool &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() && isspace((unsigned char)json[pos])) { + pos++; + } + if (json.startsWith("true", pos)) { + valueOut = true; + return true; + } + if (json.startsWith("false", pos)) { + valueOut = false; + return true; + } + return false; +} + +static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) { + hostOut = ""; + pathOut = "/"; + portOut = 0; + secureOut = false; + + 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); + pathOut = slash >= 0 ? url.substring(slash) : "/"; + 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(); + if (pathOut.isEmpty()) { + pathOut = "/"; + } + return hostOut.length() > 0 && portOut > 0; +} + static String subserverKeySuffix() { String name = gSubserverValue; name.trim(); @@ -476,6 +733,83 @@ static void refreshDerivedKeys() { deriveKeyPairFromSecretSuffix(gSecretBytes, subserverKeySuffix(), gSubserverPubB58, gSubserverPrivB58); } +static bool deriveKeypairFromSeed32(const uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { + return crypto_sign_seed_keypair(pub32, sec64, seed32) == 0; +} + +static bool deriveSeedKeypairFromBase58(const String &seedB58, uint8_t seed32[32], uint8_t pub32[32], uint8_t sec64[64]) { + if (!base58ToFixed32(seedB58, seed32)) { + return false; + } + return deriveKeypairFromSeed32(seed32, pub32, sec64); +} + +static void saveShineSessionPrefs() { + if (gShineSessionId.isEmpty() || gShineSessionKey.isEmpty()) { + gPrefs.remove("shine_sess_id"); + gPrefs.remove("shine_sess_key"); + gPrefs.remove("shine_store_pwd"); + return; + } + gPrefs.putString("shine_sess_id", gShineSessionId); + gPrefs.putString("shine_sess_key", gShineSessionKey); + gPrefs.putString("shine_store_pwd", gShineStoragePwd); +} + +static void closeWebSocket(SimpleWebSocketClient &ws) { + if (ws.client.connected()) { + ws.client.stop(); + } + ws.connected = false; +} + +static void clearShineSessionState(bool clearStoredSession) { + closeWebSocket(gShineWs); + gShineAuthenticated = false; + gLastShinePingMs = 0; + if (clearStoredSession) { + gShineSessionId = ""; + gShineSessionKey = ""; + gShineStoragePwd = ""; + saveShineSessionPrefs(); + } +} + +static void markAccountStateDirty() { + gAccountCheckPending = true; + gLastAccountCheckMs = 0; + gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; + gAccountPdaStatusMessage = "Account not checked"; + gShowRegisterAccountButton = false; + clearShineSessionState(true); + gShineStatusLine = "SHiNE: account not configured"; +} + +static bool findProgramAddress(const std::vector> &seeds, const char *programIdB58, uint8_t out32[32]) { + uint8_t programId[32] = {}; + if (!base58ToFixed32(String(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(), 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, + reinterpret_cast(kProgramDerivedAddressMarker), + strlen(kProgramDerivedAddressMarker)); + crypto_hash_sha256_final(&st, out32); + if (crypto_core_ed25519_is_valid_point(out32) == 0) { + return true; + } + } + return false; +} + static bool isHttpUrl(const String &url) { return url.startsWith("http://") || url.startsWith("https://"); } @@ -581,6 +915,849 @@ static bool refreshWalletBalance(String &messageOut) { return true; } +static String balanceHomeLine() { + return gBalanceStatusMessage; +} + +static String buildSessionKeyStringFromPublicBase64(const String &pubB64) { + return String("ed25519/") + pubB64; +} + +static String shineWsUrl() { + String raw = gShineServerUrl; + raw.trim(); + if (raw.isEmpty()) { + return ""; + } + if (raw.startsWith("ws://") || raw.startsWith("wss://")) { + return raw; + } + if (raw.startsWith("http://")) { + String tail = raw.substring(strlen("http://")); + if (tail.indexOf('/') < 0) { + tail += "/ws"; + } else if (tail.endsWith("/")) { + tail += "ws"; + } + return "ws://" + tail; + } + if (raw.startsWith("https://")) { + String tail = raw.substring(strlen("https://")); + if (tail.indexOf('/') < 0) { + tail += "/ws"; + } else if (tail.endsWith("/")) { + tail += "ws"; + } + return "wss://" + tail; + } + if (raw.indexOf('/') < 0) { + return "wss://" + raw + "/ws"; + } + return "wss://" + raw; +} + +static String shineHomeLine() { + return gShineStatusLine; +} + +static uint64_t shineNowMs() { + int64_t value = (int64_t)millis() + gShineServerTimeOffsetMs; + return value > 0 ? (uint64_t)value : (uint64_t)millis(); +} + +static bool parseShineUserPdaBytes(const std::vector &bytes, ShinePdaUserState &outState, String &errorOut) { + outState = ShinePdaUserState{}; + errorOut = ""; + if (bytes.size() < 64) { + errorOut = "PDA too short"; + return false; + } + + size_t offset = 0; + auto ensure = [&](size_t need) -> bool { + return offset + need <= bytes.size(); + }; + auto readU8 = [&](uint8_t &value) -> bool { + if (!ensure(1)) return false; + value = bytes[offset++]; + return true; + }; + auto readU16 = [&](uint16_t &value) -> bool { + if (!ensure(2)) return false; + value = (uint16_t)bytes[offset] | ((uint16_t)bytes[offset + 1] << 8); + offset += 2; + return true; + }; + auto readU32 = [&](uint32_t &value) -> bool { + if (!ensure(4)) return false; + value = (uint32_t)bytes[offset] + | ((uint32_t)bytes[offset + 1] << 8) + | ((uint32_t)bytes[offset + 2] << 16) + | ((uint32_t)bytes[offset + 3] << 24); + offset += 4; + return true; + }; + auto readU64 = [&](uint64_t &value) -> bool { + if (!ensure(8)) return false; + value = 0; + for (int i = 0; i < 8; ++i) { + value |= ((uint64_t)bytes[offset + i]) << (8 * i); + } + offset += 8; + return true; + }; + auto readBytes = [&](uint8_t *dst, size_t len) -> bool { + if (!ensure(len)) return false; + memcpy(dst, bytes.data() + offset, len); + offset += len; + return true; + }; + auto readStringU8 = [&](String &value) -> bool { + uint8_t len = 0; + if (!readU8(len) || !ensure(len)) return false; + value = ""; + value.reserve(len); + for (uint8_t i = 0; i < len; ++i) { + value += (char)bytes[offset + i]; + } + offset += len; + return true; + }; + + if (!ensure(5) || memcmp(bytes.data(), "SHiNE", 5) != 0) { + errorOut = "Bad PDA magic"; + return false; + } + offset += 5; + uint8_t version = 0; + uint8_t flags = 0; + uint16_t recordLen = 0; + uint64_t ignoreU64 = 0; + uint32_t ignoreU32 = 0; + if (!readU8(version) || !readU8(flags) || !readU16(recordLen)) { + errorOut = "Bad PDA header"; + return false; + } + if (recordLen < 73 || recordLen > bytes.size()) { + errorOut = "Bad PDA record len"; + return false; + } + offset = 9; + if (!readU64(ignoreU64) || !readU64(ignoreU64) || !readU32(ignoreU32)) { + errorOut = "Bad PDA fixed fields"; + return false; + } + offset += 32; // prev hash + if (!readStringU8(outState.login)) { + errorOut = "Bad PDA login"; + return false; + } + uint8_t blocksCount = 0; + if (!readU8(blocksCount)) { + errorOut = "Bad PDA blocks count"; + return false; + } + + for (uint8_t i = 0; i < blocksCount; ++i) { + uint8_t blockType = 0; + uint8_t blockVersion = 0; + if (!readU8(blockType) || !readU8(blockVersion)) { + errorOut = "Bad PDA block header"; + return false; + } + if (blockType == kBlockTypeRootKey) { + if (!readBytes(outState.rootKey32, 32)) { + errorOut = "Bad root key block"; + return false; + } + continue; + } + if (blockType == kBlockTypeDeviceKey) { + if (!readBytes(outState.deviceKey32, 32)) { + errorOut = "Bad device key block"; + return false; + } + continue; + } + if (blockType == kBlockTypeBlockchainRegistry) { + uint8_t count = 0; + if (!readU8(count)) { + errorOut = "Bad blockchain count"; + return false; + } + for (uint8_t j = 0; j < count; ++j) { + uint8_t blockchainType = 0; + String blockchainName; + uint8_t blockchainKey[32] = {}; + uint64_t paidLimit = 0; + uint64_t usedBytes = 0; + uint32_t lastBlockNumber = 0; + uint8_t lastBlockHash[32] = {}; + uint8_t lastBlockSig[64] = {}; + uint8_t arweavePresent = 0; + String arweaveTxId; + if (!readU8(blockchainType) + || !readStringU8(blockchainName) + || !readBytes(blockchainKey, 32) + || !readU64(paidLimit) + || !readU64(usedBytes) + || !readU32(lastBlockNumber) + || !readBytes(lastBlockHash, 32) + || !readBytes(lastBlockSig, 64) + || !readU8(arweavePresent)) { + errorOut = "Bad blockchain block"; + return false; + } + if (arweavePresent == 1 && !readStringU8(arweaveTxId)) { + errorOut = "Bad arweave field"; + return false; + } + if (j == 0) { + memcpy(outState.blockchainKey32, blockchainKey, 32); + } + } + continue; + } + if (blockType == kBlockTypeServerProfile) { + uint8_t isServer = 0; + if (!readU8(isServer)) { + errorOut = "Bad server profile"; + return false; + } + outState.isServer = isServer == 1; + if (outState.isServer) { + uint8_t addressFormatType = 0; + uint8_t addressFormatVersion = 0; + uint8_t syncCount = 0; + if (!readU8(addressFormatType) + || !readU8(addressFormatVersion) + || !readStringU8(outState.serverAddress) + || !readU8(syncCount)) { + errorOut = "Bad server address"; + return false; + } + for (uint8_t j = 0; j < syncCount; ++j) { + String ignoreSync; + if (!readStringU8(ignoreSync)) { + errorOut = "Bad sync_servers"; + return false; + } + } + } + continue; + } + if (blockType == kBlockTypeAccessServers) { + uint8_t accessCount = 0; + if (!readU8(accessCount)) { + errorOut = "Bad access servers"; + return false; + } + for (uint8_t j = 0; j < accessCount; ++j) { + String ignoreAccess; + if (!readStringU8(ignoreAccess)) { + errorOut = "Bad access server item"; + return false; + } + } + continue; + } + if (blockType == kBlockTypeSessions) { + uint8_t sessionsMode = 0; + uint8_t sessionsCount = 0; + if (!readU8(sessionsMode) || !readU8(sessionsCount)) { + errorOut = "Bad sessions block"; + return false; + } + outState.sessions.clear(); + for (uint8_t j = 0; j < sessionsCount; ++j) { + ShinePdaSessionRecord rec; + if (!readU8(rec.sessionType) + || !readU8(rec.sessionVersion) + || !readStringU8(rec.sessionName) + || !readBytes(rec.sessionPubKey32, 32)) { + errorOut = "Bad session item"; + return false; + } + outState.sessions.push_back(rec); + } + continue; + } + if (blockType == kBlockTypeTrustedState) { + uint8_t trustedCount = 0; + if (!readU8(trustedCount)) { + errorOut = "Bad trusted block"; + return false; + } + continue; + } + errorOut = "Unknown PDA block"; + return false; + } + + outState.found = true; + return true; +} + +static bool readShineUserPda(const String &login, ShinePdaUserState &outState, String &errorOut) { + outState = ShinePdaUserState{}; + errorOut = ""; + String cleanLogin = normalizeLoginValue(login); + if (cleanLogin.isEmpty()) { + errorOut = "Login not set"; + return false; + } + if (gSolanaRpcUrl.isEmpty()) { + errorOut = "Solana RPC not set"; + return false; + } + + uint8_t userPda[32] = {}; + std::vector> seeds = { + std::vector((const uint8_t *)kShineUsersUserPdaSeedPrefix, + (const uint8_t *)kShineUsersUserPdaSeedPrefix + strlen(kShineUsersUserPdaSeedPrefix)), + std::vector((const uint8_t *)cleanLogin.c_str(), + (const uint8_t *)cleanLogin.c_str() + cleanLogin.length()), + }; + if (!findProgramAddress(seeds, kShineUsersProgramId, userPda)) { + errorOut = "Cannot derive user PDA"; + return false; + } + + String pdaB58 = base58From32(userPda); + int code = -1; + String payload; + String req = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"getAccountInfo\",\"params\":[\"" + pdaB58 + "\",{\"encoding\":\"base64\",\"commitment\":\"confirmed\"}]}"; + if (!httpPostJson(gSolanaRpcUrl, req, code, payload)) { + errorOut = "Solana RPC unavailable"; + return false; + } + if (payload.indexOf("\"value\":null") >= 0) { + outState.found = false; + return true; + } + String dataB64; + if (!jsonStringField(payload, "data", dataB64)) { + errorOut = "PDA base64 missing"; + return false; + } + std::vector raw; + if (!base64DecodeStd(dataB64, raw)) { + errorOut = "PDA base64 decode failed"; + return false; + } + return parseShineUserPdaBytes(raw, outState, errorOut); +} + +static void refreshAccountPdaStatus() { + gAccountCheckPending = false; + gShowRegisterAccountButton = false; + + if (gLoginValue.isEmpty() || !gSecretConfigured) { + gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; + gAccountPdaStatusMessage = "account not configured"; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + ShinePdaUserState pdaState; + String error; + if (!readShineUserPda(gLoginValue, pdaState, error)) { + gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; + gAccountPdaStatusMessage = error.isEmpty() ? "solana check failed" : error; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " unavailable"; + clearShineSessionState(false); + if (error == "Solana RPC unavailable") { + gAccountCheckPending = true; + } + return; + } + + if (!pdaState.found) { + gAccountPdaStatus = ACCOUNT_PDA_NOT_FOUND; + gAccountPdaStatusMessage = "user not found"; + gShowRegisterAccountButton = true; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + uint8_t rootPub[32] = {}; + uint8_t devicePub[32] = {}; + uint8_t blockchainPub[32] = {}; + if (!base58ToFixed32(gRootPubB58, rootPub) + || !base58ToFixed32(gDevicePubB58, devicePub) + || !base58ToFixed32(gBlockchainPubB58, blockchainPub)) { + gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; + gAccountPdaStatusMessage = "local keys invalid"; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + String mismatch; + if (memcmp(rootPub, pdaState.rootKey32, 32) != 0) { + mismatch = "root key mismatch"; + } else if (memcmp(blockchainPub, pdaState.blockchainKey32, 32) != 0) { + mismatch = "blockchain key mismatch"; + } else if (memcmp(devicePub, pdaState.deviceKey32, 32) != 0) { + mismatch = "device key mismatch"; + } else if (gSubserverValue.isEmpty()) { + mismatch = "subserver not set"; + } else { + bool foundSession = false; + bool sessionMismatch = false; + for (const auto &session : pdaState.sessions) { + if (session.sessionType == kSessionTypeSubserver && session.sessionName == gSubserverValue) { + foundSession = true; + if (gSubserverPubB58.isEmpty()) { + sessionMismatch = true; + } else { + uint8_t subserverPub[32] = {}; + if (!base58ToFixed32(gSubserverPubB58, subserverPub) + || memcmp(subserverPub, session.sessionPubKey32, 32) != 0) { + sessionMismatch = true; + } + } + break; + } + } + if (!foundSession) { + mismatch = "subserver not in PDA"; + } else if (sessionMismatch) { + mismatch = "subserver key mismatch"; + } + } + + if (!mismatch.isEmpty()) { + gAccountPdaStatus = ACCOUNT_PDA_MISMATCH; + gAccountPdaStatusMessage = mismatch; + gShineStatusLine = String("SHiNE: ") + (gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl) + " account not configured"; + clearShineSessionState(false); + return; + } + + gAccountPdaStatus = ACCOUNT_PDA_OK; + gAccountPdaStatusMessage = "ok"; +} + +static void manageAccountPdaRefresh() { + if (!gAccountCheckPending) { + return; + } + if (WiFi.status() != WL_CONNECTED) { + return; + } + unsigned long now = millis(); + if (gLastAccountCheckMs != 0 && now - gLastAccountCheckMs < ACCOUNT_CHECK_RETRY_MS) { + return; + } + gLastAccountCheckMs = now; + refreshAccountPdaStatus(); +} + +static bool waitForClientBytes(WiFiClientSecure &client, size_t need, uint32_t timeoutMs) { + unsigned long start = millis(); + while (client.connected() && millis() - start < timeoutMs) { + if ((size_t)client.available() >= need) { + return true; + } + delay(2); + } + return false; +} + +static bool ensureWebSocketConnected(SimpleWebSocketClient &ws, const String &url, String &errorOut) { + errorOut = ""; + if (ws.connected && ws.client.connected()) { + return true; + } + + closeWebSocket(ws); + + bool secure = false; + if (!parseUrlHostPortPath(url, ws.host, ws.port, ws.path, secure)) { + errorOut = "bad WS url"; + return false; + } + + ws.client.setInsecure(); + ws.client.setTimeout(5); + if (!ws.client.connect(ws.host.c_str(), ws.port)) { + errorOut = "WS connect failed"; + return false; + } + + uint8_t keyRaw[16]; + for (size_t i = 0; i < sizeof(keyRaw); ++i) { + keyRaw[i] = (uint8_t)esp_random(); + } + String secKey = bytesToBase64String(keyRaw, sizeof(keyRaw)); + String request = + String("GET ") + ws.path + " HTTP/1.1\r\n" + + "Host: " + ws.host + ":" + String(ws.port) + "\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Version: 13\r\n" + + "Sec-WebSocket-Key: " + secKey + "\r\n" + + "User-Agent: SHiNE-ESP32\r\n\r\n"; + ws.client.print(request); + + String statusLine = ws.client.readStringUntil('\n'); + if (statusLine.indexOf("101") < 0) { + errorOut = "WS handshake failed"; + closeWebSocket(ws); + return false; + } + + while (ws.client.connected()) { + String headerLine = ws.client.readStringUntil('\n'); + headerLine.trim(); + if (headerLine.isEmpty()) { + break; + } + } + + ws.connected = true; + return true; +} + +static bool wsSendFrame(SimpleWebSocketClient &ws, uint8_t opcode, const uint8_t *data, size_t len) { + if (!ws.connected || !ws.client.connected()) { + return false; + } + uint8_t header[10]; + size_t headerLen = 0; + header[headerLen++] = 0x80 | (opcode & 0x0F); + if (len < 126) { + header[headerLen++] = 0x80 | (uint8_t)len; + } else { + header[headerLen++] = 0x80 | 126; + header[headerLen++] = (uint8_t)((len >> 8) & 0xFF); + header[headerLen++] = (uint8_t)(len & 0xFF); + } + uint8_t mask[4]; + for (int i = 0; i < 4; ++i) { + mask[i] = (uint8_t)esp_random(); + header[headerLen++] = mask[i]; + } + if (ws.client.write(header, headerLen) != (int)headerLen) { + closeWebSocket(ws); + return false; + } + for (size_t i = 0; i < len; ++i) { + uint8_t byte = data ? (data[i] ^ mask[i % 4]) : mask[i % 4]; + if (ws.client.write(&byte, 1) != 1) { + closeWebSocket(ws); + return false; + } + } + return true; +} + +static bool wsSendText(SimpleWebSocketClient &ws, const String &payload) { + return wsSendFrame(ws, 0x1, reinterpret_cast(payload.c_str()), payload.length()); +} + +static bool wsReadTextFrame(SimpleWebSocketClient &ws, String &messageOut, uint32_t timeoutMs) { + messageOut = ""; + while (ws.connected && ws.client.connected()) { + if (!waitForClientBytes(ws.client, 2, timeoutMs)) { + closeWebSocket(ws); + return false; + } + uint8_t hdr[2]; + if (ws.client.read(hdr, 2) != 2) { + closeWebSocket(ws); + return false; + } + uint8_t opcode = hdr[0] & 0x0F; + bool masked = (hdr[1] & 0x80) != 0; + uint64_t payloadLen = hdr[1] & 0x7F; + if (payloadLen == 126) { + if (!waitForClientBytes(ws.client, 2, timeoutMs)) { + closeWebSocket(ws); + return false; + } + uint8_t ext[2]; + if (ws.client.read(ext, 2) != 2) { + closeWebSocket(ws); + return false; + } + payloadLen = ((uint16_t)ext[0] << 8) | ext[1]; + } else if (payloadLen == 127) { + closeWebSocket(ws); + return false; + } + uint8_t mask[4] = {}; + if (masked) { + if (!waitForClientBytes(ws.client, 4, timeoutMs) || ws.client.read(mask, 4) != 4) { + closeWebSocket(ws); + return false; + } + } + if (payloadLen > 4096) { + closeWebSocket(ws); + return false; + } + std::vector payload(payloadLen); + if (payloadLen > 0) { + if (!waitForClientBytes(ws.client, payloadLen, timeoutMs) + || ws.client.read(payload.data(), payloadLen) != (int)payloadLen) { + closeWebSocket(ws); + return false; + } + if (masked) { + for (size_t i = 0; i < payloadLen; ++i) { + payload[i] ^= mask[i % 4]; + } + } + } + + if (opcode == 0x8) { + closeWebSocket(ws); + return false; + } + if (opcode == 0x9) { + wsSendFrame(ws, 0xA, payload.data(), payload.size()); + continue; + } + if (opcode == 0xA) { + continue; + } + if (opcode == 0x1) { + messageOut = ""; + messageOut.reserve(payload.size()); + for (uint8_t byte : payload) { + messageOut += (char)byte; + } + return true; + } + } + closeWebSocket(ws); + return false; +} + +static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const String &payloadJson, String &responseOut, uint32_t timeoutMs) { + responseOut = ""; + String requestId = String("esp32-") + String(gWsRequestCounter++); + String request = String("{\"op\":\"") + op + "\",\"requestId\":\"" + requestId + "\",\"payload\":" + payloadJson + "}"; + if (!wsSendText(ws, request)) { + return false; + } + unsigned long start = millis(); + while (millis() - start < timeoutMs) { + String frame; + if (!wsReadTextFrame(ws, frame, timeoutMs)) { + return false; + } + if (frame.indexOf("\"requestId\":\"" + requestId + "\"") >= 0 && frame.indexOf("\"op\":\"" + op + "\"") >= 0) { + responseOut = frame; + return true; + } + } + return false; +} + +static bool ensureShineSessionAuthenticated(String &errorOut) { + errorOut = ""; + if (WiFi.status() != WL_CONNECTED) { + errorOut = "Wi-Fi disconnected"; + return false; + } + if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { + errorOut = "account not configured"; + return false; + } + if (gAccountPdaStatus != ACCOUNT_PDA_OK) { + errorOut = "account not ready"; + return false; + } + + String wsUrl = shineWsUrl(); + if (wsUrl.isEmpty()) { + errorOut = "shine server not set"; + return false; + } + if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) { + return false; + } + + { + String pingResp; + if (!shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) { + errorOut = "Ping failed"; + return false; + } + uint64_t pingStatus = 0; + uint64_t serverTs = 0; + if (!jsonInt64Field(pingResp, "status", pingStatus) || pingStatus != 200 || !jsonInt64Field(pingResp, "ts", serverTs)) { + errorOut = "Ping rejected"; + return false; + } + gShineServerTimeOffsetMs = (int64_t)serverTs - (int64_t)millis(); + } + + uint8_t deviceSeed[32] = {}; + uint8_t devicePub[32] = {}; + uint8_t deviceSec[64] = {}; + uint8_t subSeed[32] = {}; + uint8_t subPub[32] = {}; + uint8_t subSec[64] = {}; + if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec) + || !deriveSeedKeypairFromBase58(gSubserverPrivB58, subSeed, subPub, subSec)) { + errorOut = "local key derive failed"; + return false; + } + + String sessionKey = buildSessionKeyStringFromPublicBase64(bytesToBase64String(subPub, 32)); + if (!gShineSessionKey.isEmpty() && gShineSessionKey != sessionKey) { + clearShineSessionState(true); + } + + if (!gShineSessionId.isEmpty()) { + String response; + if (shineWsRequest(gShineWs, + "SessionChallenge", + String("{\"sessionId\":\"") + jsonEscape(gShineSessionId) + "\"}", + response)) { + uint64_t statusCode = 0; + String nonce; + if (jsonInt64Field(response, "status", statusCode) && statusCode == 200 && jsonStringField(response, "nonce", nonce)) { + uint64_t timeMs = shineNowMs(); + String preimage = String("SESSION_LOGIN:") + gShineSessionId + ":" + String((unsigned long long)timeMs) + ":" + nonce; + uint8_t signature[64] = {}; + crypto_sign_ed25519_detached(signature, nullptr, + reinterpret_cast(preimage.c_str()), + preimage.length(), subSec); + String loginReq = String("{\"sessionId\":\"") + jsonEscape(gShineSessionId) + + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + + "\",\"timeMs\":" + String((unsigned long long)timeMs) + + ",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + + "\",\"clientInfo\":\"ESP32 subserver\"}"; + String loginResp; + if (shineWsRequest(gShineWs, "SessionLogin", loginReq, loginResp)) { + if (jsonInt64Field(loginResp, "status", statusCode) && statusCode == 200) { + String storagePwd; + if (jsonStringField(loginResp, "storagePwd", storagePwd)) { + gShineStoragePwd = storagePwd; + } + gShineSessionKey = sessionKey; + gShineAuthenticated = true; + saveShineSessionPrefs(); + return true; + } + } + } + } + clearShineSessionState(true); + if (!ensureWebSocketConnected(gShineWs, wsUrl, errorOut)) { + return false; + } + } + + if (gShineStoragePwd.isEmpty()) { + uint8_t storageRaw[32]; + for (size_t i = 0; i < sizeof(storageRaw); ++i) { + storageRaw[i] = (uint8_t)esp_random(); + } + gShineStoragePwd = bytesToBase64String(storageRaw, sizeof(storageRaw)); + } + + String authResp; + if (!shineWsRequest(gShineWs, "AuthChallenge", + String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}", + authResp)) { + errorOut = "AuthChallenge failed"; + return false; + } + uint64_t statusCode = 0; + String authNonce; + if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) { + errorOut = "AuthChallenge rejected"; + return false; + } + + uint64_t timeMs = shineNowMs(); + String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + sessionKey + ":" + gShineStoragePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce; + uint8_t signature[64] = {}; + crypto_sign_ed25519_detached(signature, nullptr, + reinterpret_cast(preimage.c_str()), + preimage.length(), deviceSec); + String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue) + + "\",\"sessionKey\":\"" + jsonEscape(sessionKey) + + "\",\"storagePwd\":\"" + jsonEscape(gShineStoragePwd) + + "\",\"timeMs\":" + String((unsigned long long)timeMs) + + ",\"authNonce\":\"" + jsonEscape(authNonce) + + "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32)) + + "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64)) + + "\",\"clientInfo\":\"ESP32 subserver\"}"; + String createResp; + if (!shineWsRequest(gShineWs, "CreateAuthSession", createReq, createResp)) { + errorOut = "CreateAuthSession failed"; + return false; + } + if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", gShineSessionId)) { + errorOut = "CreateAuthSession rejected"; + return false; + } + + gShineSessionKey = sessionKey; + gShineAuthenticated = true; + saveShineSessionPrefs(); + return true; +} + +static void manageShineConnection() { + String serverLabel = gShineServerUrl.isEmpty() ? "not set" : gShineServerUrl; + if (gLoginValue.isEmpty() || !gSecretConfigured || gSubserverValue.isEmpty()) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; + clearShineSessionState(false); + return; + } + if (gAccountPdaStatus != ACCOUNT_PDA_OK) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " account not configured"; + clearShineSessionState(false); + return; + } + if (WiFi.status() != WL_CONNECTED) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; + clearShineSessionState(false); + return; + } + + unsigned long now = millis(); + if (!gShineAuthenticated || !gShineWs.connected || !gShineWs.client.connected()) { + if (now - gLastShineAttemptMs < SHINE_RECONNECT_MS) { + return; + } + gLastShineAttemptMs = now; + String error; + if (ensureShineSessionAuthenticated(error)) { + gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; + gLastShinePingMs = now; + } else { + gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; + clearShineSessionState(false); + } + return; + } + + if (now - gLastShinePingMs >= SHINE_PING_INTERVAL_MS) { + String pingResp; + if (shineWsRequest(gShineWs, "Ping", "{\"ts\":0}", pingResp, SHINE_RPC_TIMEOUT_MS)) { + uint64_t statusCode = 0; + if (jsonInt64Field(pingResp, "status", statusCode) && statusCode == 200) { + gLastShinePingMs = now; + gShineStatusLine = String("SHiNE: ") + serverLabel + " connected"; + return; + } + } + gShineStatusLine = String("SHiNE: ") + serverLabel + " unavailable"; + clearShineSessionState(false); + } +} + static void upsertKnownWifi(const String &ssid, const String &password) { if (ssid.isEmpty()) { return; @@ -637,7 +1814,16 @@ static void loadPrefs() { } } refreshDerivedKeys(); + gShineSessionId = gPrefs.getString("shine_sess_id", ""); + gShineSessionKey = gPrefs.getString("shine_sess_key", ""); + gShineStoragePwd = gPrefs.getString("shine_store_pwd", ""); gBalanceStatusMessage = gDevicePubB58.isEmpty() ? "Balance: secret not set" : "Balance: tap to load"; + gAccountCheckPending = true; + gLastAccountCheckMs = 0; + gAccountPdaStatus = ACCOUNT_PDA_UNKNOWN; + gAccountPdaStatusMessage = "Account not checked"; + gShowRegisterAccountButton = false; + gShineStatusLine = "SHiNE: account not configured"; } static void saveWifiPrefs() { @@ -853,6 +2039,7 @@ static void clearSecretValue() { refreshDerivedKeys(); gBalanceStatusMessage = "Balance: secret not set"; saveAccountPrefs(); + markAccountStateDirty(); } static void setSecretValue(const uint8_t *bytes32) { @@ -864,6 +2051,7 @@ static void setSecretValue(const uint8_t *bytes32) { refreshDerivedKeys(); gBalanceStatusMessage = "Balance: tap to load"; saveAccountPrefs(); + markAccountStateDirty(); } static void updateWifiReconnectState() { @@ -1016,6 +2204,7 @@ static void applyEditorValue() { gSolanaRpcUrl = value; saveServerPrefs(); gServerStatusMessage = "Solana RPC saved"; + markAccountStateDirty(); showScreen(SCREEN_SERVER); return; } @@ -1024,6 +2213,8 @@ static void applyEditorValue() { gShineServerUrl = value; saveServerPrefs(); gServerStatusMessage = "Shine server saved"; + clearShineSessionState(false); + gShineStatusLine = "SHiNE: reconnect pending"; showScreen(SCREEN_SERVER); return; } @@ -1034,6 +2225,8 @@ static void applyEditorValue() { gLoginValue = value; if (gLoginValue != oldLogin) { clearSecretValue(); + } else { + markAccountStateDirty(); } saveAccountPrefs(); gAccountStatusMessage = gLoginValue.isEmpty() ? "Login cleared" : "Login saved"; @@ -1046,6 +2239,7 @@ static void applyEditorValue() { gSubserverValue = value; refreshDerivedKeys(); saveAccountPrefs(); + markAccountStateDirty(); gAccountStatusMessage = gSubserverValue.isEmpty() ? "Subserver cleared" : "Subserver saved"; showScreen(SCREEN_ACCOUNT); return; @@ -1215,6 +2409,9 @@ static void actionButtonCb(lv_event_t *event) { case ACTION_OPEN_SETTINGS: showScreen(SCREEN_SETTINGS_MENU); break; + case ACTION_REGISTER_ACCOUNT: + showScreen(SCREEN_REGISTER_ACCOUNT_PLACEHOLDER); + break; case ACTION_OPEN_WIFI: gWifiViewMode = WIFI_VIEW_OVERVIEW; showScreen(SCREEN_WIFI); @@ -1313,6 +2510,7 @@ static void actionButtonCb(lv_event_t *event) { gSubserverValue = "subserver1"; refreshDerivedKeys(); saveAccountPrefs(); + markAccountStateDirty(); gAccountStatusMessage = "Subserver set to subserver1"; showScreen(SCREEN_ACCOUNT); break; @@ -1430,6 +2628,41 @@ static void drawHome() { lv_obj_set_style_text_color(login, lv_color_hex(0xD5DEE7), 0); lv_obj_align_to(login, subserver, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 6); + lv_obj_t *accountDot = lv_obj_create(gRoot); + lv_obj_set_size(accountDot, 14, 14); + lv_obj_set_pos(accountDot, 250, 52); + lv_obj_set_style_radius(accountDot, 7, 0); + lv_obj_set_style_border_width(accountDot, 2, 0); + lv_obj_set_style_bg_opa(accountDot, LV_OPA_COVER, 0); + lv_obj_clear_flag(accountDot, LV_OBJ_FLAG_SCROLLABLE); + + uint32_t dotColor = 0x8AA2B7; + uint32_t dotBg = 0x08111B; + uint32_t statusColor = 0xAFC0D1; + if (gAccountPdaStatus == ACCOUNT_PDA_OK) { + dotColor = 0x38B26D; + dotBg = 0x38B26D; + statusColor = 0x38B26D; + } else if (gAccountPdaStatus == ACCOUNT_PDA_MISMATCH) { + dotColor = 0xD26969; + dotBg = 0xD26969; + statusColor = 0xD26969; + } else if (gAccountPdaStatus == ACCOUNT_PDA_NOT_FOUND) { + dotColor = 0xF0F4F8; + dotBg = 0x08111B; + statusColor = 0xF0F4F8; + } + lv_obj_set_style_border_color(accountDot, lv_color_hex(dotColor), 0); + lv_obj_set_style_bg_color(accountDot, lv_color_hex(dotBg), 0); + + if (gAccountPdaStatus != ACCOUNT_PDA_OK) { + lv_obj_t *accountStatus = lv_label_create(gRoot); + lv_label_set_text(accountStatus, gAccountPdaStatusMessage.c_str()); + lv_obj_set_style_text_font(accountStatus, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(accountStatus, lv_color_hex(statusColor), 0); + lv_obj_set_pos(accountStatus, 272, 48); + } + if (!gSecretConfigured) { lv_obj_t *secret = lv_label_create(gRoot); lv_label_set_text(secret, homeSecretStatus().c_str()); @@ -1439,10 +2672,13 @@ static void drawHome() { } drawTopStatusIndicators(); - makeTitle("STATUS", 150, &lv_font_montserrat_28); + makeTitle("STATUS", 138, &lv_font_montserrat_28); showMessageAt(wifiHomeSummary(), 214); - makeButton(gBalanceStatusMessage.c_str(), 22, 254, 436, 58, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); - makeBody("Swipe left or tap the button below.", 328, 360); + makeButton(balanceHomeLine().c_str(), 22, 254, 436, 56, 0x355C7D, ACTION_REFRESH_BALANCE, &lv_font_montserrat_18); + showMessageAt(shineHomeLine(), 322); + if (gShowRegisterAccountButton) { + makeButton("REGISTER ACCOUNT", 22, 360, 220, 78, 0x2A9D8F, ACTION_REGISTER_ACCOUNT, &lv_font_montserrat_20); + } makeButton("SETTINGS", 238, 360, 220, 78, 0x2A6F97, ACTION_OPEN_SETTINGS, &lv_font_montserrat_24); makeVersionTag(); } @@ -1700,6 +2936,15 @@ static void drawSecretGenerateCancelConfirmScreen() { makeVersionTag(); } +static void drawRegisterAccountPlaceholderScreen() { + setRootStyle(); + makeTitle("REGISTER ACCOUNT", 22, &lv_font_montserrat_24); + makeBody("Registration flow is not implemented yet.", 112, 420); + makeBody("This button is shown when login is not found in Solana PDA.", 156, 420); + makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_BACK_HOME, &lv_font_montserrat_22); + makeVersionTag(); +} + static void drawKeyRow(const char *const *tokens, int count, lv_coord_t x, @@ -1860,6 +3105,9 @@ static void rebuildScreen() { case SCREEN_TEXT_EDIT: drawTextEditScreen(); break; + case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER: + drawRegisterAccountPlaceholderScreen(); + break; } } @@ -1984,11 +3232,15 @@ static void handleSwipe(SwipeDirection swipe) { case SCREEN_TEXT_EDIT: handleTextEditSwipe(swipe); break; + case SCREEN_REGISTER_ACCOUNT_PLACEHOLDER: + handleHomeSwipe(swipe); + break; } } void setup() { Serial.begin(115200); + sodium_init(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); initPowerManagement(); @@ -2045,9 +3297,11 @@ void setup() { void loop() { lv_timer_handler(); manageWifiReconnect(); + manageAccountPdaRefresh(); + manageShineConnection(); static unsigned long lastHomeRefreshMs = 0; - if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= 1000) { + if (gCurrentScreen == SCREEN_HOME && !gTouchDown && millis() - lastHomeRefreshMs >= HOME_REFRESH_MS) { lastHomeRefreshMs = millis(); rebuildScreen(); } diff --git a/VERSION.properties b/VERSION.properties index a7e35e2..56c86b5 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.152 -server.version=1.2.144 +client.version=1.2.153 +server.version=1.2.145