Обновить pairing устройств и доработать ESP32 UI

This commit is contained in:
AidarKC
2026-06-19 20:47:56 +04:00
parent cc074a941f
commit a788d8bcf5
18 changed files with 1020 additions and 126 deletions
@@ -7,6 +7,7 @@
#include <lvgl.h>
#include <Arduino_GFX_Library.h>
#include <TouchDrvCSTXXX.hpp>
#include <mbedtls/gcm.h>
#include <mbedtls/sha256.h>
#include <mbedtls/base64.h>
#include <Ed25519.h>
@@ -93,6 +94,8 @@ enum Screen {
SCREEN_HOME,
SCREEN_WALLET_QR,
SCREEN_SETTINGS_MENU,
SCREEN_PAIRING_REQUESTS,
SCREEN_PAIRING_REQUEST_DETAIL,
SCREEN_WIFI,
SCREEN_SERVER,
SCREEN_ACCOUNT,
@@ -121,6 +124,7 @@ enum ActionId {
ACTION_NONE,
ACTION_OPEN_SETTINGS,
ACTION_OPEN_WALLET_QR,
ACTION_OPEN_PAIRING_REQUESTS,
ACTION_OPEN_WIFI,
ACTION_OPEN_SERVER,
ACTION_OPEN_ACCOUNT,
@@ -145,6 +149,9 @@ enum ActionId {
ACTION_BACK_SECRET_MENU,
ACTION_BACK_ACCOUNT,
ACTION_REFRESH_BALANCE,
ACTION_PAIRING_REFRESH,
ACTION_PAIRING_APPROVE,
ACTION_PAIRING_REJECT,
ACTION_REGISTER_ACCOUNT,
ACTION_REGISTER_ACCOUNT_EXECUTE,
ACTION_HOMESERVER_PDA_ACTION,
@@ -226,8 +233,14 @@ struct SimpleWebSocketClient {
uint16_t port = 0;
};
static const char *kMenuItems[] = {"1. Wi-Fi", "2. Server", "3. Account"};
static const size_t kMenuCount = sizeof(kMenuItems) / sizeof(kMenuItems[0]);
struct PairingRequestUiItem {
String pairingId;
String requesterSessionKey;
int requesterSessionType = 0;
String requesterClientPlatform;
String shortCode;
uint64_t expiresAtMs = 0;
};
static lv_disp_draw_buf_t gDrawBuf;
static lv_color_t *gBuf1 = nullptr;
@@ -328,6 +341,10 @@ static unsigned long gShineReconnectDelayMs = SHINE_RECONNECT_MIN_MS;
static uint32_t gWsRequestCounter = 1;
static int64_t gShineServerTimeOffsetMs = 0;
static SimpleWebSocketClient gShineWs;
static std::vector<PairingRequestUiItem> gPairingRequests;
static int gSelectedPairingRequestIndex = -1;
static String gPairingStatusMessage = "Refresh requests";
static bool gPairingBusy = false;
struct DerivedKeyInfo {
String title;
@@ -409,7 +426,25 @@ 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 jsonInt64Field(const String &json, const String &field, uint64_t &valueOut);
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut);
static String formatPairingShortCode(const String &value);
static bool pairingMenuVisible();
static int settingsMenuCount();
static String settingsMenuLabel(int itemIndex);
static ActionId settingsMenuAction(int itemIndex);
static String pairingSessionKindLabel(int sessionType);
static String pairingSessionNameLabel(const PairingRequestUiItem &item);
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut);
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut);
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut);
static bool refreshPairingRequests(String &errorOut);
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out);
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut);
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut);
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut);
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut);
static String shineWsUrl();
static String shineHomeLine();
static String balanceHomeLine();
@@ -855,6 +890,195 @@ static bool jsonBoolField(const String &json, const String &field, bool &valueOu
return false;
}
static String formatPairingShortCode(const String &value) {
String digits;
for (int i = 0; i < (int)value.length(); ++i) {
char ch = value.charAt(i);
if (ch >= '0' && ch <= '9') {
digits += ch;
}
}
while (digits.length() < 10) {
digits = String("0") + digits;
}
if (digits.length() > 10) {
digits = digits.substring(0, 10);
}
String out;
for (int i = 0; i < 10; i += 2) {
if (!out.isEmpty()) out += " ";
out += digits.substring(i, i + 2);
}
return out;
}
static bool pairingMenuVisible() {
return gShineAuthenticated;
}
static int settingsMenuCount() {
return pairingMenuVisible() ? 4 : 3;
}
static String settingsMenuLabel(int itemIndex) {
if (pairingMenuVisible()) {
switch (itemIndex) {
case 0: return "1. Device requests";
case 1: return "2. Wi-Fi";
case 2: return "3. Server";
case 3: return "4. Account";
default: return "";
}
}
switch (itemIndex) {
case 0: return "1. Wi-Fi";
case 1: return "2. Server";
case 2: return "3. Account";
default: return "";
}
}
static ActionId settingsMenuAction(int itemIndex) {
if (pairingMenuVisible()) {
switch (itemIndex) {
case 0: return ACTION_OPEN_PAIRING_REQUESTS;
case 1: return ACTION_OPEN_WIFI;
case 2: return ACTION_OPEN_SERVER;
case 3: return ACTION_OPEN_ACCOUNT;
default: return ACTION_NONE;
}
}
switch (itemIndex) {
case 0: return ACTION_OPEN_WIFI;
case 1: return ACTION_OPEN_SERVER;
case 2: return ACTION_OPEN_ACCOUNT;
default: return ACTION_NONE;
}
}
static String pairingSessionKindLabel(int sessionType) {
return sessionType == 50 ? "Wallet session" : "Client session";
}
static String pairingSessionNameLabel(const PairingRequestUiItem &item) {
String value = item.requesterClientPlatform;
value.trim();
return value.isEmpty() ? "Unknown client" : value;
}
static bool findJsonArrayBounds(const String &json, const String &field, int &startOut, int &endOut) {
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 start = json.indexOf('[', colon + 1);
if (start < 0) return false;
bool inString = false;
bool escape = false;
int depth = 0;
for (int i = start; i < (int)json.length(); ++i) {
char ch = json.charAt(i);
if (escape) {
escape = false;
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch == '[') depth++;
if (ch == ']') {
depth--;
if (depth == 0) {
startOut = start;
endOut = i;
return true;
}
}
}
return false;
}
static bool extractJsonObjectAt(const String &json, int startIndex, int &endOut, String &objectOut) {
int start = json.indexOf('{', startIndex);
if (start < 0) return false;
bool inString = false;
bool escape = false;
int depth = 0;
for (int i = start; i < (int)json.length(); ++i) {
char ch = json.charAt(i);
if (escape) {
escape = false;
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch == '{') depth++;
if (ch == '}') {
depth--;
if (depth == 0) {
endOut = i;
objectOut = json.substring(start, i + 1);
return true;
}
}
}
return false;
}
static bool parsePairingRequestsResponse(const String &json, std::vector<PairingRequestUiItem> &itemsOut, String &errorOut) {
itemsOut.clear();
errorOut = "";
uint64_t status = 0;
if (!jsonInt64Field(json, "status", status) || status != 200) {
errorOut = "ListTrustedDeviceLoginRequests rejected";
return false;
}
int arrayStart = 0;
int arrayEnd = 0;
if (!findJsonArrayBounds(json, "requests", arrayStart, arrayEnd)) {
errorOut = "requests array missing";
return false;
}
int cursor = arrayStart + 1;
while (cursor < arrayEnd) {
String objectJson;
int objectEnd = 0;
if (!extractJsonObjectAt(json, cursor, objectEnd, objectJson) || objectEnd > arrayEnd) {
break;
}
PairingRequestUiItem item;
uint64_t sessionType = 0;
uint64_t expiresAtMs = 0;
jsonStringField(objectJson, "pairingId", item.pairingId);
jsonStringField(objectJson, "requesterSessionKey", item.requesterSessionKey);
jsonStringField(objectJson, "requesterClientPlatform", item.requesterClientPlatform);
jsonStringField(objectJson, "shortCode", item.shortCode);
jsonInt64Field(objectJson, "requesterSessionType", sessionType);
jsonInt64Field(objectJson, "expiresAtMs", expiresAtMs);
item.requesterSessionType = (int)sessionType;
item.expiresAtMs = expiresAtMs;
if (!item.pairingId.isEmpty()) {
itemsOut.push_back(item);
}
cursor = objectEnd + 1;
}
return true;
}
static bool parseUrlHostPortPath(const String &url, String &hostOut, uint16_t &portOut, String &pathOut, bool &secureOut) {
hostOut = "";
pathOut = "/";
@@ -3177,6 +3401,289 @@ static bool shineWsRequest(SimpleWebSocketClient &ws, const String &op, const St
return false;
}
static bool refreshPairingRequests(String &errorOut) {
errorOut = "";
String authError;
if (!ensureShineSessionAuthenticated(authError)) {
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
return false;
}
String response;
if (!shineWsRequest(gShineWs, "ListTrustedDeviceLoginRequests", "{}", response, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "ListTrustedDeviceLoginRequests failed";
return false;
}
std::vector<PairingRequestUiItem> items;
if (!parsePairingRequestsResponse(response, items, errorOut)) {
return false;
}
gPairingRequests = items;
if (gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
gSelectedPairingRequestIndex = -1;
}
gPairingStatusMessage = "";
return true;
}
static bool buildPkcs8FromSeed32(const uint8_t seed32[32], String &pkcs8B64Out) {
static const uint8_t kEd25519Pkcs8Prefix[] = {
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20
};
std::vector<uint8_t> raw;
raw.reserve(sizeof(kEd25519Pkcs8Prefix) + 32);
raw.insert(raw.end(), kEd25519Pkcs8Prefix, kEd25519Pkcs8Prefix + sizeof(kEd25519Pkcs8Prefix));
raw.insert(raw.end(), seed32, seed32 + 32);
pkcs8B64Out = bytesToBase64String(raw.data(), raw.size());
return !pkcs8B64Out.isEmpty();
}
static bool buildPairingSecretsPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
payloadJsonOut = "";
errorOut = "";
uint8_t deviceSeed[32] = {};
uint8_t devicePub[32] = {};
uint8_t deviceSec[64] = {};
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
errorOut = "Failed to derive device key";
return false;
}
String devicePkcs8;
if (!buildPkcs8FromSeed32(deviceSeed, devicePkcs8)) {
errorOut = "Failed to encode device key";
return false;
}
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-pairing-transfer\",\"login\":\"") + jsonEscape(gLoginValue)
+ "\",\"mode\":\"device-only\",\"keys\":{\"deviceKey\":\"" + jsonEscape(devicePkcs8)
+ "\",\"blockchainKey\":\"\",\"rootKey\":\"\"},\"payloadType\":1,\"createdAtMs\":"
+ String((unsigned long long)shineNowMs()) + "}";
return true;
}
static bool createDelegatedWalletSessionPayload(const PairingRequestUiItem &item, String &payloadJsonOut, String &errorOut) {
payloadJsonOut = "";
errorOut = "";
uint8_t deviceSeed[32] = {};
uint8_t devicePub[32] = {};
uint8_t deviceSec[64] = {};
if (!deriveSeedKeypairFromBase58(gDevicePrivB58, deviceSeed, devicePub, deviceSec)) {
errorOut = "Failed to derive device key";
return false;
}
String wsUrl = shineWsUrl();
if (wsUrl.isEmpty()) {
errorOut = "Shine server is not configured";
return false;
}
uint8_t storageRaw[32];
for (size_t i = 0; i < sizeof(storageRaw); ++i) storageRaw[i] = (uint8_t)esp_random();
String storagePwd = bytesToBase64String(storageRaw, sizeof(storageRaw));
SimpleWebSocketClient tempWs;
String wsError;
if (!ensureWebSocketConnected(tempWs, wsUrl, wsError)) {
errorOut = wsError.isEmpty() ? "Delegated session WS connect failed" : wsError;
return false;
}
bool ok = false;
do {
String authResp;
if (!shineWsRequest(tempWs, "AuthChallenge",
String("{\"login\":\"") + jsonEscape(gLoginValue) + "\"}",
authResp, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "Delegated AuthChallenge failed";
break;
}
uint64_t statusCode = 0;
String authNonce;
if (!jsonInt64Field(authResp, "status", statusCode) || statusCode != 200 || !jsonStringField(authResp, "authNonce", authNonce)) {
errorOut = "Delegated AuthChallenge rejected";
break;
}
uint64_t timeMs = shineNowMs();
String preimage = String("AUTH_CREATE_SESSION:") + gLoginValue + ":" + item.requesterSessionKey + ":" + storagePwd + ":" + String((unsigned long long)timeMs) + ":" + authNonce;
uint8_t signature[64] = {};
crypto_sign_ed25519_detached(signature, nullptr,
reinterpret_cast<const unsigned char *>(preimage.c_str()),
preimage.length(), deviceSec);
String clientPlatform = item.requesterClientPlatform;
clientPlatform.trim();
if (clientPlatform.isEmpty()) clientPlatform = "Wallet session";
String createReq = String("{\"login\":\"") + jsonEscape(gLoginValue)
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
+ "\",\"timeMs\":" + String((unsigned long long)timeMs)
+ ",\"authNonce\":\"" + jsonEscape(authNonce)
+ "\",\"deviceKey\":\"" + jsonEscape(bytesToBase64String(devicePub, 32))
+ "\",\"signatureB64\":\"" + jsonEscape(bytesToBase64String(signature, 64))
+ "\",\"sessionType\":50"
+ ",\"clientPlatform\":\"" + jsonEscape(clientPlatform)
+ "\",\"clientInfo\":\"Wallet session approved via ESP32 pairing\"}";
String createResp;
if (!shineWsRequest(tempWs, "CreateAuthSession", createReq, createResp, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "Delegated CreateAuthSession failed";
break;
}
String sessionId;
if (!jsonInt64Field(createResp, "status", statusCode) || statusCode != 200 || !jsonStringField(createResp, "sessionId", sessionId)) {
errorOut = "Delegated CreateAuthSession rejected";
break;
}
payloadJsonOut = String("{\"v\":1,\"type\":\"shine-esp-session-attach\",\"login\":\"") + jsonEscape(gLoginValue)
+ "\",\"session\":{\"sessionId\":\"" + jsonEscape(sessionId)
+ "\",\"sessionKey\":\"" + jsonEscape(item.requesterSessionKey)
+ "\",\"storagePwd\":\"" + jsonEscape(storagePwd)
+ "\",\"sessionType\":50,\"clientPlatform\":\"" + jsonEscape(clientPlatform)
+ "\"},\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
ok = true;
} while (false);
closeWebSocket(tempWs);
return ok;
}
static bool encryptPairingPayloadForRequester(const String &requesterSessionKey, const String &payloadJson, String &encryptedPayloadOut, String &errorOut) {
encryptedPayloadOut = "";
errorOut = "";
if (!requesterSessionKey.startsWith("ed25519/")) {
errorOut = "Unsupported requesterSessionKey";
return false;
}
std::vector<uint8_t> requesterEdPub;
if (!base64DecodeStd(requesterSessionKey.substring(8), requesterEdPub) || requesterEdPub.size() != 32) {
errorOut = "Bad requester session public key";
return false;
}
uint8_t requesterCurvePub[32] = {};
if (crypto_sign_ed25519_pk_to_curve25519(requesterCurvePub, requesterEdPub.data()) != 0) {
errorOut = "Failed to convert requester public key";
return false;
}
uint8_t ephPub[32] = {};
uint8_t ephSec[32] = {};
if (crypto_box_keypair(ephPub, ephSec) != 0) {
errorOut = "Failed to create ephemeral key";
return false;
}
uint8_t sharedSecret[32] = {};
if (crypto_scalarmult(sharedSecret, ephSec, requesterCurvePub) != 0) {
errorOut = "Failed to derive shared secret";
return false;
}
uint8_t aesKey[32] = {};
mbedtls_sha256(sharedSecret, sizeof(sharedSecret), aesKey, 0);
uint8_t iv[12] = {};
for (size_t i = 0; i < sizeof(iv); ++i) iv[i] = (uint8_t)esp_random();
std::vector<uint8_t> plain(payloadJson.length());
for (int i = 0; i < (int)payloadJson.length(); ++i) plain[i] = (uint8_t)payloadJson.charAt(i);
std::vector<uint8_t> cipher(plain.size());
uint8_t tag[16] = {};
mbedtls_gcm_context gcm;
mbedtls_gcm_init(&gcm);
bool ok = false;
if (mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, aesKey, 256) == 0
&& mbedtls_gcm_crypt_and_tag(&gcm,
MBEDTLS_GCM_ENCRYPT,
plain.size(),
iv, sizeof(iv),
nullptr, 0,
plain.data(),
cipher.data(),
sizeof(tag), tag) == 0) {
std::vector<uint8_t> cipherWithTag = cipher;
cipherWithTag.insert(cipherWithTag.end(), tag, tag + sizeof(tag));
String envelopeJson = String("{\"v\":1,\"alg\":\"x25519-aes256-gcm\",\"ephPubB64\":\"")
+ jsonEscape(bytesToBase64String(ephPub, sizeof(ephPub)))
+ "\",\"ivB64\":\"" + jsonEscape(bytesToBase64String(iv, sizeof(iv)))
+ "\",\"cipherB64\":\"" + jsonEscape(bytesToBase64String(cipherWithTag.data(), cipherWithTag.size()))
+ "\",\"createdAtMs\":" + String((unsigned long long)shineNowMs()) + "}";
String payloadB64 = bytesToBase64String(reinterpret_cast<const uint8_t *>(envelopeJson.c_str()), envelopeJson.length());
payloadB64.replace("+", "-");
payloadB64.replace("/", "_");
while (payloadB64.endsWith("=")) payloadB64.remove(payloadB64.length() - 1);
encryptedPayloadOut = String("shine-esp-pairing-v1:") + payloadB64;
ok = true;
} else {
errorOut = "AES-GCM encryption failed";
}
mbedtls_gcm_free(&gcm);
sodium_memzero(sharedSecret, sizeof(sharedSecret));
sodium_memzero(aesKey, sizeof(aesKey));
sodium_memzero(ephSec, sizeof(ephSec));
return ok;
}
static bool approvePairingRequest(const PairingRequestUiItem &item, String &errorOut) {
errorOut = "";
String payloadJson;
if (item.requesterSessionType == 50) {
if (!createDelegatedWalletSessionPayload(item, payloadJson, errorOut)) {
return false;
}
} else {
if (!buildPairingSecretsPayload(item, payloadJson, errorOut)) {
return false;
}
}
String encryptedPayload;
if (!encryptPairingPayloadForRequester(item.requesterSessionKey, payloadJson, encryptedPayload, errorOut)) {
return false;
}
String authError;
if (!ensureShineSessionAuthenticated(authError)) {
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
return false;
}
String approveReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
+ "\",\"encryptedPayload\":\"" + jsonEscape(encryptedPayload) + "\"}";
String response;
if (!shineWsRequest(gShineWs, "ApproveTrustedDeviceLogin", approveReq, response, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "ApproveTrustedDeviceLogin failed";
return false;
}
uint64_t status = 0;
if (!jsonInt64Field(response, "status", status) || status != 200) {
errorOut = "ApproveTrustedDeviceLogin rejected";
return false;
}
return true;
}
static bool rejectPairingRequest(const PairingRequestUiItem &item, String &errorOut) {
errorOut = "";
String authError;
if (!ensureShineSessionAuthenticated(authError)) {
errorOut = authError.isEmpty() ? "Homeserver is offline" : authError;
return false;
}
String rejectReq = String("{\"pairingId\":\"") + jsonEscape(item.pairingId)
+ "\",\"reason\":\"rejected_by_user\"}";
String response;
if (!shineWsRequest(gShineWs, "RejectTrustedDeviceLogin", rejectReq, response, SHINE_RPC_TIMEOUT_MS)) {
errorOut = "RejectTrustedDeviceLogin failed";
return false;
}
uint64_t status = 0;
if (!jsonInt64Field(response, "status", status) || status != 200) {
errorOut = "RejectTrustedDeviceLogin rejected";
return false;
}
return true;
}
static bool ensureShineSessionAuthenticated(String &errorOut) {
errorOut = "";
String diagDetails;
@@ -3788,6 +4295,10 @@ static int batteryPercentValue() {
return value;
}
static bool batteryIsChargingNow() {
return gPowerReady && gPower.isBatteryConnect() && gPower.isCharging();
}
static int wifiSignalLevel() {
if (WiFi.status() != WL_CONNECTED) {
return 0;
@@ -3801,6 +4312,7 @@ static int wifiSignalLevel() {
static void drawTopStatusIndicators() {
int batt = batteryPercentValue();
bool charging = batteryIsChargingNow();
String battText = batt >= 0 ? String(batt) + "%" : "--";
lv_obj_t *battLabel = lv_label_create(gRoot);
@@ -3809,6 +4321,18 @@ static void drawTopStatusIndicators() {
lv_obj_set_style_text_color(battLabel, lv_color_hex(0xC9D3DE), 0);
lv_obj_set_pos(battLabel, 297, 18);
if (charging) {
static const lv_point_t boltPoints[] = {
{6, 0}, {1, 9}, {5, 9}, {2, 18}, {10, 7}, {6, 7}
};
lv_obj_t *chargeBolt = lv_line_create(gRoot);
lv_line_set_points(chargeBolt, boltPoints, sizeof(boltPoints) / sizeof(boltPoints[0]));
lv_obj_set_pos(chargeBolt, 337, 15);
lv_obj_set_style_line_width(chargeBolt, 2, 0);
lv_obj_set_style_line_color(chargeBolt, lv_color_hex(0xFFD34D), 0);
lv_obj_set_style_line_rounded(chargeBolt, true, 0);
}
lv_obj_t *battery = lv_obj_create(gRoot);
lv_obj_set_size(battery, 32, 16);
lv_obj_set_pos(battery, 349, 20);
@@ -4170,6 +4694,22 @@ static void networkSelectCb(lv_event_t *event) {
true);
}
static void pairingSelectCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence) {
return;
}
gLastHandledTouchSequence = gTouchSequence;
gSuppressTouchUntilRelease = true;
gBlockClick = true;
int index = static_cast<int>(reinterpret_cast<uintptr_t>(lv_event_get_user_data(event)));
if (index < 0 || index >= (int)gPairingRequests.size()) {
return;
}
gSelectedPairingRequestIndex = index;
showScreen(SCREEN_PAIRING_REQUEST_DETAIL);
}
static void editorKeyCb(lv_event_t *event) {
if (lv_event_get_code(event) != LV_EVENT_CLICKED || gBlockClick || gLastHandledTouchSequence == gTouchSequence || !gInputTextArea) {
return;
@@ -4244,6 +4784,14 @@ static void actionButtonCb(lv_event_t *event) {
case ACTION_OPEN_WALLET_QR:
showScreen(SCREEN_WALLET_QR);
break;
case ACTION_OPEN_PAIRING_REQUESTS: {
String error;
if (!refreshPairingRequests(error)) {
gPairingStatusMessage = error.isEmpty() ? "Failed to load requests" : error;
}
showScreen(SCREEN_PAIRING_REQUESTS);
break;
}
case ACTION_REGISTER_ACCOUNT:
prepareRegisterAccountScreen();
showScreen(SCREEN_REGISTER_ACCOUNT_CONFIRM);
@@ -4401,6 +4949,44 @@ static void actionButtonCb(lv_event_t *event) {
}
break;
}
case ACTION_PAIRING_REFRESH: {
String error;
if (!refreshPairingRequests(error)) {
gPairingStatusMessage = error.isEmpty() ? "Failed to refresh requests" : error;
}
rebuildScreen();
break;
}
case ACTION_PAIRING_APPROVE: {
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
String error;
if (approvePairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
gPairingStatusMessage = "Request approved";
refreshPairingRequests(error);
gSelectedPairingRequestIndex = -1;
showScreen(SCREEN_PAIRING_REQUESTS);
} else {
gPairingStatusMessage = error.isEmpty() ? "Approve failed" : error;
rebuildScreen();
}
}
break;
}
case ACTION_PAIRING_REJECT: {
if (gSelectedPairingRequestIndex >= 0 && gSelectedPairingRequestIndex < (int)gPairingRequests.size()) {
String error;
if (rejectPairingRequest(gPairingRequests[gSelectedPairingRequestIndex], error)) {
gPairingStatusMessage = "Request rejected";
refreshPairingRequests(error);
gSelectedPairingRequestIndex = -1;
showScreen(SCREEN_PAIRING_REQUESTS);
} else {
gPairingStatusMessage = error.isEmpty() ? "Reject failed" : error;
rebuildScreen();
}
}
break;
}
case ACTION_EDITOR_SAVE:
applyEditorValue();
break;
@@ -4663,27 +5249,32 @@ static void drawSettingsMenu() {
makeTitle("SETTINGS", 18, &lv_font_montserrat_24);
makeBody("Swipe up/down to move. Swipe right to go home.", 56, 420);
int totalItems = settingsMenuCount();
if (totalItems <= 2) {
gSettingsScrollIndex = 0;
} else if (gSettingsScrollIndex > totalItems - 2) {
gSettingsScrollIndex = totalItems - 2;
}
if (gSettingsScrollIndex < 0) {
gSettingsScrollIndex = 0;
}
for (int visibleIndex = 0; visibleIndex < 2; ++visibleIndex) {
int itemIndex = gSettingsScrollIndex + visibleIndex;
if (itemIndex >= static_cast<int>(kMenuCount)) {
if (itemIndex >= totalItems) {
break;
}
ActionId action = ACTION_NONE;
if (itemIndex == 0) action = ACTION_OPEN_WIFI;
if (itemIndex == 1) action = ACTION_OPEN_SERVER;
if (itemIndex == 2) action = ACTION_OPEN_ACCOUNT;
makeButton(kMenuItems[itemIndex], 22, 132 + visibleIndex * 126, 436, 104,
0x355C7D, action, &lv_font_montserrat_24);
String label = settingsMenuLabel(itemIndex);
makeButton(label.c_str(), 22, 132 + visibleIndex * 126, 436, 104,
0x355C7D, settingsMenuAction(itemIndex), &lv_font_montserrat_24);
}
lv_obj_t *hint = lv_label_create(gRoot);
char hintText[48];
snprintf(hintText, sizeof(hintText), "Items %d-%d of %d",
gSettingsScrollIndex + 1,
min(gSettingsScrollIndex + 2, static_cast<int>(kMenuCount)),
static_cast<int>(kMenuCount));
min(gSettingsScrollIndex + 2, totalItems),
totalItems);
lv_label_set_text(hint, hintText);
lv_obj_set_style_text_font(hint, &lv_font_montserrat_16, 0);
lv_obj_set_style_text_color(hint, lv_color_hex(0xA8B8C7), 0);
@@ -4692,6 +5283,161 @@ static void drawSettingsMenu() {
makeVersionTag();
}
static void drawPairingRequestsScreen() {
setRootStyle();
makeTitle("REQUESTS:", 18, &lv_font_montserrat_24);
lv_obj_t *titleLabel = lv_obj_get_child(gRoot, lv_obj_get_child_cnt(gRoot) - 1);
if (titleLabel) {
lv_obj_set_x(titleLabel, 194);
}
makeButton("REFRESH", 20, 18, 160, 44, 0x2A6F97, ACTION_PAIRING_REFRESH, &lv_font_montserrat_18);
lv_obj_t *countLabel = lv_label_create(gRoot);
String countText = String((int)gPairingRequests.size());
lv_label_set_text(countLabel, countText.c_str());
lv_obj_set_style_text_font(countLabel, &lv_font_montserrat_36, 0);
lv_obj_set_style_text_color(countLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_align(countLabel, LV_ALIGN_TOP_RIGHT, -22, 10);
if (!gPairingStatusMessage.isEmpty()) {
showMessageAt(gPairingStatusMessage, 72);
}
lv_obj_t *panel = lv_obj_create(gRoot);
lv_obj_set_size(panel, 440, 324);
lv_obj_set_pos(panel, 20, gPairingStatusMessage.isEmpty() ? 84 : 104);
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(panel, 1, 0);
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
lv_obj_set_style_radius(panel, 14, 0);
lv_obj_set_style_pad_all(panel, 10, 0);
lv_obj_set_style_pad_row(panel, 10, 0);
lv_obj_set_scroll_dir(panel, LV_DIR_VER);
lv_obj_set_scrollbar_mode(panel, LV_SCROLLBAR_MODE_ACTIVE);
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
if (gPairingRequests.empty()) {
lv_obj_t *empty = lv_label_create(panel);
lv_label_set_text(empty, "No active requests");
lv_obj_set_width(empty, 392);
lv_label_set_long_mode(empty, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(empty, &lv_font_montserrat_20, 0);
lv_obj_set_style_text_color(empty, lv_color_hex(0xA8B8C7), 0);
} else {
for (int i = 0; i < (int)gPairingRequests.size(); ++i) {
const PairingRequestUiItem &item = gPairingRequests[i];
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
lv_obj_t *btn = lv_btn_create(panel);
lv_obj_set_size(btn, 410, 146);
lv_obj_set_style_radius(btn, 14, 0);
lv_obj_set_style_bg_color(btn, lv_color_hex(0x203547), 0);
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(btn, 2, 0);
lv_obj_set_style_border_color(btn, lv_color_hex(0x4D6C82), 0);
lv_obj_add_event_cb(btn, pairingSelectCb, LV_EVENT_CLICKED, reinterpret_cast<void *>(static_cast<uintptr_t>(i)));
lv_obj_t *codeLabel = lv_label_create(btn);
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_pos(codeLabel, 18, 12);
lv_obj_t *nameLabel = lv_label_create(btn);
lv_label_set_text(nameLabel, sessionNameText.c_str());
lv_obj_set_width(nameLabel, 374);
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_set_pos(nameLabel, 18, 56);
lv_obj_t *kindLabel = lv_label_create(btn);
lv_label_set_text(kindLabel, sessionKindText.c_str());
lv_obj_set_width(kindLabel, 374);
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
lv_obj_set_pos(kindLabel, 18, 88);
}
}
makeVersionTag();
}
static void drawPairingRequestDetailScreen() {
setRootStyle();
makeTitle("REQUEST DETAIL", 18, &lv_font_montserrat_24);
if (gSelectedPairingRequestIndex < 0 || gSelectedPairingRequestIndex >= (int)gPairingRequests.size()) {
showMessageAt("Request not selected", 88);
makeButton("BACK", 140, 360, 200, 72, 0x5A6570, ACTION_OPEN_PAIRING_REQUESTS, &lv_font_montserrat_22);
makeVersionTag();
return;
}
const PairingRequestUiItem &item = gPairingRequests[gSelectedPairingRequestIndex];
String question = String("Connect session ") + pairingSessionNameLabel(item) + "?";
String explain = item.requesterSessionType == 50
? "Wallet session. No keys will be transferred."
: "Client session. Only device key will be transferred. No additional keys will be sent.";
String sessionNameText = String("Session: ") + pairingSessionNameLabel(item);
String sessionKindText = String("Kind: ") + pairingSessionKindLabel(item.requesterSessionType);
lv_obj_t *panel = lv_obj_create(gRoot);
lv_obj_set_size(panel, 440, 248);
lv_obj_set_pos(panel, 20, 86);
lv_obj_set_style_bg_color(panel, lv_color_hex(0x0F1B27), 0);
lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(panel, 1, 0);
lv_obj_set_style_border_color(panel, lv_color_hex(0x425466), 0);
lv_obj_set_style_radius(panel, 14, 0);
lv_obj_set_style_pad_all(panel, 16, 0);
lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t *qLabel = lv_label_create(panel);
lv_label_set_text(qLabel, question.c_str());
lv_obj_set_width(qLabel, 404);
lv_label_set_long_mode(qLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(qLabel, &lv_font_montserrat_22, 0);
lv_obj_set_style_text_color(qLabel, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_pos(qLabel, 0, 0);
lv_obj_t *codeLabel = lv_label_create(panel);
lv_label_set_text(codeLabel, formatPairingShortCode(item.shortCode).c_str());
lv_obj_set_style_text_font(codeLabel, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(codeLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_set_pos(codeLabel, 0, 48);
lv_obj_t *nameLabel = lv_label_create(panel);
lv_label_set_text(nameLabel, sessionNameText.c_str());
lv_obj_set_width(nameLabel, 404);
lv_label_set_long_mode(nameLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(nameLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(nameLabel, lv_color_hex(0xD5DEE7), 0);
lv_obj_set_pos(nameLabel, 0, 92);
lv_obj_t *kindLabel = lv_label_create(panel);
lv_label_set_text(kindLabel, sessionKindText.c_str());
lv_obj_set_width(kindLabel, 404);
lv_label_set_long_mode(kindLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(kindLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(kindLabel, lv_color_hex(0xA8D6A2), 0);
lv_obj_set_pos(kindLabel, 0, 124);
lv_obj_t *explainLabel = lv_label_create(panel);
lv_label_set_text(explainLabel, explain.c_str());
lv_obj_set_width(explainLabel, 404);
lv_label_set_long_mode(explainLabel, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(explainLabel, &lv_font_montserrat_18, 0);
lv_obj_set_style_text_color(explainLabel, lv_color_hex(0xB8C6D3), 0);
lv_obj_set_pos(explainLabel, 0, 164);
makeButton("YES", 40, 364, 180, 76, 0x2A9D8F, ACTION_PAIRING_APPROVE, &lv_font_montserrat_22);
makeButton("NO", 260, 364, 180, 76, 0x7D3A3A, ACTION_PAIRING_REJECT, &lv_font_montserrat_22);
makeVersionTag();
}
static lv_obj_t *makeNetworkButton(const char *text, int index, lv_coord_t y) {
lv_obj_t *btn = lv_btn_create(gRoot);
lv_obj_set_size(btn, 436, 52);
@@ -5134,6 +5880,12 @@ static void rebuildScreen() {
case SCREEN_SETTINGS_MENU:
drawSettingsMenu();
break;
case SCREEN_PAIRING_REQUESTS:
drawPairingRequestsScreen();
break;
case SCREEN_PAIRING_REQUEST_DETAIL:
drawPairingRequestDetailScreen();
break;
case SCREEN_WIFI:
drawWifiScreen();
break;
@@ -5198,8 +5950,8 @@ static void handleSettingsSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_UP) {
gSettingsScrollIndex++;
if (gSettingsScrollIndex > static_cast<int>(kMenuCount) - 2) {
gSettingsScrollIndex = static_cast<int>(kMenuCount) - 2;
if (gSettingsScrollIndex > settingsMenuCount() - 2) {
gSettingsScrollIndex = settingsMenuCount() - 2;
}
rebuildScreen();
return;
@@ -5221,6 +5973,18 @@ static void handleWifiSwipe(SwipeDirection swipe) {
}
}
static void handlePairingRequestsSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_RIGHT) {
showScreen(SCREEN_SETTINGS_MENU);
}
}
static void handlePairingRequestDetailSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_RIGHT) {
showScreen(SCREEN_PAIRING_REQUESTS);
}
}
static void handleServerSwipe(SwipeDirection swipe) {
if (swipe == SWIPE_RIGHT) {
showScreen(SCREEN_SETTINGS_MENU);
@@ -5281,6 +6045,12 @@ static void handleSwipe(SwipeDirection swipe) {
case SCREEN_SETTINGS_MENU:
handleSettingsSwipe(swipe);
break;
case SCREEN_PAIRING_REQUESTS:
handlePairingRequestsSwipe(swipe);
break;
case SCREEN_PAIRING_REQUEST_DETAIL:
handlePairingRequestDetailSwipe(swipe);
break;
case SCREEN_WIFI:
handleWifiSwipe(swipe);
break;
@@ -26,7 +26,7 @@
- реальное чтение баланса кошелька из `Solana RPC`;
- проверка обязательных условий перед регистрацией;
- живая on-chain регистрация серверного `user_pda` в `shine_users` по нажатию кнопки на главном экране;
- прототип входящих запросов с подтверждением и отклонением;
- живой экран заявок на подключение новых устройств через доверенную homeserver-сессию;
- PIN-блокировка (в текущей временной сборке вход по PIN отключён, устройство открывает `HOME` сразу после старта);
- базовые настройки, статус и главный экран;
- сохранение `PDA` и `tx signature` после успешной регистрации.
@@ -34,8 +34,8 @@
Что пока считается именно прототипом, а не финальной интеграцией:
- приём реальных входящих запросов на вход/подпись пока не подключён к живой сети;
- входящие запросы пока демонстрационные, чтобы можно было проверить UX и логику подтверждения.
- пока реализован только сценарий заявок на подключение устройств через доверенную сессию;
- другие типы входящих запросов на подпись и произвольные approval-flow на этом устройстве ещё не подключены.
## Основная идея устройства
@@ -46,7 +46,7 @@
- показывает адрес кошелька устройства;
- позволяет пополнить баланс перед регистрацией;
- после выполнения условий даёт зарегистрировать устройство как homeserver;
- после регистрации может принимать входящие запросы на вход и на подпись.
- после авторизации в SHiNE может подтверждать заявки на подключение новых устройств пользователя.
`SD`-карта не нужна для постоянного хранения секрета в этом прототипе.
Основное сохранение идёт во внутреннюю flash-память через `NVS`.
@@ -120,6 +120,11 @@
- Верхняя строка всегда показывает краткий статус устройства:
`PIN`, `Wi-Fi`, `сервер`, `регистрация`.
- В правом верхнем углу рядом с батареей:
- сначала процент заряда;
- затем, если устройство реально заряжается, маленькая иконка молнии;
- затем контур батареи;
- затем индикатор `Wi-Fi`.
- Основной язык прототипа: русский.
- Для вывода текста в текущей временной сборке используется стандартный шрифт `Arduino_GFX`.
- Русские строки на экране временно показываются в ASCII-транслитерации.
@@ -452,60 +457,80 @@
## Экран REQUESTS
Показывает список демонстрационных запросов:
- `Вход в сессию`
- `Подпись сообщения`
Для каждого запроса:
- тип;
- источник;
- короткий статус.
Кнопки:
- `Открыть запрос 1`
- `Открыть запрос 2`
- `Назад`
## Экран REQUEST_DETAIL
Показывает детали выбранного запроса:
- тип запроса;
- кто запросил;
- время;
- описание;
- отпечаток/идентификатор.
Кнопки:
- `Разрешить`
- `Отклонить`
- `Назад`
Поведение:
- после разрешения или отклонения запрос помечается обработанным;
- экран списка отражает новый статус.
## Экран SETTINGS
Экран доступен только если homeserver уже авторизован в SHiNE.
Показывает:
- текущий PIN;
- базовые флаги безопасности;
- технические действия прототипа.
- слева сверху кнопку `REFRESH`;
- заголовок `REQUESTS:` немного правее стандартного левого положения;
- справа сверху только большую цифру числа активных заявок;
- ниже прокручиваемый список активных pairing-заявок;
- на экране одновременно видны примерно две плитки.
Каждая плитка показывает:
- код подключения из `10` цифр в виде `5` пар: `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`.
Поведение:
- список берётся из живой операции `ListTrustedDeviceLoginRequests`;
- если заявок нет, экран показывает `No active requests`;
- отдельная строка вида `Active requests: N` на этом экране не показывается;
- вертикальный скролл позволяет просматривать все активные заявки;
- нажатие по плитке открывает `REQUEST_DETAIL`;
- свайп вправо возвращает в `SETTINGS`.
## Экран REQUEST_DETAIL
Показывает детали выбранной pairing-заявки:
- вопрос `Connect session ...?`;
- код подключения `XX XX XX XX XX`;
- строку `Session: <platform/name>`;
- строку `Kind: Client session` или `Kind: Wallet session`;
- пояснение:
- для client session: `Only device key will be transferred. No additional keys will be sent.`
- для wallet session: `No keys will be transferred.`
Кнопки:
- `Сменить PIN`
- `Сбросить онлайн`
- `Полный сброс`
- `Назад`
- `YES`
- `NO`
`Полный сброс` очищает весь локальный конфиг и возвращает устройство к стартовому состоянию.
Поведение:
- `YES` подтверждает заявку:
- для client session устройство передаёт только `device key`;
- для wallet session устройство выпускает отдельную `wallet-session` без передачи ключей;
- `NO` отклоняет заявку;
- после любого решения устройство возвращается в список `REQUESTS` и обновляет его;
- свайп вправо возвращает в `REQUESTS`.
## Экран SETTINGS
Показывает вертикальное меню крупных пунктов.
Если homeserver ещё не авторизован в SHiNE:
- `1. Wi-Fi`
- `2. Server`
- `3. Account`
Если homeserver уже авторизован:
- `1. Device requests`
- `2. Wi-Fi`
- `3. Server`
- `4. Account`
Поведение:
- одновременно видны только две карточки;
- список листается свайпом вверх/вниз;
- свайп вправо возвращает на `HOME`;
- пункт `Device requests` должен быть первым и появляется только для авторизованного homeserver.
## Экран PIN_EDIT