diff --git a/boards/seeed_wio_tracker_l2.json b/boards/seeed_wio_tracker_l2.json new file mode 100644 index 0000000000..706ed4c170 --- /dev/null +++ b/boards/seeed_wio_tracker_l2.json @@ -0,0 +1,43 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "memory_type": "qio_opi", + "partitions": "default_16MB.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DBOARD_HAS_PSRAM", + "-DARDUINO_USB_CDC_ON_BOOT=1", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [["0x303A", "0x1001"]], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": ["wifi", "bluetooth", "lora"], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": ["esp-builtin"], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": ["arduino"], + "name": "Seeed Wio Tracker L2 (16 MB flash, 8 MB OPI PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "require_upload_port": true, + "speed": 921600 + }, + "url": "https://www.seeedstudio.com/", + "vendor": "Seeed Studio" +} diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index b25b1442fc..5e391c4973 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -41,6 +41,12 @@ class AbstractUITask { void disableBluetooth() { _interfaceManager->disableBluetooth(); } virtual void msgRead(int msgcount) = 0; virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0; + virtual void msgAck(uint32_t ack_crc) { } // delivery ACK received (any origin) + virtual void msgEchoHeard() { } // our last sent message heard being retransmitted + virtual void loginResult(const uint8_t* pub_key, bool success) { } // repeater/room login outcome + virtual void statusResponse(const uint8_t* pub_key, const uint8_t* data, int len) { } // REQ_TYPE_GET_STATUS reply + virtual void cliResponse(const char* from_name, const char* text) { } // repeater CLI reply text + virtual void traceResponse(uint32_t tag, const uint8_t* path_hashes, const uint8_t* path_snrs, uint8_t hop_count, int8_t final_snr) { } // TRACE round trip returned virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 46c8e2f60c..4b95ac27d0 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -289,7 +289,33 @@ uint8_t MyMesh::getExtraAckTransmitCount() const { return _prefs.multi_acks; } +static uint32_t fnv1aHash(const uint8_t* data, int len) { + uint32_t h = 2166136261u; + for (int i = 0; i < len; i++) { + h ^= data[i]; + h *= 16777619u; + } + return h; +} + +void MyMesh::logTx(mesh::Packet* packet, int len) { + // remember the payload signature of outgoing text so the UI can count + // repeaters echoing it (payload bytes survive retransmission unchanged) + uint8_t t = packet->getPayloadType(); + if (_ui != NULL && (t == PAYLOAD_TYPE_TXT_MSG || t == PAYLOAD_TYPE_GRP_TXT) && packet->payload_len > 0) { + echo_hash = fnv1aHash(packet->payload, packet->payload_len); + echo_len = packet->payload_len; + echo_time = millis(); + } +} + void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { + // the payload sits at the tail of the frame; a match on the last echo_len + // bytes means a neighbor just retransmitted our message + if (_ui != NULL && echo_len > 0 && len > (int) echo_len && millis() - echo_time < 60000) { + if (fnv1aHash(&raw[len - echo_len], echo_len) == echo_hash) _ui->msgEchoHeard(); + } + if (_serial->isConnected() && len + 3 <= MAX_FRAME_SIZE) { int i = 0; out_frame[i++] = PUSH_CODE_LOG_RX_DATA; @@ -465,6 +491,13 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { } ContactInfo* MyMesh::processAck(const uint8_t *data) { +#ifdef DISPLAY_CLASS + if (_ui) { // let on-device UI match acks for messages it originated + uint32_t ack_crc; + memcpy(&ack_crc, data, 4); + _ui->msgAck(ack_crc); + } +#endif // see if matches any in a table for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) { if (memcmp(data, &expected_ack_table[i].ack, 4) == 0) { // got an ACK from recipient @@ -525,6 +558,8 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe if (!_serial->isConnected()) { _ui->notify(UIEventType::contactMessage); } + } else if (txt_type == TXT_TYPE_CLI_DATA && _ui) { + _ui->cliResponse(from.name, text); // for UIs with a repeater terminal } #endif } @@ -737,6 +772,35 @@ uint8_t MyMesh::onContactRequest(const ContactInfo &contact, uint32_t sender_tim return 0; // unknown } +int MyMesh::uiLogin(const ContactInfo &recipient, const char *password) { + uint32_t est_timeout; + int result = sendLogin(recipient, password, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + memcpy(&pending_login, recipient.id.pub_key, 4); // match this to onContactResponse() + } + return result; +} + +int MyMesh::uiRequestStatus(const ContactInfo &recipient) { + uint32_t tag, est_timeout; + int result = sendRequest(recipient, REQ_TYPE_GET_STATUS, tag, est_timeout); + if (result != MSG_SEND_FAILED) { + clearPendingReqs(); + memcpy(&pending_status, recipient.id.pub_key, 4); // match this to onContactResponse() + } + return result; +} + +int MyMesh::uiTracePath(const uint8_t *path, uint8_t path_len, uint32_t tag) { + auto pkt = createTrace(tag, 0, 0); // flags = 0: one-byte path hashes + if (pkt == NULL) return 0; + sendDirect(pkt, path, path_len); + + uint32_t t = _radio->getEstAirtimeFor(pkt->payload_len + pkt->path_len + 2); + return (int) calcDirectTimeoutMillisFor(t, path_len); +} + void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, uint8_t len) { uint32_t tag; memcpy(&tag, data, 4); @@ -745,6 +809,13 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, // yes, is response to pending sendLogin() pending_login = 0; +#ifdef DISPLAY_CLASS + if (_ui) { + bool login_ok = (memcmp(&data[4], "OK", 2) == 0) || data[4] == RESP_SERVER_LOGIN_OK; + _ui->loginResult(contact.id.pub_key, login_ok); + } +#endif + int i = 0; if (memcmp(&data[4], "OK", 2) == 0) { // legacy Repeater login OK response out_frame[i++] = PUSH_CODE_LOGIN_SUCCESS; @@ -778,6 +849,10 @@ void MyMesh::onContactResponse(const ContactInfo &contact, const uint8_t *data, ) { pending_status = 0; +#ifdef DISPLAY_CLASS + if (_ui) _ui->statusResponse(contact.id.pub_key, &data[4], len - 4); +#endif + int i = 0; out_frame[i++] = PUSH_CODE_STATUS_RESPONSE; out_frame[i++] = 0; // reserved @@ -888,6 +963,9 @@ void MyMesh::onRawDataRecv(mesh::Packet *packet) { void MyMesh::onTraceRecv(mesh::Packet *packet, uint32_t tag, uint32_t auth_code, uint8_t flags, const uint8_t *path_snrs, const uint8_t *path_hashes, uint8_t path_len) { uint8_t path_sz = flags & 0x03; // NEW v1.11+ + if (_ui != NULL) { // on-device trace UI (matches by tag) + _ui->traceResponse(tag, path_hashes, path_snrs, (uint8_t)(path_len >> path_sz), (int8_t)(packet->getSNR() * 4)); + } if (12 + path_len + (path_len >> path_sz) + 1 > sizeof(out_frame)) { MESH_DEBUG_PRINTLN("onTraceRecv(), path_len is too long: %d", (uint32_t)path_len); return; @@ -2403,6 +2481,20 @@ void MyMesh::loop() { #endif } +bool MyMesh::advertFlood() { + mesh::Packet* pkt; + if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { + pkt = createSelfAdvert(_prefs.node_name); + } else { + pkt = createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon); + } + if (pkt == NULL) return false; + TransportKey default_scope; + memcpy(&default_scope.key, _prefs.default_scope_key, sizeof(default_scope.key)); + sendFloodScoped(default_scope, pkt, 0); + return true; +} + bool MyMesh::advert() { mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3b98a4f674..1ca5e2f270 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -108,6 +108,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void loop(); void handleCmdFrame(size_t len); bool advert(); + bool advertFlood(); // same advert, flood-routed (phone app "flood advert") + const mesh::LocalIdentity& selfId() const { return self_id; } void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); @@ -117,6 +119,17 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { int getDiscoveredNodes(DiscoveredNode nodes[], int max_num); #endif + // on-device UI login to repeater/room server (registers for the response + // like the phone CMD_SEND_LOGIN path does) + int uiLogin(const ContactInfo& recipient, const char* password); + int uiRequestStatus(const ContactInfo& recipient); + int uiTracePath(const uint8_t* path, uint8_t path_len, uint32_t tag); + + // used by the on-device UI as well as the phone command handlers + void saveChannels() { _store->saveChannels(this); } + void saveContacts(); + bool isValidClientRepeatFreq(uint32_t f) const; + protected: float getAirtimeBudgetFactor() const override; int getInterferenceThreshold() const override; @@ -136,6 +149,7 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; + void logTx(mesh::Packet* packet, int len) override; bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -224,11 +238,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void checkCLIRescueCmd(); bool handleCommand(const char* text, uint32_t sender_timestamp, char* reply); void checkSerialInterface(); - bool isValidClientRepeatFreq(uint32_t f) const; // helpers, short-cuts - void saveChannels() { _store->saveChannels(this); } - void saveContacts(); DataStore* _store; NodePrefs _prefs; @@ -255,6 +266,11 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { TransportKey send_scope; + // signature of the last transmitted text message, for UI echo detection + uint32_t echo_hash = 0; + uint16_t echo_len = 0; + unsigned long echo_time = 0; + uint8_t cmd_frame[MAX_FRAME_SIZE + 1]; uint8_t out_frame[MAX_FRAME_SIZE + 1]; CayenneLPP telemetry; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 89f0e6cb9f..f55421b8aa 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -125,12 +125,14 @@ void setup() { DisplayDriver* disp = NULL; if (display.begin()) { disp = &display; +#ifndef UI_LVGL // LVGL UI shows its own splash; skip the text banner disp->startFrame(); #ifdef ST7789 disp->setTextSize(2); #endif disp->drawTextCentered(disp->width() / 2, 28, "Loading..."); disp->endFrame(); +#endif } #endif diff --git a/examples/companion_radio/ui-lvgl/ChatStore.cpp b/examples/companion_radio/ui-lvgl/ChatStore.cpp new file mode 100644 index 0000000000..62a05b4443 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ChatStore.cpp @@ -0,0 +1,183 @@ +#include "ChatStore.h" +#include + +#ifdef ESP32 + #include +#endif + +#define CHAT_HIST_FILE "/chat_hist.bin" +#define CHAT_HIST_MAGIC 0x43483032 // "CH02" + +static ChatMsg chat_store[CHAT_STORE_SIZE]; +static int chat_head = 0; +static bool chat_dirty = false; +static unsigned long chat_flush_at = 0; + +void makeChannelKey(int idx, uint8_t key[6]) { + key[0] = 0xFE; key[1] = 0xC4; key[2] = (uint8_t)idx; + key[3] = 0x00; key[4] = 0xBE; key[5] = 0xEF; +} + +bool isChannelKey(const uint8_t key[6], int* idx_out) { + if (key[0] == 0xFE && key[1] == 0xC4 && key[3] == 0x00 && key[4] == 0xBE && key[5] == 0xEF) { + if (idx_out) *idx_out = key[2]; + return true; + } + return false; +} + +void chatStoreMarkDirty() { + chat_dirty = true; + chat_flush_at = millis() + 4000; +} + +ChatMsg* chatStorePush(const uint8_t* pub_key, bool outgoing, uint32_t timestamp, const char* text) { + auto m = &chat_store[chat_head]; + chat_head = (chat_head + 1) % CHAT_STORE_SIZE; + memcpy(m->prefix, pub_key, 6); + m->outgoing = outgoing ? 1 : 0; + m->valid = 1; + m->status = MSG_STATUS_NONE; + m->expected_ack = 0; + m->timeout_at = 0; + m->timestamp = timestamp; + StrHelper::strncpy(m->text, text, sizeof(m->text)); + chatStoreMarkDirty(); + return m; +} + +ChatMsg* chatStoreGet(const uint8_t* pub_key, int k) { + int found = 0; + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + int idx = (chat_head - 1 - i + CHAT_STORE_SIZE * 2) % CHAT_STORE_SIZE; + auto m = &chat_store[idx]; + if (!m->valid || memcmp(m->prefix, pub_key, 6) != 0) continue; + if (found == k) return m; + found++; + } + return NULL; +} + +bool chatStoreAck(uint32_t ack_crc) { + bool changed = false; + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + auto m = &chat_store[i]; + if (m->valid && m->status == MSG_STATUS_PENDING && m->expected_ack == ack_crc) { + m->status = MSG_STATUS_DELIVERED; + m->expected_ack = 0; + changed = true; + } + } + if (changed) chatStoreMarkDirty(); + return changed; +} + +int chatStoreThreads(uint8_t keys[][6], int max) { + int count = 0; + for (int i = 0; i < CHAT_STORE_SIZE && count < max; i++) { + int idx = (chat_head - 1 - i + CHAT_STORE_SIZE * 2) % CHAT_STORE_SIZE; + auto m = &chat_store[idx]; + if (!m->valid) continue; + bool seen = false; + for (int j = 0; j < count; j++) { + if (memcmp(keys[j], m->prefix, 6) == 0) { seen = true; break; } + } + if (!seen) memcpy(keys[count++], m->prefix, 6); + } + return count; +} + +void chatStoreLoad() { +#ifdef ESP32 + if (!SPIFFS.begin(true)) return; + File f = SPIFFS.open(CHAT_HIST_FILE, "r"); + if (!f) return; + uint32_t magic = 0; + int32_t head = 0; + bool ok = f.read((uint8_t *)&magic, 4) == 4 && magic == CHAT_HIST_MAGIC + && f.read((uint8_t *)&head, 4) == 4 + && f.read((uint8_t *)chat_store, sizeof(chat_store)) == sizeof(chat_store) + && head >= 0 && head < CHAT_STORE_SIZE; + f.close(); + if (ok) { + chat_head = head; + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + chat_store[i].text[sizeof(chat_store[i].text) - 1] = 0; // guard vs flash corruption + if (chat_store[i].valid && chat_store[i].status == MSG_STATUS_PENDING) { + chat_store[i].status = MSG_STATUS_NO_ACK; // acks can't match across reboot + } + } + } else { + memset(chat_store, 0, sizeof(chat_store)); + chat_head = 0; + } +#endif +} + +static void chatStoreSave() { + chat_dirty = false; +#ifdef ESP32 + File f = SPIFFS.open(CHAT_HIST_FILE, "w"); + if (!f) return; + uint32_t magic = CHAT_HIST_MAGIC; + int32_t head = chat_head; + f.write((const uint8_t *)&magic, 4); + f.write((const uint8_t *)&head, 4); + f.write((const uint8_t *)chat_store, sizeof(chat_store)); + f.close(); +#endif +} + +void chatStoreFlushLoop() { + if (chat_dirty && millis() > chat_flush_at) { + chatStoreSave(); + } +} + +void chatStoreClearThread(const uint8_t key[6]) { + for (int i = 0; i < CHAT_STORE_SIZE; i++) { + if (chat_store[i].valid && memcmp(chat_store[i].prefix, key, 6) == 0) { + chat_store[i].valid = 0; + } + } + chatStoreMarkDirty(); +} + +// --- unread counters --- +#define UNREAD_SLOTS 24 +struct UnreadSlot { uint8_t key[6]; int count; }; +static UnreadSlot unread[UNREAD_SLOTS]; + +void chatStoreUnreadBump(const uint8_t key[6]) { + int free_slot = -1; + for (int i = 0; i < UNREAD_SLOTS; i++) { + if (unread[i].count > 0 && memcmp(unread[i].key, key, 6) == 0) { + unread[i].count++; + return; + } + if (unread[i].count == 0 && free_slot < 0) free_slot = i; + } + if (free_slot >= 0) { + memcpy(unread[free_slot].key, key, 6); + unread[free_slot].count = 1; + } +} + +void chatStoreUnreadClear(const uint8_t key[6]) { + for (int i = 0; i < UNREAD_SLOTS; i++) { + if (unread[i].count > 0 && memcmp(unread[i].key, key, 6) == 0) unread[i].count = 0; + } +} + +int chatStoreUnreadGet(const uint8_t key[6]) { + for (int i = 0; i < UNREAD_SLOTS; i++) { + if (unread[i].count > 0 && memcmp(unread[i].key, key, 6) == 0) return unread[i].count; + } + return 0; +} + +int chatStoreUnreadTotal() { + int total = 0; + for (int i = 0; i < UNREAD_SLOTS; i++) total += unread[i].count; + return total; +} diff --git a/examples/companion_radio/ui-lvgl/ChatStore.h b/examples/companion_radio/ui-lvgl/ChatStore.h new file mode 100644 index 0000000000..2ae301099d --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ChatStore.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +// On-device chat history, persisted to /chat_hist.bin so threads survive a +// reboot. Fixed-size ring: CHAT_STORE_SIZE messages across all threads. + +#define MSG_STATUS_NONE 0 +#define MSG_STATUS_PENDING 1 +#define MSG_STATUS_DELIVERED 2 +#define MSG_STATUS_NO_ACK 3 + +struct ChatMsg { + uint8_t prefix[6]; // contact pub_key prefix; identifies the thread + uint8_t outgoing; // 1 = sent from this device + uint8_t valid; + uint8_t status; // MSG_STATUS_* + uint32_t timestamp; + uint32_t expected_ack; // ack CRC we're waiting for (pending only) + uint32_t timeout_at; // millis deadline for pending -> no_ack + char text[120]; +}; + +#ifndef CHAT_STORE_SIZE + #define CHAT_STORE_SIZE 40 +#endif + +// synthetic 6-byte thread key for channel slot idx (vs contact pub_key prefix) +void makeChannelKey(int idx, uint8_t key[6]); +bool isChannelKey(const uint8_t key[6], int* idx_out); + +ChatMsg* chatStorePush(const uint8_t* pub_key, bool outgoing, uint32_t timestamp, const char* text); +ChatMsg* chatStoreGet(const uint8_t* pub_key, int k); // k-th newest in thread; NULL when exhausted +void chatStoreLoad(); +void chatStoreFlushLoop(); // call from loop(); debounced save when dirty +void chatStoreMarkDirty(); +bool chatStoreAck(uint32_t ack_crc); // mark matching pending msg delivered + +// distinct thread keys, newest activity first; returns count (<= max) +int chatStoreThreads(uint8_t keys[][6], int max); + +void chatStoreClearThread(const uint8_t key[6]); // delete a thread's messages + +// per-thread unread counters (RAM only) +void chatStoreUnreadBump(const uint8_t key[6]); +void chatStoreUnreadClear(const uint8_t key[6]); +int chatStoreUnreadGet(const uint8_t key[6]); +int chatStoreUnreadTotal(); diff --git a/examples/companion_radio/ui-lvgl/MapView.cpp b/examples/companion_radio/ui-lvgl/MapView.cpp new file mode 100644 index 0000000000..1bcd7d168f --- /dev/null +++ b/examples/companion_radio/ui-lvgl/MapView.cpp @@ -0,0 +1,437 @@ +#include "MapView.h" + +#include +#include +#include +#include +#include +#include +#include "../MyMesh.h" +#include "target.h" +#include "UITask.h" + +// Wio Tracker L2 Pro SDIO 1-bit wiring; TF power rail is raised by board init +#define SD_PIN_CLK 2 +#define SD_PIN_CMD 3 +#define SD_PIN_D0 1 + +#define TILE_PX 256 +#define GRID_COLS 3 +#define GRID_ROWS 2 +#define MAP_MIN_Z 5 +#define MAP_MAX_Z 17 + +static SensorManager* map_sensors = NULL; +static UITask* map_task = NULL; +static lv_obj_t* map_canvas = NULL; +static lv_obj_t* tiles[GRID_COLS * GRID_ROWS]; +static lv_obj_t* marker = NULL; +static lv_obj_t* status_lbl = NULL; +static lv_obj_t* zoom_lbl = NULL; +static bool sd_ok = false; + +#define MAX_MAP_NODES 16 +static lv_obj_t* node_marks[MAX_MAP_NODES]; +static lv_obj_t* node_labels[MAX_MAP_NODES]; +static int node_contact_idx[MAX_MAP_NODES]; // contact index behind each marker + +static void node_mark_cb(lv_event_t* e) { + int slot = (int)(intptr_t) lv_event_get_user_data(e); + if (slot >= 0 && slot < MAX_MAP_NODES && map_task != NULL) { + map_task->openContactDetail(node_contact_idx[slot]); + } +} + +// view center in tile-float coordinates at the current zoom +static int map_z = 13; +static double center_fx, center_fy; +static bool center_initialized = false; + +static void lonlatToTileF(double lon, double lat, int z, double* fx, double* fy) { + double n = (double)(1 << z); + *fx = (lon + 180.0) / 360.0 * n; + double lat_r = lat * M_PI / 180.0; + *fy = (1.0 - asinh(tan(lat_r)) / M_PI) / 2.0 * n; +} + +static void savedPosLoad(double* lat, double* lon) { + File f = SPIFFS.open("/lastpos", "r"); + if (!f) return; + double a = f.parseFloat(); + double b = f.parseFloat(); + f.close(); + if (a != 0 || b != 0) { *lat = a; *lon = b; } +} +static void savedPosStore(double lat, double lon) { + static unsigned long last_save = 0; + if (last_save != 0 && millis() - last_save < 600000) return; // at most every 10 min + last_save = millis(); + File f = SPIFFS.open("/lastpos", "w"); + if (f) { f.printf("%.6f %.6f\n", lat, lon); f.close(); } +} + +static void mapApplyDim(bool dim); + +static void mapInitCenter() { + if (center_initialized) return; + double lat = 0, lon = 0; + NodePrefs* np = map_task != NULL ? map_task->nodePrefs() : NULL; + if (np != NULL && (np->node_lat != 0 || np->node_lon != 0)) { lat = np->node_lat; lon = np->node_lon; } + else savedPosLoad(&lat, &lon); + if (lat == 0 && lon == 0) { + map_z = 5; // nothing known yet: wide view until GPS or the phone sets a position + lonlatToTileF(0.0, 20.0, map_z, ¢er_fx, ¢er_fy); + } else { + lonlatToTileF(lon, lat, map_z, ¢er_fx, ¢er_fy); + } + center_initialized = true; +} + +// not exported through lvgl.h in this configuration +extern "C" void lv_image_cache_drop(const void* src); + +#define SYMBOL_SUN "\xEF\x86\x85" /* U+F185 */ +#define SYMBOL_MOON "\xEF\x86\x86" /* U+F186 */ + +static bool map_night = false; // defined here: the pak loader needs it +static bool dark_set_ok = false; // a dark set was seen on the card +static bool day_set_ok = false; // a day set was seen on the card +static int dark_hits = 0; // tiles served from maps_dark this refresh + +// Packed tiles: maps/{z}/{x}.pak = 'TPK1' | y0 | y1 | offsets[n+1] | PNGs. +// One file per tile column, since a FAT card copies millions of small files +// far slower than a few thousand large ones. Loose z/x/y.png still works. +static uint8_t* pak_buf[GRID_COLS * GRID_ROWS]; +static size_t pak_cap[GRID_COLS * GRID_ROWS]; +static lv_image_dsc_t pak_dsc[GRID_COLS * GRID_ROWS]; +static int pak_key_z[GRID_COLS * GRID_ROWS]; +static int pak_key_x[GRID_COLS * GRID_ROWS]; +static int pak_key_y[GRID_COLS * GRID_ROWS]; + +static bool loadPakTile(lv_obj_t* img, int z, int tx, int ty, int slot) { + int zkey = z | (map_night ? 0x100 : 0); + if (pak_key_z[slot] == zkey && pak_key_x[slot] == tx && pak_key_y[slot] == ty) { + return true; // this slot already shows exactly this tile (and this set) + } + const char* first = map_night ? "maps_dark" : "maps"; + const char* second = map_night ? "maps" : "maps_dark"; + bool from_dark = map_night; + char path[48]; + snprintf(path, sizeof(path), "/sdcard/%s/%d/%d.pak", first, z, tx); + FILE* f = fopen(path, "rb"); + if (f == NULL) { + snprintf(path, sizeof(path), "/sdcard/%s/%d/%d.pak", second, z, tx); + f = fopen(path, "rb"); + from_dark = !map_night; + } + if (f == NULL) return false; + uint8_t hdr[12]; + uint32_t y0, y1; + if (fread(hdr, 1, 12, f) != 12 || memcmp(hdr, "TPK1", 4) != 0) { fclose(f); return false; } + memcpy(&y0, hdr + 4, 4); + memcpy(&y1, hdr + 8, 4); + if (ty < (int) y0 || ty > (int) y1) { fclose(f); return false; } + uint32_t off[2]; + if (fseek(f, 12 + 4 * (ty - (int) y0), SEEK_SET) != 0 || fread(off, 4, 2, f) != 2) { fclose(f); return false; } + uint32_t len = off[1] - off[0]; + if (len == 0 || len > 262144) { fclose(f); return false; } + if (pak_cap[slot] < len) { + uint8_t* nb = (uint8_t*) heap_caps_realloc(pak_buf[slot], len, MALLOC_CAP_SPIRAM); + if (nb == NULL) { fclose(f); return false; } + pak_buf[slot] = nb; + pak_cap[slot] = len; + } + bool ok = fseek(f, off[0], SEEK_SET) == 0 && fread(pak_buf[slot], 1, len, f) == len; + fclose(f); + if (!ok) return false; + + lv_image_cache_drop(&pak_dsc[slot]); // same dsc pointer, new bytes + memset(&pak_dsc[slot], 0, sizeof(pak_dsc[slot])); + pak_dsc[slot].header.magic = LV_IMAGE_HEADER_MAGIC; + pak_dsc[slot].header.cf = LV_COLOR_FORMAT_RAW; + pak_dsc[slot].header.w = TILE_PX; + pak_dsc[slot].header.h = TILE_PX; + pak_dsc[slot].data_size = len; + pak_dsc[slot].data = pak_buf[slot]; + lv_image_set_src(img, &pak_dsc[slot]); + lv_obj_invalidate(img); + pak_key_z[slot] = zkey; pak_key_x[slot] = tx; pak_key_y[slot] = ty; + if (from_dark) { dark_hits++; dark_set_ok = true; } + return true; +} + +static bool sdMount() { + if (sd_ok) return true; + SD_MMC.setPins(SD_PIN_CLK, SD_PIN_CMD, SD_PIN_D0); + sd_ok = SD_MMC.begin("/sdcard", true /* 1-bit mode */); + MESH_DEBUG_PRINTLN("map: SD %s", sd_ok ? "mounted" : "mount FAILED"); + if (sd_ok) { + File d = SD_MMC.open("/maps"); + day_set_ok = d && d.isDirectory(); + MESH_DEBUG_PRINTLN("map: tile store %s", day_set_ok ? "present" : "missing"); + if (d) d.close(); + File dd = SD_MMC.open("/maps_dark"); + dark_set_ok = dd && dd.isDirectory(); + if (dd) dd.close(); + MESH_DEBUG_PRINTLN("map: dark set %s", dark_set_ok ? "present" : "not on card"); + if (dark_set_ok && !day_set_ok) map_night = true; // dark-only card: start dark + } + return sd_ok; +} + +void mapViewRefresh() { + if (map_canvas == NULL) return; + mapInitCenter(); + dark_hits = 0; + + int cw = lv_obj_get_width(map_canvas); + int chh = lv_obj_get_height(map_canvas); + if (cw <= 0) { cw = 320; chh = 150; } // pre-layout fallback + + int n = 1 << map_z; + int base_tx = (int)floor(center_fx) - 1; + int base_ty = (int)floor(center_fy) - 1; + + char src[48]; + for (int j = 0; j < GRID_ROWS; j++) { + for (int i = 0; i < GRID_COLS; i++) { + lv_obj_t* img = tiles[j * GRID_COLS + i]; + int tx = base_tx + i; + int ty = base_ty + j; + int px = (int)((tx - center_fx) * TILE_PX) + cw / 2; + int py = (int)((ty - center_fy) * TILE_PX) + chh / 2; + lv_obj_set_pos(img, px, py); + int slot = j * GRID_COLS + i; + if (sd_ok && tx >= 0 && ty >= 0 && tx < n && ty < n) { + if (!loadPakTile(img, map_z, tx, ty, slot)) { + // loose-tile fallback; the leading slash matters after "/sdcard" + pak_key_z[slot] = -1; + snprintf(src, sizeof(src), "A:/%s/%d/%d/%d.png", + map_night ? "maps_dark" : "maps", map_z, tx, ty); + lv_image_set_src(img, src); + } + lv_obj_remove_flag(img, LV_OBJ_FLAG_HIDDEN); + } else { + pak_key_z[slot] = -1; + lv_obj_add_flag(img, LV_OBJ_FLAG_HIDDEN); + } + } + } + + mapApplyDim(map_night && dark_hits == 0); + lv_label_set_text_fmt(zoom_lbl, map_night ? "z%d dark" : "z%d", map_z); + if (!sd_ok) { + lv_label_set_text(status_lbl, "No SD card / tiles"); + lv_obj_remove_flag(status_lbl, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(status_lbl, LV_OBJ_FLAG_HIDDEN); + } + + int used = 0; + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots() && used < MAX_MAP_NODES; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] == 0 || (c.gps_lat == 0 && c.gps_lon == 0)) continue; + double nfx, nfy; + lonlatToTileF(c.gps_lon / 1000000.0, c.gps_lat / 1000000.0, map_z, &nfx, &nfy); + int px = (int)((nfx - center_fx) * TILE_PX) + cw / 2; + int py = (int)((nfy - center_fy) * TILE_PX) + chh / 2; + if (px < -20 || py < -20 || px > cw + 20 || py > chh + 20) continue; + node_contact_idx[used] = idx; + lv_obj_set_pos(node_marks[used], px - 5, py - 5); + lv_obj_remove_flag(node_marks[used], LV_OBJ_FLAG_HIDDEN); + lv_label_set_text(node_labels[used], c.name); + lv_obj_set_pos(node_labels[used], px + 7, py - 6); + lv_obj_remove_flag(node_labels[used], LV_OBJ_FLAG_HIDDEN); + used++; + } + for (int i = used; i < MAX_MAP_NODES; i++) { + lv_obj_add_flag(node_marks[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(node_labels[i], LV_OBJ_FLAG_HIDDEN); + } + + bool marker_shown = false; + if (map_sensors != NULL) { + LocationProvider* nmea = map_sensors->getLocationProvider(); + if (nmea != NULL && nmea->isValid()) { + savedPosStore(nmea->getLatitude() / 1000000.0, nmea->getLongitude() / 1000000.0); + double gfx, gfy; + lonlatToTileF(nmea->getLongitude() / 1000000.0, nmea->getLatitude() / 1000000.0, + map_z, &gfx, &gfy); + int mx = (int)((gfx - center_fx) * TILE_PX) + cw / 2; + int my = (int)((gfy - center_fy) * TILE_PX) + chh / 2; + if (mx >= 0 && my >= 0 && mx < cw && my < chh) { + lv_obj_set_pos(marker, mx - 6, my - 6); + lv_obj_remove_flag(marker, LV_OBJ_FLAG_HIDDEN); + marker_shown = true; + } + } + } + if (!marker_shown) lv_obj_add_flag(marker, LV_OBJ_FLAG_HIDDEN); +} + +static void map_press_cb(lv_event_t* e) { + lv_indev_t* indev = lv_indev_active(); + if (indev == NULL) return; + lv_point_t vect; + lv_indev_get_vect(indev, &vect); + if (vect.x == 0 && vect.y == 0) return; + center_fx -= (double)vect.x / TILE_PX; + center_fy -= (double)vect.y / TILE_PX; + double n = (double)(1 << map_z); + if (center_fx < 0) center_fx = 0; + if (center_fy < 0) center_fy = 0; + if (center_fx > n) center_fx = n; + if (center_fy > n) center_fy = n; + mapViewRefresh(); +} + +static void zoom_cb(lv_event_t* e) { + int dir = (int)(intptr_t) lv_event_get_user_data(e); + int new_z = map_z + dir; + if (new_z < MAP_MIN_Z || new_z > MAP_MAX_Z) return; + double scale = dir > 0 ? 2.0 : 0.5; + center_fx *= scale; + center_fy *= scale; + map_z = new_z; + mapViewRefresh(); +} + +static void locate_cb(lv_event_t* e) { + if (map_sensors == NULL) return; + LocationProvider* nmea = map_sensors->getLocationProvider(); + if (nmea == NULL || !nmea->isValid()) return; + lonlatToTileF(nmea->getLongitude() / 1000000.0, nmea->getLatitude() / 1000000.0, + map_z, ¢er_fx, ¢er_fy); + mapViewRefresh(); +} + +// with a dark set on the card the toggle swaps sets, otherwise it recolors +static lv_obj_t* night_btn_lbl = NULL; + +// Dimming is only a fallback for cards without a dark set, so it is applied +// after a refresh, once it is known whether any tile came from maps_dark. +static void mapApplyDim(bool dim) { + for (int k = 0; k < GRID_COLS * GRID_ROWS; k++) { + lv_obj_set_style_image_recolor(tiles[k], lv_color_hex(0x0A1428), 0); + lv_obj_set_style_image_recolor_opa(tiles[k], dim ? LV_OPA_60 : LV_OPA_TRANSP, 0); + } +} + +static void mapApplyNight() { + for (int k = 0; k < GRID_COLS * GRID_ROWS; k++) pak_key_z[k] = -1; // reload + if (night_btn_lbl != NULL) lv_label_set_text(night_btn_lbl, map_night ? SYMBOL_SUN : SYMBOL_MOON); + mapViewRefresh(); +} + +static void mapNightLoad() { + File f = SPIFFS.open("/mapnight", "r"); + if (f) { map_night = f.parseInt() != 0; f.close(); } +} + +static void night_cb(lv_event_t* e) { + map_night = !map_night; + File f = SPIFFS.open("/mapnight", "w"); + if (f) { f.print(map_night ? 1 : 0); f.close(); } + mapApplyNight(); + if (map_task != NULL) { + if (!map_night) map_task->showToast("Day tiles"); + else if (dark_hits > 0) map_task->showToast("Dark tiles"); + else map_task->showToast("No dark tiles here - dimming instead"); + } +} + +static lv_obj_t* makeMapBtn(lv_obj_t* parent, const char* txt, lv_event_cb_t cb, void* ud, + lv_align_t align, int x_ofs, int y_ofs) { + lv_obj_t* btn = lv_button_create(parent); + lv_obj_set_size(btn, 36, 30); + lv_obj_align(btn, align, x_ofs, y_ofs); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, ud); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, txt); + lv_obj_center(lbl); + return btn; +} + +void mapViewBuild(lv_obj_t* parent, SensorManager* sensors, UITask* task) { + map_sensors = sensors; + map_task = task; + sdMount(); + + lv_obj_set_style_pad_all(parent, 0, 0); + map_canvas = lv_obj_create(parent); + lv_obj_set_size(map_canvas, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(map_canvas, lv_color_hex(0x101820), 0); + lv_obj_set_style_border_width(map_canvas, 0, 0); + lv_obj_set_style_radius(map_canvas, 0, 0); + lv_obj_set_style_pad_all(map_canvas, 0, 0); + lv_obj_remove_flag(map_canvas, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(map_canvas, LV_OBJ_FLAG_SCROLL_CHAIN); // drags pan the map, never the tabview + lv_obj_add_flag(map_canvas, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(map_canvas, map_press_cb, LV_EVENT_PRESSING, NULL); + lv_obj_set_style_clip_corner(map_canvas, true, 0); + + for (int k = 0; k < GRID_COLS * GRID_ROWS; k++) { + pak_key_z[k] = pak_key_x[k] = pak_key_y[k] = -1; + tiles[k] = lv_image_create(map_canvas); + lv_obj_set_size(tiles[k], TILE_PX, TILE_PX); + lv_obj_remove_flag(tiles[k], LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(tiles[k], LV_OBJ_FLAG_HIDDEN); + } + + for (int i = 0; i < MAX_MAP_NODES; i++) { + node_marks[i] = lv_obj_create(map_canvas); + lv_obj_set_size(node_marks[i], 10, 10); + lv_obj_set_style_radius(node_marks[i], LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(node_marks[i], lv_color_hex(0xFFA030), 0); // orange = other nodes + lv_obj_set_style_border_color(node_marks[i], lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_border_width(node_marks[i], 1, 0); + lv_obj_add_flag(node_marks[i], LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_ext_click_area(node_marks[i], 8); // fingertip-sized hit box + lv_obj_add_event_cb(node_marks[i], node_mark_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)i); + lv_obj_add_flag(node_marks[i], LV_OBJ_FLAG_HIDDEN); + + node_labels[i] = lv_label_create(map_canvas); + lv_label_set_text(node_labels[i], ""); + lv_label_set_long_mode(node_labels[i], LV_LABEL_LONG_DOT); + lv_obj_set_width(node_labels[i], 90); + lv_obj_set_style_text_color(node_labels[i], lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_color(node_labels[i], lv_color_hex(0x101820), 0); + lv_obj_set_style_bg_opa(node_labels[i], LV_OPA_70, 0); + lv_obj_set_style_radius(node_labels[i], 4, 0); + lv_obj_set_style_pad_hor(node_labels[i], 3, 0); + lv_obj_set_style_pad_ver(node_labels[i], 1, 0); + lv_obj_add_flag(node_labels[i], LV_OBJ_FLAG_HIDDEN); + } + + marker = lv_obj_create(map_canvas); + lv_obj_set_size(marker, 12, 12); + lv_obj_set_style_radius(marker, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(marker, lv_color_hex(0x58B4FF), 0); + lv_obj_set_style_border_color(marker, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_border_width(marker, 2, 0); + lv_obj_remove_flag(marker, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(marker, LV_OBJ_FLAG_HIDDEN); + + status_lbl = lv_label_create(map_canvas); + lv_label_set_text(status_lbl, ""); + lv_obj_set_style_text_color(status_lbl, lv_color_hex(0x8FA3BF), 0); + lv_obj_align(status_lbl, LV_ALIGN_CENTER, 0, 0); + + zoom_lbl = lv_label_create(map_canvas); + lv_label_set_text(zoom_lbl, ""); + lv_obj_set_style_text_color(zoom_lbl, lv_color_hex(0xE8ECF2), 0); + lv_obj_align(zoom_lbl, LV_ALIGN_TOP_LEFT, 4, 4); + + makeMapBtn(map_canvas, LV_SYMBOL_PLUS, zoom_cb, (void*)(intptr_t)+1, LV_ALIGN_BOTTOM_RIGHT, -4, -40); + makeMapBtn(map_canvas, LV_SYMBOL_MINUS, zoom_cb, (void*)(intptr_t)-1, LV_ALIGN_BOTTOM_RIGHT, -4, -4); + makeMapBtn(map_canvas, LV_SYMBOL_GPS, locate_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 4, -4); + lv_obj_t* nb = makeMapBtn(map_canvas, LV_SYMBOL_EYE_OPEN, night_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 4, -40); + night_btn_lbl = lv_obj_get_child(nb, 0); + mapNightLoad(); + mapApplyNight(); + + mapViewRefresh(); +} + +bool mapViewSdOk() { return sd_ok; } diff --git a/examples/companion_radio/ui-lvgl/MapView.h b/examples/companion_radio/ui-lvgl/MapView.h new file mode 100644 index 0000000000..e9bfb6bdc5 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/MapView.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +// Slippy-map viewer: renders OSM raster tiles from the microSD card +// (/sdcard/maps/{z}/{x}/{y}.png via LVGL's POSIX FS + lodepng decoder), +// with drag panning, zoom buttons, and an own-position marker from GPS. + +class UITask; +void mapViewBuild(lv_obj_t* parent, SensorManager* sensors, UITask* task); +void mapViewRefresh(); // reposition/reload tiles + marker +bool mapViewSdOk(); diff --git a/examples/companion_radio/ui-lvgl/SettingsUI.cpp b/examples/companion_radio/ui-lvgl/SettingsUI.cpp new file mode 100644 index 0000000000..9e39174819 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SettingsUI.cpp @@ -0,0 +1,678 @@ +#include "SettingsUI.h" + +#include +#include +#include "UITask.h" +#include "../MyMesh.h" +#include "target.h" +#include +#include + +#define S_COL_CARD 0x16233B +#define S_COL_ACCENT 0x58B4FF +#define S_COL_MUTED 0x8FA3BF +#define S_KB_H 160 + +#define TX_MIN 2 +#define TX_MAX 22 + +static UITask* s_task = NULL; + +static lv_obj_t* name_val; +static lv_obj_t* radio_val; +static lv_obj_t* tx_val; +static lv_obj_t* pin_val; +static lv_obj_t* tz_val; +static int tz_offset = 0; // UTC offset hours (persisted; wizard suggests from GPS) + +// single-field editor overlay (name / BLE pin) +enum EditMode { EDIT_NAME, EDIT_PIN }; +static EditMode edit_mode; +static lv_obj_t* edit_scr; +static lv_obj_t* edit_title; +static lv_obj_t* edit_ta; +static lv_obj_t* edit_kb; + +// radio params overlay +static lv_obj_t* radio_scr; +static lv_obj_t* radio_ta[4]; // freq MHz, BW kHz, SF, CR +static lv_obj_t* radio_kb; +static lv_obj_t* radio_apply_btn; +static lv_obj_t* radio_apply_lbl; +static lv_obj_t* radio_preset_dd; +static lv_obj_t* radio_repeat_sw; +static lv_obj_t* tx_minus_btn; +static lv_obj_t* tx_plus_btn; +static bool radio_confirm_armed = false; + +// Regional presets. Client repeat mode only runs on the repeat frequencies +// MyMesh validates, so each preset carries the one for its band. +struct RadioPreset { + const char* label; // dropdown text + const char* short_label; // Settings row text + float freq; float bw; uint8_t sf; uint8_t cr; + float repeat_freq; +}; +static const RadioPreset RADIO_PRESETS[] = { + { "USA/CA narrow (rec.)", "US narrow", 910.525f, 62.5f, 7, 5, 918.000f }, + { "USA/CA classic", "US classic", 910.525f, 250.0f, 10, 5, 918.000f }, + { "EU/UK 869.525", "EU/UK", 869.525f, 250.0f, 11, 5, 869.495f }, + { "AUS/NZ 915.8", "AUS/NZ", 915.800f, 250.0f, 11, 5, 918.000f }, +}; +#define NUM_RADIO_PRESETS (sizeof(RADIO_PRESETS) / sizeof(RADIO_PRESETS[0])) + +static float repeatFreqForBand(float freq) { + if (freq < 500.0f) return 433.000f; + if (freq < 900.0f) return 869.495f; + return 918.000f; +} + +static void radioSetFreqField(float mhz) { + char buf[20]; + snprintf(buf, sizeof(buf), "%.3f", mhz); + lv_textarea_set_text(radio_ta[0], buf); +} + +static NodePrefs* prefs() { return s_task->nodePrefs(); } + +// --------------------------------------------------------------------------- +// row refresh +// --------------------------------------------------------------------------- +void settingsRefreshRows() { + if (s_task == NULL) return; + char buf[48]; + lv_label_set_text(name_val, prefs()->node_name); + const char* preset = "Custom"; + for (unsigned i = 0; i < NUM_RADIO_PRESETS; i++) { + const RadioPreset* r = &RADIO_PRESETS[i]; + if (fabsf(prefs()->freq - r->freq) < 0.001f && fabsf((float)prefs()->bw - r->bw) < 0.1f && + prefs()->sf == r->sf && prefs()->cr == r->cr) { preset = r->short_label; break; } + } + if (prefs()->isRepeatEn()) { + char rbuf[64]; + snprintf(rbuf, sizeof(rbuf), "%s + repeat", preset); // e.g. "US narrow + repeat" + lv_label_set_text(radio_val, rbuf); + } else { + lv_label_set_text(radio_val, preset); // full parameters shown in the dialog + } + snprintf(buf, sizeof(buf), "%d dBm", prefs()->tx_power_dbm); + lv_label_set_text(tx_val, buf); + if (tx_minus_btn != NULL && tx_plus_btn != NULL) { + bool at_min = prefs()->tx_power_dbm <= TX_MIN; + bool at_max = prefs()->tx_power_dbm >= TX_MAX; + if (at_min) lv_obj_add_state(tx_minus_btn, LV_STATE_DISABLED); + else lv_obj_remove_state(tx_minus_btn, LV_STATE_DISABLED); + if (at_max) lv_obj_add_state(tx_plus_btn, LV_STATE_DISABLED); + else lv_obj_remove_state(tx_plus_btn, LV_STATE_DISABLED); + } + snprintf(buf, sizeof(buf), "%u", (unsigned)prefs()->ble_pin); + lv_label_set_text(pin_val, prefs()->ble_pin ? buf : "(random)"); + snprintf(buf, sizeof(buf), "UTC%+d", tz_offset); + lv_label_set_text(tz_val, buf); +} + +// --------------------------------------------------------------------------- +// single-field editor +// --------------------------------------------------------------------------- +static void editOpen(EditMode mode) { + edit_mode = mode; + if (mode == EDIT_NAME) { + lv_label_set_text(edit_title, "Node name"); + lv_textarea_set_text(edit_ta, prefs()->node_name); + lv_keyboard_set_mode(edit_kb, LV_KEYBOARD_MODE_TEXT_LOWER); + } else { + lv_label_set_text(edit_title, "Bluetooth PIN (6 digits, next boot)"); + lv_textarea_set_text(edit_ta, ""); + lv_keyboard_set_mode(edit_kb, LV_KEYBOARD_MODE_NUMBER); + } + lv_obj_remove_flag(edit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(edit_kb, LV_OBJ_FLAG_HIDDEN); +} + +static void editSave() { + const char* txt = lv_textarea_get_text(edit_ta); + if (edit_mode == EDIT_NAME) { + if (txt == NULL || txt[0] == 0) { + s_task->showToast("Name can't be empty"); + return; + } + StrHelper::strncpy(prefs()->node_name, txt, sizeof(prefs()->node_name)); + the_mesh.savePrefs(); + s_task->nodeNameChanged(); + s_task->showToast("Name saved"); + } else { + uint32_t pin = (uint32_t) strtoul(txt, NULL, 10); + if (pin < 100000 || pin > 999999) { + s_task->showToast("Pin must be 6 digits"); + return; + } + prefs()->ble_pin = pin; + the_mesh.savePrefs(); + s_task->showToast("Pin saved - takes effect on reboot"); + } + lv_obj_add_flag(edit_scr, LV_OBJ_FLAG_HIDDEN); + settingsRefreshRows(); +} + +static void edit_save_cb(lv_event_t* e) { editSave(); } +static void edit_cancel_cb(lv_event_t* e) { lv_obj_add_flag(edit_scr, LV_OBJ_FLAG_HIDDEN); } +static void edit_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_obj_remove_flag(edit_kb, LV_OBJ_FLAG_HIDDEN); + } else if (code == LV_EVENT_READY) { + editSave(); + } +} + +static void name_row_cb(lv_event_t* e) { editOpen(EDIT_NAME); } +static void pin_row_cb(lv_event_t* e) { editOpen(EDIT_PIN); } + +// --------------------------------------------------------------------------- +// TX power stepper (applies immediately - safe, reversible) +// --------------------------------------------------------------------------- +static void tx_step_cb(lv_event_t* e) { + int dir = (int)(intptr_t) lv_event_get_user_data(e); + int p = prefs()->tx_power_dbm + dir; + if (p < TX_MIN || p > TX_MAX) return; + prefs()->tx_power_dbm = p; + the_mesh.savePrefs(); + radio_driver.setTxPower(p); + settingsRefreshRows(); +} + +// --------------------------------------------------------------------------- +// radio params editor +// --------------------------------------------------------------------------- +static void radioDisarm() { + radio_confirm_armed = false; + lv_label_set_text(radio_apply_lbl, "Apply"); + lv_obj_set_style_bg_color(radio_apply_btn, lv_color_hex(S_COL_ACCENT), 0); +} + +static void radioOpen(lv_event_t* e) { + char buf[20]; + snprintf(buf, sizeof(buf), "%.3f", prefs()->freq); + lv_textarea_set_text(radio_ta[0], buf); + snprintf(buf, sizeof(buf), "%g", (double)prefs()->bw); + lv_textarea_set_text(radio_ta[1], buf); + snprintf(buf, sizeof(buf), "%d", prefs()->sf); + lv_textarea_set_text(radio_ta[2], buf); + snprintf(buf, sizeof(buf), "%d", prefs()->cr); + lv_textarea_set_text(radio_ta[3], buf); + lv_dropdown_set_selected(radio_preset_dd, 0); // "(presets)" placeholder + if (prefs()->isRepeatEn()) lv_obj_add_state(radio_repeat_sw, LV_STATE_CHECKED); + else lv_obj_remove_state(radio_repeat_sw, LV_STATE_CHECKED); + radioDisarm(); + lv_obj_add_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(radio_scr, LV_OBJ_FLAG_HIDDEN); +} + +// repeat mode has its own frequency: move the field with the switch +static void radio_repeat_cb(lv_event_t* e) { + float freq = atof(lv_textarea_get_text(radio_ta[0])); + bool on = lv_obj_has_state(radio_repeat_sw, LV_STATE_CHECKED); + if (on) { + if (!the_mesh.isValidClientRepeatFreq((uint32_t)(freq * 1000.0f))) { + float rf = repeatFreqForBand(freq); + radioSetFreqField(rf); + char msg[56]; + snprintf(msg, sizeof(msg), "Repeat mode uses %.3f MHz", rf); + s_task->showToast(msg); + } + } else if (the_mesh.isValidClientRepeatFreq((uint32_t)(freq * 1000.0f))) { + for (unsigned i = 0; i < NUM_RADIO_PRESETS; i++) { + if (fabsf(RADIO_PRESETS[i].repeat_freq - freq) < 0.001f) { + radioSetFreqField(RADIO_PRESETS[i].freq); + break; + } + } + } + radioDisarm(); +} + +static void radio_preset_cb(lv_event_t* e) { + int sel = (int) lv_dropdown_get_selected(radio_preset_dd); + if (sel < 1 || sel > (int)NUM_RADIO_PRESETS) return; // index 0 = placeholder + const RadioPreset* p = &RADIO_PRESETS[sel - 1]; + char buf[20]; + radioSetFreqField(lv_obj_has_state(radio_repeat_sw, LV_STATE_CHECKED) ? p->repeat_freq : p->freq); + snprintf(buf, sizeof(buf), "%g", (double)p->bw); + lv_textarea_set_text(radio_ta[1], buf); + snprintf(buf, sizeof(buf), "%d", p->sf); + lv_textarea_set_text(radio_ta[2], buf); + snprintf(buf, sizeof(buf), "%d", p->cr); + lv_textarea_set_text(radio_ta[3], buf); + radioDisarm(); +} + +static void radio_apply_cb(lv_event_t* e) { + float freq = atof(lv_textarea_get_text(radio_ta[0])); + float bw = atof(lv_textarea_get_text(radio_ta[1])); + int sf = atoi(lv_textarea_get_text(radio_ta[2])); + int cr = atoi(lv_textarea_get_text(radio_ta[3])); + + // same bounds the phone command path enforces (freq narrowed to SX1262 range) + if (freq < 400.0f || freq > 960.0f || bw < 7.0f || bw > 500.0f || + sf < 5 || sf > 12 || cr < 5 || cr > 8) { + s_task->showToast("Out of range - check values"); + radioDisarm(); + return; + } + + bool repeat_en = lv_obj_has_state(radio_repeat_sw, LV_STATE_CHECKED); + if (repeat_en && !the_mesh.isValidClientRepeatFreq((uint32_t)(freq * 1000.0f))) { + s_task->showToast("Repeat mode needs 433.000, 869.495 or 918.000 MHz"); + radioDisarm(); + return; + } + + if (!radio_confirm_armed) { // two-tap confirm: mismatched nodes fall off-mesh + radio_confirm_armed = true; + lv_label_set_text(radio_apply_lbl, "CONFIRM?"); + lv_obj_set_style_bg_color(radio_apply_btn, lv_color_hex(0xFFA030), 0); + return; + } + + prefs()->freq = freq; + prefs()->bw = bw; + prefs()->sf = sf; + prefs()->cr = cr; + prefs()->setRepeatEn(repeat_en); + the_mesh.savePrefs(); + radio_driver.setParams(prefs()->freq, prefs()->bw, prefs()->sf, prefs()->cr); + + lv_obj_add_flag(radio_scr, LV_OBJ_FLAG_HIDDEN); + settingsRefreshRows(); + s_task->refreshNodeTab(); + s_task->showToast("Radio settings applied"); +} + +static void radio_cancel_cb(lv_event_t* e) { lv_obj_add_flag(radio_scr, LV_OBJ_FLAG_HIDDEN); } + +static void radio_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* ta = (lv_obj_t*) lv_event_get_target(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_keyboard_set_textarea(radio_kb, ta); + lv_obj_remove_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + radioDisarm(); + } else if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { + lv_obj_add_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + } +} + +// --------------------------------------------------------------------------- +// timezone offset (persisted in its own SPIFFS file) +// --------------------------------------------------------------------------- +int settingsTzOffset() { return tz_offset; } + +static void tzLoad() { + File f = SPIFFS.open("/tzoff", "r"); + if (f) { + int8_t v = 0; + if (f.read((uint8_t*)&v, 1) == 1 && v >= -12 && v <= 14) tz_offset = v; + f.close(); + } +} + +static void tzSave() { + File f = SPIFFS.open("/tzoff", "w"); + if (f) { + int8_t v = (int8_t)tz_offset; + f.write((uint8_t*)&v, 1); + f.close(); + } +} + +void settingsSetTzOffset(int hours) { + if (hours < -12 || hours > 14) return; + tz_offset = hours; + tzSave(); + settingsRefreshRows(); +} + +int settingsPresetCount() { return (int) NUM_RADIO_PRESETS; } +const char* settingsPresetLabel(int idx) { + return (idx >= 0 && idx < (int) NUM_RADIO_PRESETS) ? RADIO_PRESETS[idx].label : ""; +} +void settingsApplyPreset(int idx) { + if (idx < 0 || idx >= (int) NUM_RADIO_PRESETS) return; + const RadioPreset* r = &RADIO_PRESETS[idx]; + prefs()->freq = r->freq; + prefs()->bw = r->bw; + prefs()->sf = r->sf; + prefs()->cr = r->cr; + the_mesh.savePrefs(); + radio_driver.setParams(prefs()->freq, prefs()->bw, prefs()->sf, prefs()->cr); + settingsRefreshRows(); +} + +static void tz_step_cb(lv_event_t* e) { + int dir = (int)(intptr_t) lv_event_get_user_data(e); + int v = tz_offset + dir; + if (v < -12 || v > 14) return; + tz_offset = v; + tzSave(); + settingsRefreshRows(); +} + +// --------------------------------------------------------------------------- +// node backup / restore / factory reset +// --------------------------------------------------------------------------- +#define BACKUP_DIR "/l2_backup" +static bool restore_armed = false; +static bool factory_armed = false; +static lv_obj_t* restore_lbl; +static lv_obj_t* factory_lbl; + +static bool sdReady() { return SD_MMC.cardType() != CARD_NONE && SD_MMC.cardType() != CARD_UNKNOWN; } + +static int copyAll(fs::FS& src_fs, const char* src_dir, fs::FS& dst_fs, const char* dst_dir) { + File root = src_fs.open(src_dir); + if (!root || !root.isDirectory()) return -1; + int copied = 0; + File f = root.openNextFile(); + while (f) { + if (!f.isDirectory()) { + const char* name = strrchr(f.name(), '/'); + name = name ? name + 1 : f.name(); + char dst_path[96]; + snprintf(dst_path, sizeof(dst_path), "%s/%s", dst_dir, name); + File out = dst_fs.open(dst_path, "w"); + if (out) { + uint8_t buf[512]; + int n; + while ((n = f.read(buf, sizeof(buf))) > 0) out.write(buf, n); + out.close(); + copied++; + } + } + f = root.openNextFile(); + } + return copied; +} + +static void backup_cb(lv_event_t* e) { + if (!sdReady()) { s_task->showToast("No SD card"); return; } + SD_MMC.mkdir(BACKUP_DIR); + int n = copyAll(SPIFFS, "/", SD_MMC, BACKUP_DIR); + char buf[48]; + snprintf(buf, sizeof(buf), n >= 0 ? "Backed up %d files to SD" : "Backup failed", n); + s_task->showToast(buf); +} + +static void restore_cb(lv_event_t* e) { + if (!sdReady()) { s_task->showToast("No SD card"); return; } + if (!restore_armed) { + restore_armed = true; + lv_label_set_text(restore_lbl, "SURE? (overwrites node)"); + return; + } + int n = copyAll(SD_MMC, BACKUP_DIR, SPIFFS, ""); + if (n > 0) { + s_task->showToast("Restored - rebooting"); + lv_refr_now(NULL); + delay(1500); + s_task->shutdown(true); + } else { + s_task->showToast("No backup found on SD"); + restore_armed = false; + lv_label_set_text(restore_lbl, LV_SYMBOL_DOWNLOAD " Restore from SD"); + } +} + +static void factory_cb(lv_event_t* e) { + if (!factory_armed) { + factory_armed = true; + lv_label_set_text(factory_lbl, "SURE? (wipes identity)"); + return; + } + SPIFFS.format(); + s_task->showToast("Factory reset - rebooting"); + lv_refr_now(NULL); + delay(1500); + s_task->shutdown(true); +} + +// --------------------------------------------------------------------------- +// builders +// --------------------------------------------------------------------------- +static lv_obj_t* makeRow(lv_obj_t* parent, const char* title, lv_event_cb_t cb, lv_obj_t** val_out) { + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(S_COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + if (cb != NULL) { + lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, NULL); + } + lv_obj_t* lbl = lv_label_create(row); + lv_label_set_text(lbl, title); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* val = lv_label_create(row); + lv_label_set_text(val, ""); + lv_obj_set_style_text_color(val, lv_color_hex(S_COL_MUTED), 0); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + // bounded so long values elide instead of running into the title + lv_obj_set_width(val, 168); + lv_label_set_long_mode(val, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(val, LV_ALIGN_RIGHT_MID, -4, 0); + *val_out = val; + return row; +} + +static lv_obj_t* makeDialog(const char* none) { + lv_obj_t* scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(scr, 320, 240); + lv_obj_set_style_bg_color(scr, lv_color_hex(0x0A1020), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(scr, 0, 0); + lv_obj_set_style_radius(scr, 0, 0); + lv_obj_set_style_pad_all(scr, 8, 0); + lv_obj_add_flag(scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + return scr; +} + +static void styleTa(lv_obj_t* ta) { + lv_obj_set_height(ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(ta, 5, 0); + lv_obj_set_scroll_dir(ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_opa(ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); +} + +static lv_obj_t* makeBtn(lv_obj_t* parent, const char* txt, lv_event_cb_t cb, void* ud, + lv_align_t align, int xo, int yo, int w) { + lv_obj_t* btn = lv_button_create(parent); + lv_obj_set_size(btn, w, 32); + lv_obj_align(btn, align, xo, yo); + lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, ud); + lv_obj_t* lbl = lv_label_create(btn); + lv_label_set_text(lbl, txt); + lv_obj_center(lbl); + return btn; +} + +void settingsBuildExtras(lv_obj_t* parent, UITask* task) { + s_task = task; + + makeRow(parent, LV_SYMBOL_EDIT " Node name", name_row_cb, &name_val); + makeRow(parent, LV_SYMBOL_WIFI " Radio settings", radioOpen, &radio_val); + + lv_obj_t* tx_row = lv_obj_create(parent); + lv_obj_set_size(tx_row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(tx_row, lv_color_hex(S_COL_CARD), 0); + lv_obj_set_style_border_width(tx_row, 0, 0); + lv_obj_remove_flag(tx_row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* tx_lbl = lv_label_create(tx_row); + lv_label_set_text(tx_lbl, LV_SYMBOL_CHARGE " TX power"); + lv_obj_align(tx_lbl, LV_ALIGN_LEFT_MID, 4, 0); + tx_minus_btn = lv_button_create(tx_row); + lv_obj_set_size(tx_minus_btn, 30, 26); + lv_obj_align(tx_minus_btn, LV_ALIGN_RIGHT_MID, -108, 0); + lv_obj_add_event_cb(tx_minus_btn, tx_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)-1); + lv_obj_t* ml = lv_label_create(tx_minus_btn); lv_label_set_text(ml, LV_SYMBOL_MINUS); lv_obj_center(ml); + tx_val = lv_label_create(tx_row); + lv_label_set_text(tx_val, ""); + lv_obj_set_width(tx_val, 62); + lv_obj_set_style_text_align(tx_val, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(tx_val, LV_ALIGN_RIGHT_MID, -40, 0); + tx_plus_btn = lv_button_create(tx_row); + lv_obj_set_size(tx_plus_btn, 30, 26); + lv_obj_align(tx_plus_btn, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(tx_plus_btn, tx_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)+1); + lv_obj_t* pl = lv_label_create(tx_plus_btn); lv_label_set_text(pl, LV_SYMBOL_PLUS); lv_obj_center(pl); + +#ifndef STANDALONE_NO_BT + makeRow(parent, LV_SYMBOL_BLUETOOTH " Bluetooth PIN", pin_row_cb, &pin_val); +#else + pin_val = lv_label_create(parent); // keep the refresh path valid; never shown + lv_obj_add_flag(pin_val, LV_OBJ_FLAG_HIDDEN); +#endif + + tzLoad(); + lv_obj_t* tz_row = lv_obj_create(parent); + lv_obj_set_size(tz_row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(tz_row, lv_color_hex(S_COL_CARD), 0); + lv_obj_set_style_border_width(tz_row, 0, 0); + lv_obj_remove_flag(tz_row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* tz_lbl = lv_label_create(tz_row); + lv_label_set_text(tz_lbl, LV_SYMBOL_REFRESH " Timezone"); + lv_obj_align(tz_lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* tzm = lv_button_create(tz_row); + lv_obj_set_size(tzm, 30, 26); + lv_obj_align(tzm, LV_ALIGN_RIGHT_MID, -100, 0); + lv_obj_add_event_cb(tzm, tz_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)-1); + lv_obj_t* tzml = lv_label_create(tzm); lv_label_set_text(tzml, LV_SYMBOL_MINUS); lv_obj_center(tzml); + tz_val = lv_label_create(tz_row); + lv_label_set_text(tz_val, ""); + lv_obj_align(tz_val, LV_ALIGN_RIGHT_MID, -44, 0); + lv_obj_t* tzp = lv_button_create(tz_row); + lv_obj_set_size(tzp, 30, 26); + lv_obj_align(tzp, LV_ALIGN_RIGHT_MID, -4, 0); + lv_obj_add_event_cb(tzp, tz_step_cb, LV_EVENT_CLICKED, (void*)(intptr_t)+1); + lv_obj_t* tzpl = lv_label_create(tzp); lv_label_set_text(tzpl, LV_SYMBOL_PLUS); lv_obj_center(tzpl); + + lv_obj_t* bkp_btn = lv_button_create(parent); + lv_obj_set_size(bkp_btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(bkp_btn, lv_color_hex(S_COL_CARD), 0); + lv_obj_add_event_cb(bkp_btn, backup_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* bkp_lbl = lv_label_create(bkp_btn); + lv_label_set_text(bkp_lbl, LV_SYMBOL_SD_CARD " Backup node to SD"); + lv_obj_center(bkp_lbl); + + lv_obj_t* rst_btn = lv_button_create(parent); + lv_obj_set_size(rst_btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(rst_btn, lv_color_hex(S_COL_CARD), 0); + lv_obj_add_event_cb(rst_btn, restore_cb, LV_EVENT_CLICKED, NULL); + restore_lbl = lv_label_create(rst_btn); + lv_label_set_text(restore_lbl, LV_SYMBOL_DOWNLOAD " Restore from SD"); + lv_obj_center(restore_lbl); + + lv_obj_t* fct_btn = lv_button_create(parent); + lv_obj_set_size(fct_btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(fct_btn, lv_color_hex(0x7A2020), 0); + lv_obj_add_event_cb(fct_btn, factory_cb, LV_EVENT_CLICKED, NULL); + factory_lbl = lv_label_create(fct_btn); + lv_label_set_text(factory_lbl, LV_SYMBOL_WARNING " Factory reset"); + lv_obj_center(factory_lbl); + + edit_scr = makeDialog(NULL); + edit_title = lv_label_create(edit_scr); + lv_label_set_text(edit_title, ""); + lv_obj_set_style_text_color(edit_title, lv_color_hex(S_COL_ACCENT), 0); + lv_obj_align(edit_title, LV_ALIGN_TOP_LEFT, 0, 0); + edit_ta = lv_textarea_create(edit_scr); + lv_textarea_set_one_line(edit_ta, true); + lv_textarea_set_max_length(edit_ta, 30); + lv_obj_set_width(edit_ta, 300); + styleTa(edit_ta); + lv_obj_align(edit_ta, LV_ALIGN_TOP_MID, 0, 40); + lv_obj_add_event_cb(edit_ta, edit_ta_cb, LV_EVENT_ALL, NULL); + // in the title row, clear of the keyboard + makeBtn(edit_scr, LV_SYMBOL_OK, edit_save_cb, NULL, LV_ALIGN_TOP_RIGHT, -78, 0, 72); + makeBtn(edit_scr, LV_SYMBOL_CLOSE, edit_cancel_cb, NULL, LV_ALIGN_TOP_RIGHT, -2, 0, 72); + edit_kb = lv_keyboard_create(edit_scr); + lv_keyboard_set_textarea(edit_kb, edit_ta); + kbAttachShiftBehavior(edit_kb); + lv_obj_set_size(edit_kb, 320, S_KB_H); + lv_obj_align(edit_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(edit_kb, LV_OBJ_FLAG_HIDDEN); + + radio_scr = makeDialog(NULL); + lv_obj_t* rt = lv_label_create(radio_scr); + lv_label_set_text(rt, "Radio settings"); + lv_obj_set_style_text_color(rt, lv_color_hex(S_COL_ACCENT), 0); + lv_obj_set_style_text_font(rt, &lv_font_montserrat_16, 0); + lv_obj_align(rt, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_t* rh = lv_label_create(radio_scr); + lv_label_set_text(rh, "Every node on your mesh must use the same values."); + lv_obj_set_style_text_font(rh, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rh, lv_color_hex(S_COL_MUTED), 0); + lv_obj_align(rh, LV_ALIGN_TOP_LEFT, 0, 20); + + static const char* labels[4] = {"Frequency MHz", "Bandwidth kHz", "Spread factor", "Coding rate"}; + for (int i = 0; i < 4; i++) { + int x = i * 76; + lv_obj_t* l = lv_label_create(radio_scr); + lv_label_set_text(l, labels[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, lv_color_hex(S_COL_MUTED), 0); + lv_obj_align(l, LV_ALIGN_TOP_LEFT, x + 1, 40); + radio_ta[i] = lv_textarea_create(radio_scr); + lv_textarea_set_one_line(radio_ta[i], true); + lv_textarea_set_max_length(radio_ta[i], 8); + lv_obj_set_width(radio_ta[i], 72); + styleTa(radio_ta[i]); + lv_obj_align(radio_ta[i], LV_ALIGN_TOP_LEFT, x, 56); + lv_obj_add_event_cb(radio_ta[i], radio_ta_cb, LV_EVENT_ALL, NULL); + } + + radio_preset_dd = lv_dropdown_create(radio_scr); + char dd_opts[160] = "(presets)"; + for (unsigned i = 0; i < NUM_RADIO_PRESETS; i++) { + strlcat(dd_opts, "\n", sizeof(dd_opts)); + strlcat(dd_opts, RADIO_PRESETS[i].label, sizeof(dd_opts)); + } + lv_dropdown_set_options(radio_preset_dd, dd_opts); + lv_obj_set_size(radio_preset_dd, 304, 32); + lv_obj_align(radio_preset_dd, LV_ALIGN_TOP_MID, 0, 96); + lv_obj_add_event_cb(radio_preset_dd, radio_preset_cb, LV_EVENT_VALUE_CHANGED, NULL); + + // repeat mode: this node forwards other nodes' packets + lv_obj_t* rep_lbl = lv_label_create(radio_scr); + lv_label_set_text(rep_lbl, "Repeat mode"); + lv_obj_set_style_text_color(rep_lbl, lv_color_hex(S_COL_MUTED), 0); + lv_label_set_text(rep_lbl, "Repeat mode - this node relays for others"); + lv_obj_set_style_text_font(rep_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(rep_lbl, LV_ALIGN_TOP_LEFT, 2, 142); + radio_repeat_sw = lv_switch_create(radio_scr); + lv_obj_align(radio_repeat_sw, LV_ALIGN_TOP_RIGHT, -2, 134); + lv_obj_add_event_cb(radio_repeat_sw, radio_repeat_cb, LV_EVENT_VALUE_CHANGED, NULL); + + lv_obj_t* rep_hint = lv_label_create(radio_scr); + lv_label_set_text(rep_hint, "Repeat mode runs on its own frequency\n(433.000 / 869.495 / 918.000 MHz) and is\nset here automatically."); + lv_obj_set_style_text_font(rep_hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_hint, lv_color_hex(S_COL_MUTED), 0); + lv_obj_set_width(rep_hint, 304); + lv_label_set_long_mode(rep_hint, LV_LABEL_LONG_WRAP); + lv_obj_align(rep_hint, LV_ALIGN_TOP_LEFT, 2, 162); + + radio_apply_btn = makeBtn(radio_scr, LV_SYMBOL_OK " Apply", radio_apply_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 0, -2, 148); + radio_apply_lbl = lv_obj_get_child(radio_apply_btn, 0); + makeBtn(radio_scr, LV_SYMBOL_CLOSE " Cancel", radio_cancel_cb, NULL, LV_ALIGN_BOTTOM_RIGHT, 0, -2, 148); + + radio_kb = lv_keyboard_create(radio_scr); + lv_keyboard_set_mode(radio_kb, LV_KEYBOARD_MODE_NUMBER); + lv_obj_set_style_text_font(radio_kb, &lv_font_montserrat_16, LV_PART_ITEMS); + lv_obj_set_size(radio_kb, 320, 128); // numeric pad: keep the fields visible while typing + lv_obj_align(radio_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(radio_kb, LV_OBJ_FLAG_HIDDEN); + + settingsRefreshRows(); +} diff --git a/examples/companion_radio/ui-lvgl/SettingsUI.h b/examples/companion_radio/ui-lvgl/SettingsUI.h new file mode 100644 index 0000000000..2cece8fe58 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/SettingsUI.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +class UITask; + +// On-device node & radio configuration rows for the Settings tab: +// node name, radio params (freq/BW/SF/CR with preset prefills), TX power, +// BLE pin. Applies through the same internals the phone commands use. +void settingsBuildExtras(lv_obj_t* parent, UITask* task); +void settingsRefreshRows(); +int settingsTzOffset(); // UTC offset in hours (persisted) + +// exports for the first-boot wizard and about screen +int settingsPresetCount(); +const char* settingsPresetLabel(int idx); +void settingsApplyPreset(int idx); // sets + saves radio params, no confirm +void settingsSetTzOffset(int hours); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp new file mode 100644 index 0000000000..3c19d699e8 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -0,0 +1,3448 @@ +#include "UITask.h" +#include +#include "../MyMesh.h" +#include "target.h" +#include +#include +#include +#include +#ifndef STANDALONE_NO_BT + #include +#endif +#include "Sound.h" +#include "MapView.h" +#include "SettingsUI.h" + +// from base64.hpp, which defines (not just declares) its functions and is +// already compiled into BaseChatMesh.cpp - re-including would double-define +unsigned int encode_base64(const unsigned char input[], unsigned int len, unsigned char output[]); +unsigned int decode_base64(const unsigned char input[], unsigned int len, unsigned char output[]); + +#ifndef AUTO_OFF_MILLIS + #define AUTO_OFF_MILLIS 60000 +#endif + +// --------------------------------------------------------------------------- +// theme +// --------------------------------------------------------------------------- +#define COL_BG 0x0A1020 // deep navy +#define COL_CARD 0x16233B // panel/card +#define COL_ACCENT 0x58B4FF // meshcore sky blue +#define COL_ACCENT_D 0x1F4E7A // outgoing bubble +#define COL_TXT 0xE8ECF2 +#define COL_MUTED 0x8FA3BF +#define COL_WARN 0xFFA030 + +// --------------------------------------------------------------------------- +// LVGL <-> LovyanGFX glue +// --------------------------------------------------------------------------- +static UITask* ui = NULL; // singleton for LVGL callbacks + +static void lv_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { + auto gfx = (lgfx::LGFX_Device*) lv_display_get_user_data(disp); + int w = area->x2 - area->x1 + 1; + int h = area->y2 - area->y1 + 1; + gfx->startWrite(); + gfx->setAddrWindow(area->x1, area->y1, w, h); + gfx->pushPixels((uint16_t*) px_map, (uint32_t)w * h, true /* swap bytes */); + gfx->endWrite(); + lv_display_flush_ready(disp); +} + +static void lv_touch_cb(lv_indev_t* indev, lv_indev_data_t* data) { + auto gfx = (lgfx::LGFX_Device*) lv_indev_get_user_data(indev); + lgfx::touch_point_t tp; + if (gfx->getTouch(&tp, 1) > 0) { + data->state = LV_INDEV_STATE_PRESSED; + data->point.x = tp.x; + data->point.y = tp.y; + } else { + data->state = LV_INDEV_STATE_RELEASED; + } +} + +// --------------------------------------------------------------------------- +// widget handles +// --------------------------------------------------------------------------- +static lv_obj_t* status_bar; +static lv_obj_t* lbl_node_name; +static lv_obj_t* lbl_status_right; +static lv_obj_t* tabview; +static lv_obj_t* tab_chats; +static lv_obj_t* tab_contacts; +static lv_obj_t* tab_map; +static lv_obj_t* tab_node; +static lv_obj_t* tab_settings; +// add-channel dialog +static lv_obj_t* addch_scr; +static lv_obj_t* addch_name_ta; +static lv_obj_t* addch_psk_ta; +static lv_obj_t* addch_kb; +static lv_obj_t* chats_list; +static lv_obj_t* contacts_list; +static lv_obj_t* node_info_lbl; +// thread overlay +static lv_obj_t* thread_scr; +static lv_obj_t* thread_title; +static lv_obj_t* thread_sub_lbl; // route (flood/hops) + repeat-echo count +static lv_obj_t* thread_msgs; +static lv_obj_t* thread_input_row; +static lv_obj_t* thread_ta; +static lv_obj_t* thread_kb; + +#define KB_HEIGHT 160 + +// UI font: the icon glyphs the screens use, falling back to montserrat for text. +LV_FONT_DECLARE(fa_icons_14); + +#define SYMBOL_TOWER "\xEF\x94\x99" /* U+F519 broadcast tower */ +#define SYMBOL_ADDR_BOOK "\xEF\x8A\xB9" /* U+F2B9 address book */ + +// boot splash +const lv_image_dsc_t* meshcoreLogoImage(); +static lv_obj_t* splash_scr; +static lv_obj_t* wiz_scr = NULL; // first-boot setup wizard +static bool wizard_pending = false; +static void wizSuggestTz(); + +static void splash_dismiss_cb(lv_timer_t* t) { + lv_obj_delete(splash_scr); + splash_scr = NULL; + lv_timer_delete(t); + if (wizard_pending && wiz_scr != NULL) { wizSuggestTz(); lv_obj_remove_flag(wiz_scr, LV_OBJ_FLAG_HIDDEN); } +} + +static void buildSplash() { + splash_scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(splash_scr, 320, 240); + lv_obj_set_style_bg_color(splash_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(splash_scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(splash_scr, 0, 0); + lv_obj_set_style_radius(splash_scr, 0, 0); + lv_obj_remove_flag(splash_scr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(splash_scr, LV_OBJ_FLAG_CLICKABLE); // eat taps during boot + + lv_obj_t* title = lv_image_create(splash_scr); + lv_image_set_src(title, meshcoreLogoImage()); + lv_obj_set_style_image_recolor(title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_image_recolor_opa(title, LV_OPA_COVER, 0); + lv_obj_align(title, LV_ALIGN_CENTER, 0, -26); + + lv_obj_t* sub = lv_label_create(splash_scr); + lv_label_set_text(sub, "Wio Tracker L2 Pro"); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_TXT), 0); + lv_obj_align(sub, LV_ALIGN_CENTER, 0, 2); + + lv_obj_t* ver = lv_label_create(splash_scr); + char vbuf[40]; + snprintf(vbuf, sizeof(vbuf), "%s %s", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); + lv_label_set_text(ver, vbuf); + lv_obj_set_style_text_color(ver, lv_color_hex(COL_MUTED), 0); + lv_obj_align(ver, LV_ALIGN_CENTER, 0, 26); + + lv_timer_create(splash_dismiss_cb, 2500, NULL); +} + +// invisible overlay that eats the wake-up tap while the display is dimmed +static lv_obj_t* sleep_shield; + +// repeater manager overlay +static lv_obj_t* rep_scr; +static lv_obj_t* rep_title; +static lv_obj_t* rep_body; +static lv_obj_t* rep_pw_ta; +static lv_obj_t* rep_kb; + +// mirrors RepeaterStats in examples/simple_repeater/MyMesh.h (wire format) +struct RepStats { + uint16_t batt_milli_volts; + uint16_t curr_tx_queue_len; + int16_t noise_floor; + int16_t last_rssi; + uint32_t n_packets_recv; + uint32_t n_packets_sent; + uint32_t total_air_time_secs; + uint32_t total_up_time_secs; + uint32_t n_sent_flood, n_sent_direct; + uint32_t n_recv_flood, n_recv_direct; + uint16_t err_events; + int16_t last_snr; // x 4 + uint16_t n_direct_dups, n_flood_dups; + uint32_t total_rx_air_time_secs; + uint32_t n_recv_errors; +}; +static RepStats rep_stats; +static bool rep_stats_valid = false; +static bool rep_stats_waiting = false; +static lv_obj_t* rep_stats_lbl; // card body: status or command reply +static lv_obj_t* rep_card_title; +static bool rep_show_reply = false; // card is showing a command reply +static char rep_last_cmd[24] = ""; +static bool rep_auto_refresh = false; // opt-in: poll status while open +static char rep_last_reply[110] = ""; +static lv_obj_t* rep_remember_cb; +static lv_obj_t* rep_hint_lbl; +static lv_obj_t* rep_flood_cb; +static bool rep_flood_on = false; // survives screen rebuilds while logging in +static lv_obj_t* rep_login_btn; + +// saved repeater passwords (device flash; the node itself is the boundary) +struct SavedPw { uint8_t key[6]; char pw[26]; }; +#define MAX_SAVED_PW 8 +static SavedPw saved_pw[MAX_SAVED_PW]; +static char rep_pending_pw[26] = ""; +static bool rep_pending_remember = false; + +static void savedPwLoad() { + File f = SPIFFS.open("/rep_pw.bin", "r"); + if (f) { + f.read((uint8_t*)saved_pw, sizeof(saved_pw)); + f.close(); + } +} + +static void savedPwStore() { + File f = SPIFFS.open("/rep_pw.bin", "w"); + if (f) { + f.write((const uint8_t*)saved_pw, sizeof(saved_pw)); + f.close(); + } +} + +static const char* savedPwFor(const uint8_t key[6]) { + for (int i = 0; i < MAX_SAVED_PW; i++) { + if (saved_pw[i].pw[0] != 0 && memcmp(saved_pw[i].key, key, 6) == 0) return saved_pw[i].pw; + } + return NULL; +} + +static void savedPwSet(const uint8_t key[6], const char* pw) { + int slot = -1; + for (int i = 0; i < MAX_SAVED_PW; i++) { + if (memcmp(saved_pw[i].key, key, 6) == 0) { slot = i; break; } + if (saved_pw[i].pw[0] == 0 && slot < 0) slot = i; + } + if (slot < 0) slot = 0; // full: overwrite oldest slot + memcpy(saved_pw[slot].key, key, 6); + StrHelper::strncpy(saved_pw[slot].pw, pw, sizeof(saved_pw[slot].pw)); + savedPwStore(); +} + +static void savedPwForget(const uint8_t key[6]) { + for (int i = 0; i < MAX_SAVED_PW; i++) { + if (memcmp(saved_pw[i].key, key, 6) == 0) saved_pw[i].pw[0] = 0; + } + savedPwStore(); +} + +// ---- small persisted comfort prefs ---- +static uint32_t auto_off_ms = AUTO_OFF_MILLIS; // 0 = never sleep +static bool clock_12h = false; +static bool ble_off_pref = false; +static bool ble_pref_applied = false; +static bool units_miles = false; +static int ble_autooff_min = 0; // 0 = never; else minutes idle before slow advertising +static bool ble_slow = false; +static bool grove_on = true; +static lv_obj_t* ble_sw = NULL; + +static int prefReadInt(const char* path, int dflt) { + File f = SPIFFS.open(path, "r"); + if (!f) return dflt; + int v = f.parseInt(); + f.close(); + return v; +} +static void prefWriteInt(const char* path, int v) { + File f = SPIFFS.open(path, "w"); + if (f) { f.print(v); f.close(); } +} +static void polishPrefsLoad() { + // only the dropdown values: a torn write could yield a near-zero timeout + int off = prefReadInt("/autooff", -1); + static const uint32_t valid_off[] = {30000, 60000, 120000, 300000, 0}; + for (uint32_t v : valid_off) { + if (off >= 0 && (uint32_t) off == v) { auto_off_ms = v; break; } + } + clock_12h = prefReadInt("/clock12", 0) != 0; + ble_off_pref = prefReadInt("/ble_off", 0) != 0; + int ba = prefReadInt("/ble_auto", 0); + ble_autooff_min = (ba == 10 || ba == 30 || ba == 60) ? ba : 0; + grove_on = prefReadInt("/grove", 1) != 0; + rep_auto_refresh = prefReadInt("/rep_auto", 0) != 0; + units_miles = prefReadInt("/units", 0) != 0; +} + +// Slow advertising instead of switching the radio off, so phones can still +// reconnect. Intervals are 0.625 ms units; the defaults are 0x20/0x40. +static void bleSlowAdvertising(bool slow) { +#ifndef STANDALONE_NO_BT + BLEAdvertising* adv = BLEDevice::getAdvertising(); + if (adv == NULL) return; + adv->setMinInterval(slow ? 1600 : 0x20); + adv->setMaxInterval(slow ? 2080 : 0x40); + adv->stop(); + adv->start(); +#endif + ble_slow = slow; +} + +// nothing to render while the screen sleeps, so drop to 80 MHz +static void screenPower(bool asleep) { + setCpuFrequencyMhz(asleep ? 80 : 240); +} + +static uint32_t autoOffMs() { return auto_off_ms == 0 ? 0x7FFFFFFFu : auto_off_ms; } + +// LiPo discharge curve (single cell, light load) instead of a straight line +static int battPercent(uint16_t mv) { + static const uint16_t v[] = {4200, 4100, 4000, 3900, 3800, 3700, 3600, 3500, 3300}; + static const uint8_t pc[] = { 100, 90, 78, 62, 45, 25, 10, 4, 0}; + if (mv >= v[0]) return 100; + for (int i = 1; i < 9; i++) { + if (mv >= v[i]) return pc[i] + (int)(mv - v[i]) * (pc[i-1] - pc[i]) / (v[i-1] - v[i]); + } + return 0; +} + +// ---- contact list filter chips ---- +static int contact_filter = 0; // 0 all, 1 chat, 2 repeater, 3 room +static bool contacts_by_name = false; +static lv_obj_t* filter_btns[5]; + +static void updateFilterChips() { + for (int i = 0; i < 5; i++) { + bool on = i == 4 ? contacts_by_name : (i == contact_filter); + lv_obj_set_style_bg_color(filter_btns[i], lv_color_hex(on ? COL_ACCENT : COL_CARD), 0); + } +} +static void contact_filter_cb(lv_event_t* e) { + int i = (int)(intptr_t) lv_event_get_user_data(e); + if (i == 4) contacts_by_name = !contacts_by_name; + else contact_filter = i; + updateFilterChips(); + ui->refreshContactsTab(); +} + +// ---- notification banner: sender + preview, tap to open the thread ---- +static lv_obj_t* notif_banner = NULL; +static lv_obj_t* notif_lbl = NULL; +static lv_timer_t* notif_timer = NULL; +static uint8_t notif_key[6]; +static char notif_name[36]; + +static void notif_hide_cb(lv_timer_t* tm) { + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + notif_timer = NULL; // repeat count 1: LVGL deletes the timer itself +} +static void notif_tap_cb(lv_event_t* e) { + if (notif_timer != NULL) { lv_timer_delete(notif_timer); notif_timer = NULL; } + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + ui->openThread(notif_key, notif_name); +} +static void showNotifBanner(const uint8_t key[6], const char* from_name, const char* text) { + if (notif_banner == NULL) return; + memcpy(notif_key, key, 6); + int ch_idx; + if (isChannelKey(key, &ch_idx) && from_name[0] != '#') snprintf(notif_name, sizeof(notif_name), "#%s", from_name); + else StrHelper::strncpy(notif_name, from_name, sizeof(notif_name)); + lv_label_set_text_fmt(notif_lbl, "%s\n%.60s", notif_name, text); + lv_obj_remove_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(notif_banner); + if (notif_timer != NULL) lv_timer_delete(notif_timer); + notif_timer = lv_timer_create(notif_hide_cb, 6000, NULL); + lv_timer_set_repeat_count(notif_timer, 1); +} + +// ---- channel options (long-press a channel row): rename / remove ---- +static lv_obj_t* chopt_scr; +static lv_obj_t* chopt_title; +static lv_obj_t* chopt_ta; +static lv_obj_t* chopt_kb; +static lv_obj_t* chopt_remove_lbl; +static int chopt_idx = -1; +static uint8_t chopt_key[6]; +static bool chopt_remove_armed = false; + +static void chopt_close() { + lv_obj_add_flag(chopt_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); +} +static void chopt_cancel_cb(lv_event_t* e) { chopt_close(); } +static void chopt_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) lv_obj_remove_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); + else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL || code == LV_EVENT_READY) lv_obj_add_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); +} +static void chopt_save_cb(lv_event_t* e) { + const char* txt = lv_textarea_get_text(chopt_ta); + if (txt != NULL && txt[0] == '#') txt++; + if (txt == NULL || txt[0] == 0) { ui->showToast("Channel needs a name"); return; } + ChannelDetails ch; + if (chopt_idx < 0 || !the_mesh.getChannel(chopt_idx, ch)) { chopt_close(); return; } + StrHelper::strncpy(ch.name, txt, sizeof(ch.name)); + the_mesh.setChannel(chopt_idx, ch); + the_mesh.saveChannels(); + ui->refreshChatsTab(); + ui->showToast("Channel renamed"); + chopt_close(); +} +static void chopt_remove_cb(lv_event_t* e) { + if (!chopt_remove_armed) { + chopt_remove_armed = true; + lv_label_set_text(chopt_remove_lbl, "SURE? (removes channel + history)"); + return; + } + ChannelDetails ch; + if (chopt_idx >= 0 && the_mesh.getChannel(chopt_idx, ch)) { + memset(&ch, 0, sizeof(ch)); // empty name = free slot + the_mesh.setChannel(chopt_idx, ch); + the_mesh.saveChannels(); + chatStoreClearThread(chopt_key); + ui->refreshChatsTab(); + ui->showToast("Channel removed"); + } + chopt_close(); +} + +// ---- about & help ---- +static lv_obj_t* about_scr; +static lv_obj_t* about_lbl; +static void about_close_cb(lv_event_t* e) { lv_obj_add_flag(about_scr, LV_OBJ_FLAG_HIDDEN); } +static void about_advert_cb(lv_event_t* e) { + ui->showToast(the_mesh.advertFlood() ? "Flood advert sent" : "Advert failed"); +} +static void about_open_cb(lv_event_t* e) { + char hex[65]; + const uint8_t* pk = the_mesh.selfId().pub_key; + for (int i = 0; i < 32; i++) snprintf(&hex[i * 2], 3, "%02X", pk[i]); + char buf[640]; + snprintf(buf, sizeof(buf), +#ifdef STANDALONE_NO_BT + "MeshCore %s (%s)\nWio Tracker L2 Pro - standalone (no Bluetooth)\n\n" +#else + "MeshCore %s (%s)\nWio Tracker L2 Pro - standalone + Bluetooth\n\n" +#endif + "Node: %s\nPublic key:\n%.32s\n%.32s\n\n" + "Legend\n" + LV_SYMBOL_OK " delivered " LV_SYMBOL_REFRESH " sending " LV_SYMBOL_WARNING " not delivered (tap to retry)\n" + LV_SYMBOL_LOOP "N repeaters heard repeating your message\n" + "flood = no path yet, sent everywhere\n" + "direct = 0 hops, N hops = via repeaters\n" + "#name = channel (group chat)\n" + "Long-press a contact or channel for options.\n" + "Advert = announce this node to the mesh.", + FIRMWARE_VERSION, FIRMWARE_BUILD_DATE, the_mesh.getNodeName(), hex, hex + 32); + lv_label_set_text(about_lbl, buf); + lv_obj_remove_flag(about_scr, LV_OBJ_FLAG_HIDDEN); +} + +// ---- first-boot setup wizard: region, name, timezone ---- +static lv_obj_t* wiz_title; +static lv_obj_t* wiz_steps[3]; +static lv_obj_t* wiz_dd; +static lv_obj_t* wiz_name_ta; +static lv_obj_t* wiz_kb; +static lv_obj_t* wiz_tz_lbl; +static lv_obj_t* wiz_next_lbl; +static int wiz_step = 0; +static int wiz_tz = -5; + +static void wizShowStep(int s) { + wiz_step = s; + static const char* titles[3] = {"Welcome to MeshCore", "Name your node", "Set your timezone"}; + lv_label_set_text(wiz_title, titles[s]); + for (int i = 0; i < 3; i++) { + if (i == s) lv_obj_remove_flag(wiz_steps[i], LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(wiz_steps[i], LV_OBJ_FLAG_HIDDEN); + } + lv_label_set_text(wiz_next_lbl, s == 2 ? LV_SYMBOL_OK " Finish" : "Next " LV_SYMBOL_RIGHT); + if (s != 1) lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); +} +static void wiz_next_cb(lv_event_t* e) { + if (wiz_step < 2) { wizShowStep(wiz_step + 1); return; } + ui->finishSetupWizard((int) lv_dropdown_get_selected(wiz_dd), lv_textarea_get_text(wiz_name_ta), wiz_tz, true); +} +static void wiz_skip_cb(lv_event_t* e) { ui->finishSetupWizard(0, "", wiz_tz, false); } +static void wiz_tz_cb(lv_event_t* e) { + int v = wiz_tz + (int)(intptr_t) lv_event_get_user_data(e); + if (v < -12 || v > 14) return; + wiz_tz = v; + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d", wiz_tz); +} +static void wiz_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) lv_obj_remove_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); + else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL || code == LV_EVENT_READY) lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); +} + +static lv_obj_t* makeOverlay() { + lv_obj_t* scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(scr, 320, 240); + lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(scr, 0, 0); + lv_obj_set_style_radius(scr, 0, 0); + lv_obj_set_style_pad_all(scr, 8, 0); + lv_obj_add_flag(scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + return scr; +} +static lv_obj_t* overlayBtn(lv_obj_t* parent, const char* txt, lv_event_cb_t cb, void* ud, + lv_align_t align, int xo, int yo, int w, bool accent) { + lv_obj_t* b = lv_button_create(parent); + lv_obj_set_size(b, w, 32); + lv_obj_align(b, align, xo, yo); + if (!accent) lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, ud); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, txt); + lv_obj_center(l); + return b; +} + +static void buildPolishOverlays() { + // notification banner (top layer, above everything) + notif_banner = lv_obj_create(lv_layer_top()); + lv_obj_set_size(notif_banner, 304, 46); + lv_obj_align(notif_banner, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_color(notif_banner, lv_color_hex(COL_ACCENT_D), 0); + lv_obj_set_style_radius(notif_banner, 8, 0); + lv_obj_set_style_pad_all(notif_banner, 6, 0); + lv_obj_set_style_border_width(notif_banner, 0, 0); + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(notif_banner, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(notif_banner, notif_tap_cb, LV_EVENT_CLICKED, NULL); + notif_lbl = lv_label_create(notif_banner); + lv_obj_set_width(notif_lbl, 290); + lv_label_set_long_mode(notif_lbl, LV_LABEL_LONG_CLIP); + lv_obj_set_style_text_font(notif_lbl, &lv_font_montserrat_12, 0); + lv_obj_add_flag(notif_banner, LV_OBJ_FLAG_HIDDEN); + + // channel options + chopt_scr = makeOverlay(); + chopt_title = lv_label_create(chopt_scr); + lv_obj_set_style_text_color(chopt_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(chopt_title, LV_ALIGN_TOP_LEFT, 0, 6); + overlayBtn(chopt_scr, LV_SYMBOL_OK, chopt_save_cb, NULL, LV_ALIGN_TOP_RIGHT, -78, 0, 72, true); + overlayBtn(chopt_scr, LV_SYMBOL_CLOSE, chopt_cancel_cb, NULL, LV_ALIGN_TOP_RIGHT, -2, 0, 72, false); + chopt_ta = lv_textarea_create(chopt_scr); + lv_textarea_set_one_line(chopt_ta, true); + lv_textarea_set_max_length(chopt_ta, 30); + lv_textarea_set_placeholder_text(chopt_ta, "Channel name"); + lv_obj_set_width(chopt_ta, 304); + lv_obj_set_height(chopt_ta, LV_SIZE_CONTENT); + lv_obj_align(chopt_ta, LV_ALIGN_TOP_MID, 0, 40); + lv_obj_set_style_opa(chopt_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(chopt_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(chopt_ta, chopt_ta_cb, LV_EVENT_ALL, NULL); + lv_obj_t* rb = overlayBtn(chopt_scr, LV_SYMBOL_TRASH " Remove channel", chopt_remove_cb, NULL, LV_ALIGN_TOP_MID, 0, 84, 304, false); + lv_obj_set_style_bg_color(rb, lv_color_hex(0x7A2020), 0); + chopt_remove_lbl = lv_obj_get_child(rb, 0); + chopt_kb = lv_keyboard_create(chopt_scr); + lv_keyboard_set_textarea(chopt_kb, chopt_ta); + kbAttachShiftBehavior(chopt_kb); + lv_obj_set_size(chopt_kb, 320, KB_HEIGHT); + lv_obj_align(chopt_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(chopt_kb, LV_OBJ_FLAG_HIDDEN); + + about_scr = makeOverlay(); + lv_obj_t* body = lv_obj_create(about_scr); + lv_obj_set_size(body, 304, 186); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + about_lbl = lv_label_create(body); + lv_obj_set_width(about_lbl, 290); + lv_label_set_long_mode(about_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(about_lbl, &lv_font_montserrat_12, 0); + lv_label_set_text(about_lbl, ""); + overlayBtn(about_scr, LV_SYMBOL_UPLOAD " Send advert", about_advert_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 0, -2, 148, false); + overlayBtn(about_scr, LV_SYMBOL_CLOSE " Close", about_close_cb, NULL, LV_ALIGN_BOTTOM_RIGHT, 0, -2, 148, true); + + // first-boot wizard + wiz_scr = makeOverlay(); + wiz_title = lv_label_create(wiz_scr); + lv_obj_set_style_text_color(wiz_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_text_font(wiz_title, &lv_font_montserrat_16, 0); + lv_obj_align(wiz_title, LV_ALIGN_TOP_LEFT, 0, 0); + for (int i = 0; i < 3; i++) { + wiz_steps[i] = lv_obj_create(wiz_scr); + lv_obj_set_size(wiz_steps[i], 304, 150); + lv_obj_align(wiz_steps[i], LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_opa(wiz_steps[i], LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wiz_steps[i], 0, 0); + lv_obj_set_style_pad_all(wiz_steps[i], 0, 0); + lv_obj_remove_flag(wiz_steps[i], LV_OBJ_FLAG_SCROLLABLE); + } + // step 0: region preset + lv_obj_t* l0 = lv_label_create(wiz_steps[0]); + lv_obj_set_width(l0, 300); + lv_label_set_long_mode(l0, LV_LABEL_LONG_WRAP); + lv_label_set_text(l0, "Pick your region so the radio settings match the other MeshCore nodes around you. You can change this later in Settings."); + lv_obj_align(l0, LV_ALIGN_TOP_LEFT, 0, 0); + char opts[200] = ""; + for (int i = 0; i < settingsPresetCount(); i++) { + if (i > 0) strlcat(opts, "\n", sizeof(opts)); + strlcat(opts, settingsPresetLabel(i), sizeof(opts)); + } + wiz_dd = lv_dropdown_create(wiz_steps[0]); + lv_obj_set_size(wiz_dd, 300, 34); + lv_obj_align(wiz_dd, LV_ALIGN_TOP_MID, 0, 80); + lv_dropdown_set_options(wiz_dd, opts); + // step 1: node name + lv_obj_t* l1 = lv_label_create(wiz_steps[1]); + lv_obj_set_width(l1, 300); + lv_label_set_long_mode(l1, LV_LABEL_LONG_WRAP); + lv_label_set_text(l1, "Give this node a name. Other people see it in their contact list."); + lv_obj_align(l1, LV_ALIGN_TOP_LEFT, 0, 0); + wiz_name_ta = lv_textarea_create(wiz_steps[1]); + lv_textarea_set_one_line(wiz_name_ta, true); + lv_textarea_set_max_length(wiz_name_ta, 30); + lv_obj_set_width(wiz_name_ta, 300); + lv_obj_set_height(wiz_name_ta, LV_SIZE_CONTENT); + lv_obj_align(wiz_name_ta, LV_ALIGN_TOP_MID, 0, 44); + lv_obj_set_style_opa(wiz_name_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(wiz_name_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(wiz_name_ta, wiz_ta_cb, LV_EVENT_ALL, NULL); + // step 2: timezone + lv_obj_t* l2 = lv_label_create(wiz_steps[2]); + lv_obj_set_width(l2, 300); + lv_label_set_long_mode(l2, LV_LABEL_LONG_WRAP); + lv_label_set_text(l2, "Hours offset from UTC so message times show local time. Examples: New York -5, London 0, Berlin +1, Sydney +10."); + lv_obj_align(l2, LV_ALIGN_TOP_LEFT, 0, 0); + overlayBtn(wiz_steps[2], LV_SYMBOL_MINUS, wiz_tz_cb, (void*)(intptr_t)-1, LV_ALIGN_TOP_MID, -70, 80, 50, false); + wiz_tz_lbl = lv_label_create(wiz_steps[2]); + lv_obj_set_style_text_font(wiz_tz_lbl, &lv_font_montserrat_16, 0); + lv_obj_align(wiz_tz_lbl, LV_ALIGN_TOP_MID, 0, 88); + overlayBtn(wiz_steps[2], LV_SYMBOL_PLUS, wiz_tz_cb, (void*)(intptr_t)+1, LV_ALIGN_TOP_MID, 70, 80, 50, false); + // nav + overlayBtn(wiz_scr, "Skip", wiz_skip_cb, NULL, LV_ALIGN_BOTTOM_LEFT, 0, -2, 100, false); + lv_obj_t* nb = overlayBtn(wiz_scr, "Next", wiz_next_cb, NULL, LV_ALIGN_BOTTOM_RIGHT, 0, -2, 148, true); + wiz_next_lbl = lv_obj_get_child(nb, 0); + wiz_kb = lv_keyboard_create(wiz_scr); + lv_keyboard_set_textarea(wiz_kb, wiz_name_ta); + kbAttachShiftBehavior(wiz_kb); + lv_obj_set_size(wiz_kb, 320, KB_HEIGHT); + lv_obj_align(wiz_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); + wiz_tz = settingsTzOffset(); + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d", wiz_tz); + if (ui->nodePrefs() != NULL) lv_textarea_set_text(wiz_name_ta, ui->nodePrefs()->node_name); + wizShowStep(0); +} + +// when the wizard opens with a GPS fix available, suggest the zone from longitude +static void wizSuggestTz() { + SensorManager* s = ui->sensors(); + LocationProvider* nmea = s != NULL ? s->getLocationProvider() : NULL; + if (nmea == NULL || !nmea->isValid()) return; + int guess = (int) lroundf((nmea->getLongitude() / 1000000.0f) / 15.0f); + if (guess < -12 || guess > 14) return; + wiz_tz = guess; + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d (from GPS)", wiz_tz); +} + +// re-run the wizard on demand from Settings +static void wizardOpen() { + if (ui->nodePrefs() != NULL) lv_textarea_set_text(wiz_name_ta, ui->nodePrefs()->node_name); + wiz_tz = settingsTzOffset(); + lv_label_set_text_fmt(wiz_tz_lbl, "UTC%+d", wiz_tz); + wizSuggestTz(); + wizShowStep(0); + lv_obj_remove_flag(wiz_scr, LV_OBJ_FLAG_HIDDEN); +} +static void wizard_row_cb(lv_event_t* e) { wizardOpen(); } + +// ---- quick replies: canned messages sendable without the keyboard ---- +#define QR_MAX 6 +#define QR_TEXT_LEN 64 +static char qr_texts[QR_MAX][QR_TEXT_LEN]; +static int qr_count = 0; +static lv_obj_t* qr_panel = NULL; +static lv_obj_t* thread_qr_btn; +static lv_obj_t* qredit_scr; +static lv_obj_t* qredit_ta; +static lv_obj_t* qredit_kb; + +static void qrDefaults() { + static const char* defs[] = {"On my way", "Yes", "No", "Copy that", "At camp, all good"}; + qr_count = 0; + for (auto d : defs) StrHelper::strncpy(qr_texts[qr_count++], d, QR_TEXT_LEN); +} + +static void qrLoad() { + File f = SPIFFS.open("/qreplies.txt", "r"); + if (!f) { qrDefaults(); return; } + qr_count = 0; + while (f.available() && qr_count < QR_MAX) { + String line = f.readStringUntil('\n'); + line.trim(); + if (line.length() > 0) StrHelper::strncpy(qr_texts[qr_count++], line.c_str(), QR_TEXT_LEN); + } + f.close(); + if (qr_count == 0) qrDefaults(); +} + +static void qrSave() { + File f = SPIFFS.open("/qreplies.txt", "w"); + if (!f) return; + for (int i = 0; i < qr_count; i++) { + f.print(qr_texts[i]); + f.print('\n'); + } + f.close(); +} + +// ---- backlight brightness (percent, persisted) ---- +static uint8_t bright_pct = 60; +static bool bright_dimmed = false; +#define DIM_LEAD_MILLIS 15000 + +static uint8_t brightRaw() { return (uint8_t)((uint16_t) bright_pct * 255 / 100); } + +static void brightLoad() { + File f = SPIFFS.open("/bright", "r"); + if (f) { + int v = f.parseInt(); + f.close(); + if (v >= 10 && v <= 100) bright_pct = (uint8_t) v; + } +} + +static void brightSave() { + File f = SPIFFS.open("/bright", "w"); + if (f) { + f.print((int) bright_pct); + f.close(); + } +} + +// ---- phone-style keyboard shift: one-shot upper, double-tap = caps lock ---- +struct KbShiftState { bool prev_upper; bool shift_pending; bool caps; }; +static KbShiftState kb_shift_states[10]; +static int kb_shift_count = 0; + +static void kb_shift_cb(lv_event_t* e) { + KbShiftState* st = (KbShiftState*) lv_event_get_user_data(e); + lv_obj_t* kb = (lv_obj_t*) lv_event_get_target(e); + lv_keyboard_mode_t mode = lv_keyboard_get_mode(kb); + if (mode != LV_KEYBOARD_MODE_TEXT_LOWER && mode != LV_KEYBOARD_MODE_TEXT_UPPER) { + st->prev_upper = false; // number/special map: shift state doesn't apply + st->shift_pending = st->caps = false; + return; + } + bool upper = mode == LV_KEYBOARD_MODE_TEXT_UPPER; + + if (upper && !st->prev_upper) { // shift tapped + st->shift_pending = true; + st->caps = false; + } else if (!upper && st->prev_upper) { // shift tapped again while shifted + if (st->shift_pending) { // double-tap: engage caps lock + st->caps = true; + st->shift_pending = false; + lv_keyboard_set_mode(kb, LV_KEYBOARD_MODE_TEXT_UPPER); + upper = true; + } else { + st->caps = false; + } + } else if (upper && st->shift_pending && !st->caps) { + // character typed on a one-shot shift: drop back to lower case + // (control glyphs are 3-byte UTF-8 symbols; keep shift through those) + uint32_t id = lv_keyboard_get_selected_button(kb); + const char* txt = lv_keyboard_get_button_text(kb, id); + if (txt != NULL && (uint8_t) txt[0] < 0x80 && strcmp(txt, "1#") != 0) { + st->shift_pending = false; + lv_keyboard_set_mode(kb, LV_KEYBOARD_MODE_TEXT_LOWER); + upper = false; + } + } + st->prev_upper = upper; +} + +void kbAttachShiftBehavior(lv_obj_t* kb) { + lv_obj_set_style_text_font(kb, &lv_font_montserrat_16, LV_PART_ITEMS); // bigger key labels + if (kb_shift_count >= 10) return; + KbShiftState* st = &kb_shift_states[kb_shift_count++]; + st->prev_upper = st->shift_pending = st->caps = false; + lv_obj_add_event_cb(kb, kb_shift_cb, LV_EVENT_VALUE_CHANGED, st); +} + +// per-row keys for the chats list (parallel to buttons, bounded) +#define MAX_THREAD_ROWS 24 +static uint8_t chats_row_keys[MAX_THREAD_ROWS][6]; + +// --------------------------------------------------------------------------- +// event callbacks +// --------------------------------------------------------------------------- +static void contact_row_cb(lv_event_t* e) { + int idx = (int)(intptr_t) lv_event_get_user_data(e); + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) return; + if (c.type == ADV_TYPE_CHAT) { + ui->openContactThread(c); + } else if (c.type == ADV_TYPE_REPEATER || c.type == ADV_TYPE_ROOM) { + ui->openRepeaterManager(c); + } else { + ui->showToast("Not a chat node"); + } +} + +static void contact_row_long_cb(lv_event_t* e) { + ui->openContactDetail((int)(intptr_t) lv_event_get_user_data(e)); +} + +// ---- contact detail overlay ---- +static lv_obj_t* detail_scr; +static lv_obj_t* detail_title; +static lv_obj_t* detail_info; +static lv_obj_t* detail_remove_lbl; +static lv_obj_t* detail_trace_btn; +static int detail_idx = -1; +static bool detail_remove_armed = false; + +static void detail_close_cb(lv_event_t* e) { lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); } + +static void detail_msg_cb(lv_event_t* e) { + ContactInfo c; + if (!the_mesh.getContactByIdx(detail_idx, c)) return; + lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); + if (c.type == ADV_TYPE_CHAT) ui->openContactThread(c); + else ui->openRepeaterManager(c); +} + +// ---- trace route overlay: per-hop SNR along the contact's path ---- +static lv_obj_t* trace_scr; +static lv_obj_t* trace_title; +static lv_obj_t* trace_lbl; + +static bool path_from_trace = false; // the picker returns where it came from +static void path_open_cb(lv_event_t* e); + +static void detail_trace_cb(lv_event_t* e) { ui->openTraceForContact(detail_idx); } +static void trace_rerun_cb(lv_event_t* e) { ui->openTraceForContact(detail_idx); } +static void trace_close_cb(lv_event_t* e) { lv_obj_add_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); } +static void trace_setpath_cb(lv_event_t* e) { + path_from_trace = true; + lv_obj_add_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); + path_open_cb(NULL); +} + +static void traceHashName(uint8_t hash, char* out, size_t sz) { + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots(); idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] != 0 && c.id.pub_key[0] == hash) { + StrHelper::strncpy(out, c.name, sz); + return; + } + } + snprintf(out, sz, "hop %02X", hash); +} + +// ---- manual route picker: up to 3 repeater hops or flood/direct ---- +#define PATH_MAX_HOPS 8 // the hop list scrolls, so this is not a screen limit +static lv_obj_t* path_scr; +static lv_obj_t* path_dd[PATH_MAX_HOPS]; +static lv_obj_t* path_hop_lbl[PATH_MAX_HOPS]; +static lv_obj_t* path_add_btn; +static int path_hops_shown = 1; +static int path_rep_idx[40]; // dropdown option order -> contact index +static int path_rep_count = 0; + +// only as many hop rows as are in use, plus the button that reveals the next +static void pathShowHops(int n) { + if (n < 1) n = 1; + if (n > PATH_MAX_HOPS) n = PATH_MAX_HOPS; + path_hops_shown = n; + for (int i = 0; i < PATH_MAX_HOPS; i++) { + if (i < n) { + lv_obj_remove_flag(path_dd[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(path_hop_lbl[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(path_dd[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(path_hop_lbl[i], LV_OBJ_FLAG_HIDDEN); + } + } + lv_obj_align(path_add_btn, LV_ALIGN_TOP_LEFT, 20, 4 + n * 38); + if (n < PATH_MAX_HOPS) lv_obj_remove_flag(path_add_btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(path_add_btn, LV_OBJ_FLAG_HIDDEN); +} + +static void path_add_cb(lv_event_t* e) { pathShowHops(path_hops_shown + 1); } + +static void path_open_cb(lv_event_t* e) { // NOLINT: forward declared above + char opts[600] = "(none)"; + path_rep_count = 0; + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots() && path_rep_count < 40; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] == 0 || c.type != ADV_TYPE_REPEATER) continue; + strlcat(opts, "\n", sizeof(opts)); + strlcat(opts, c.name, sizeof(opts)); + path_rep_idx[path_rep_count++] = idx; + } + if (path_rep_count == 0) { + ui->showToast("No repeaters known yet"); + return; + } + for (int i = 0; i < PATH_MAX_HOPS; i++) { + lv_dropdown_set_options(path_dd[i], opts); + lv_dropdown_set_selected(path_dd[i], 0); + } + + // show the path already in use, so it can be edited rather than retyped + int hops = 0; + ContactInfo cur; + if (the_mesh.getContactByIdx(detail_idx, cur) && cur.out_path_len != OUT_PATH_UNKNOWN) { + for (int h = 0; h < cur.out_path_len && h < PATH_MAX_HOPS; h++) { + for (int o = 0; o < path_rep_count; o++) { + ContactInfo rep_c; + if (!the_mesh.getContactByIdx(path_rep_idx[o], rep_c)) continue; + if (rep_c.id.pub_key[0] == cur.out_path[h]) { lv_dropdown_set_selected(path_dd[h], o + 1); break; } + } + hops++; + } + } + pathShowHops(hops); + lv_obj_remove_flag(path_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void path_flood_cb(lv_event_t* e) { + ContactInfo c; + if (the_mesh.getContactByIdx(detail_idx, c)) { + ContactInfo* live = the_mesh.lookupContactByPubKey(c.id.pub_key, 6); + if (live != NULL) { + the_mesh.resetPathTo(*live); + the_mesh.saveContacts(); + ui->showToast("Path reset to flood"); + } + } + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + if (path_from_trace) { path_from_trace = false; ui->openTraceForContact(detail_idx); } + else ui->openContactDetail(detail_idx); +} + +static void path_apply_cb(lv_event_t* e) { + ContactInfo c; + if (!the_mesh.getContactByIdx(detail_idx, c)) return; + ContactInfo* live = the_mesh.lookupContactByPubKey(c.id.pub_key, 6); + if (live == NULL) return; + + uint8_t pos = 0; + for (int i = 0; i < PATH_MAX_HOPS; i++) { + int sel = (int) lv_dropdown_get_selected(path_dd[i]); + if (sel < 1 || sel > path_rep_count) continue; // "(none)" + ContactInfo rep; + if (!the_mesh.getContactByIdx(path_rep_idx[sel - 1], rep)) continue; + pos += rep.id.copyHashTo(&live->out_path[pos]); + } + live->out_path_len = pos; // 0 hops = zero-hop direct + the_mesh.saveContacts(); + ui->showToast(pos == 0 ? "Path set: direct (0 hops)" : "Path set"); + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + if (path_from_trace) { path_from_trace = false; ui->openTraceForContact(detail_idx); } + else ui->openContactDetail(detail_idx); +} + +static void path_cancel_cb(lv_event_t* e) { + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + if (path_from_trace) { path_from_trace = false; lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); } +} + +static void detail_share_cb(lv_event_t* e) { + ContactInfo c; + if (!the_mesh.getContactByIdx(detail_idx, c)) return; + ui->showToast(the_mesh.shareContactZeroHop(c) ? "Contact shared (zero hop)" : "Share failed"); +} + +static void detail_remove_cb(lv_event_t* e) { + if (!detail_remove_armed) { + detail_remove_armed = true; + lv_label_set_text(detail_remove_lbl, "SURE?"); + return; + } + ContactInfo c; + if (the_mesh.getContactByIdx(detail_idx, c)) { + ContactInfo* live = the_mesh.lookupContactByPubKey(c.id.pub_key, 6); + if (live != NULL && the_mesh.removeContact(*live)) { + the_mesh.saveContacts(); + ui->showToast("Contact removed"); + } else { + ui->showToast("Remove failed"); + } + } + lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); + ui->refreshContactsTab(); + ui->refreshChatsTab(); +} + +static void rep_back_cb(lv_event_t* e) { ui->closeRepeaterManager(); } + +static const char* REP_ACTIONS[6] = {"advert", "clock sync", "ver", "neighbors", "clock", "reboot"}; +static void rep_cmd_cb(lv_event_t* e) { + int i = (int)(intptr_t) lv_event_get_user_data(e); + if (i < 0 || i >= 6) return; + rep_show_reply = true; // results land in the card at the top + // never push an unset clock to a repeater: a bad admin sync is exactly how + // repeaters end up years in the past for everyone + if (strcmp(REP_ACTIONS[i], "clock sync") == 0 && rtc_clock.getCurrentTime() < 1600000000UL) { + ui->showToast("This node has no valid time yet (GPS or app first)"); + return; + } + ui->sendRepeaterCommand(REP_ACTIONS[i]); +} + +static void rep_status_cb(lv_event_t* e) { + rep_show_reply = false; // back to the status view + ui->requestRepeaterStatus(); +} + +// used by both the Login button and the keyboard's accept key +static void repeaterLoginFromField() { + const char* pw = lv_textarea_get_text(rep_pw_ta); + if (pw == NULL || pw[0] == 0) { + pw = ui->savedRepeaterPw(); // empty field: fall back to saved password + if (pw == NULL) { + ui->showToast("Enter the repeater admin password"); + return; + } + } + rep_pending_remember = rep_remember_cb != NULL + && lv_obj_has_state(rep_remember_cb, LV_STATE_CHECKED); + bool flood = rep_flood_on; + StrHelper::strncpy(rep_pending_pw, pw, sizeof(rep_pending_pw)); + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + ui->repeaterLogin(pw, flood); + lv_textarea_set_text(rep_pw_ta, ""); +} + +static void rep_login_btn_cb(lv_event_t* e) { repeaterLoginFromField(); } + +static void rep_flood_toggle_cb(lv_event_t* e) { + rep_flood_on = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); +} + +static void rep_terminal_cb(lv_event_t* e) { ui->openConsoleThread(); } + +static void rep_auto_cb(lv_event_t* e) { + rep_auto_refresh = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/rep_auto", rep_auto_refresh ? 1 : 0); +} + +// the keyboard covers the lower screen: keep the field and checkbox above it +static void repLoginLayout(bool kb_visible) { + if (rep_pw_ta == NULL) return; + if (kb_visible) { + lv_obj_add_flag(rep_hint_lbl, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(rep_login_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(rep_flood_cb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(rep_pw_ta, LV_ALIGN_TOP_MID, 0, 2); + lv_obj_align(rep_remember_cb, LV_ALIGN_TOP_MID, 0, 30); + } else { + lv_obj_remove_flag(rep_hint_lbl, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(rep_login_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(rep_flood_cb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(rep_pw_ta, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_align(rep_remember_cb, LV_ALIGN_TOP_MID, 0, 58); + } +} + +static void rep_pw_event_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_obj_remove_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + repLoginLayout(true); + } else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL) { + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + repLoginLayout(false); + } else if (code == LV_EVENT_READY) { + repeaterLoginFromField(); // same path as the Login button + } +} + +// channels may already carry a leading '#' in their stored name +static void channelLabel(char* dest, size_t sz, const char* name) { + snprintf(dest, sz, "%s%s", name[0] == '#' ? "" : "#", name); +} + +static void chats_row_long_cb(lv_event_t* e) { + int row = (int)(intptr_t) lv_event_get_user_data(e); + if (row < 0 || row >= MAX_THREAD_ROWS) return; + int ch_idx; + if (isChannelKey(chats_row_keys[row], &ch_idx)) ui->openChannelOptions(chats_row_keys[row]); +} + +static void chats_row_cb(lv_event_t* e) { + int row = (int)(intptr_t) lv_event_get_user_data(e); + if (row < 0 || row >= MAX_THREAD_ROWS) return; + const uint8_t* key = chats_row_keys[row]; + + int ch_idx; + if (isChannelKey(key, &ch_idx)) { + ChannelDetails ch; + if (the_mesh.getChannel(ch_idx, ch) && ch.name[0]) { + char name[36]; + channelLabel(name, sizeof(name), ch.name); + ui->openThread(key, name); + } + } else { + ContactInfo* c = the_mesh.lookupContactByPubKey(key, 6); + if (c != NULL && c->type != ADV_TYPE_CHAT) { + ui->openRepeaterManager(*c); // console threads belong to the manager + return; + } + ui->openThread(key, c ? c->name : "(unknown)"); + } +} + +static void thread_back_cb(lv_event_t* e) { ui->closeThread(); } +static void thread_clear_cb(lv_event_t* e) { ui->clearCurrentThread(); } +static void thread_route_cb(lv_event_t* e) { ui->openRouteForThread(); } +static void bubble_retry_cb(lv_event_t* e) { ui->resendMessage((int)(intptr_t) lv_event_get_user_data(e)); } + +static void fmtAge(char* buf, size_t sz, uint32_t ts) { + long secs = (long) rtc_clock.getCurrentTime() - (long) ts; + if (secs < 0) secs = 0; + if (secs < 60) snprintf(buf, sz, "now"); + else if (secs < 3600) snprintf(buf, sz, "%ldm", secs / 60); + else if (secs < 86400) snprintf(buf, sz, "%ldh", secs / 3600); + else snprintf(buf, sz, "%ldd", secs / 86400); +} + +static void kb_show(bool show) { + if (show) { + lv_obj_remove_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + // lift the input row above the keyboard so typed text stays visible + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, -KB_HEIGHT); + } else { + lv_obj_add_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, 0); + } +} + +static void ta_event_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + // CLICKED too: a still-focused textarea fires no FOCUSED on re-tap, + // which left the keyboard unreachable after dismissing it + kb_show(true); + } else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL) { + kb_show(false); + } else if (code == LV_EVENT_READY) { // keyboard checkmark + const char* txt = lv_textarea_get_text(thread_ta); + if (txt != NULL && txt[0] != 0) { + ui->sendFromThread(txt); + lv_textarea_set_text(thread_ta, ""); + } + kb_show(false); + } +} + +// ---- quick reply panel (pops up over the thread, one tap to send) ---- +static void qrPanelClose() { + if (qr_panel != NULL) { + lv_obj_delete(qr_panel); + qr_panel = NULL; + } +} + +static void qr_pick_cb(lv_event_t* e) { + int i = (int)(intptr_t) lv_event_get_user_data(e); + qrPanelClose(); + if (i >= 0 && i < qr_count && qr_texts[i][0] != 0) ui->sendFromThread(qr_texts[i]); +} + +static void qr_loc_cb(lv_event_t* e) { + qrPanelClose(); + SensorManager* s = ui->sensors(); + LocationProvider* nmea = s != NULL ? s->getLocationProvider() : NULL; + if (nmea == NULL || !nmea->isValid()) { + ui->showToast("No GPS fix yet"); + return; + } + char msg[48]; + snprintf(msg, sizeof(msg), "I'm at %.5f, %.5f", + nmea->getLatitude() / 1000000.0, nmea->getLongitude() / 1000000.0); + ui->sendFromThread(msg); +} + +static void qr_open_cb(lv_event_t* e) { + if (qr_panel != NULL) { qrPanelClose(); return; } + kb_show(false); + + qr_panel = lv_obj_create(thread_scr); + lv_obj_set_size(qr_panel, 250, LV_SIZE_CONTENT); + lv_obj_set_style_max_height(qr_panel, 168, 0); + lv_obj_align(qr_panel, LV_ALIGN_BOTTOM_LEFT, 2, -38); + lv_obj_set_style_bg_color(qr_panel, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_color(qr_panel, lv_color_hex(COL_ACCENT_D), 0); + lv_obj_set_style_pad_all(qr_panel, 4, 0); + lv_obj_set_flex_flow(qr_panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(qr_panel, 3, 0); + + for (int i = 0; i < qr_count; i++) { + lv_obj_t* b = lv_button_create(qr_panel); + lv_obj_set_size(b, LV_PCT(100), 26); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(b, qr_pick_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, qr_texts[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); + } + + SensorManager* s = ui->sensors(); + LocationProvider* nmea = s != NULL ? s->getLocationProvider() : NULL; + lv_obj_t* b = lv_button_create(qr_panel); + lv_obj_set_size(b, LV_PCT(100), 26); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(b, qr_loc_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, LV_SYMBOL_GPS " Send my location"); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + if (nmea == NULL || !nmea->isValid()) lv_obj_set_style_text_color(l, lv_color_hex(COL_MUTED), 0); + lv_obj_align(l, LV_ALIGN_LEFT_MID, 0, 0); +} + +// ---- add-channel dialog ---- +static void addch_open_cb(lv_event_t* e) { + lv_textarea_set_text(addch_name_ta, ""); + lv_textarea_set_text(addch_psk_ta, ""); + lv_obj_add_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void addch_cancel_cb(lv_event_t* e) { + lv_keyboard_set_textarea(addch_kb, NULL); + lv_obj_add_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void addch_ta_focus_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* ta = (lv_obj_t*) lv_event_get_target(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_keyboard_set_textarea(addch_kb, ta); + lv_obj_remove_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + } else if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL) { + lv_obj_add_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + } +} + +static void addch_create_cb(lv_event_t* e) { + const char* raw_name = lv_textarea_get_text(addch_name_ta); + while (*raw_name == '#') raw_name++; // stored names carry no leading '#' + if (raw_name[0] == 0) { + ui->showToast("Channel needs a name"); + return; + } + const char* psk = lv_textarea_get_text(addch_psk_ta); + char psk_b64[32]; + if (psk == NULL || psk[0] == 0) { + uint8_t key[16]; + esp_fill_random(key, sizeof(key)); + unsigned int b64len = encode_base64(key, sizeof(key), (unsigned char*)psk_b64); + psk_b64[b64len] = 0; + psk = psk_b64; + } + + // NOTE: BaseChatMesh::addChannel writes at its own counter which ignores + // flash-loaded channels (it would overwrite slot 0) - place the channel + // into the first truly empty slot ourselves + int slot = -1; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails t; + if (!the_mesh.getChannel(i, t)) break; + const char* existing = t.name[0] == '#' ? t.name + 1 : t.name; + if (t.name[0] != 0 && strcmp(existing, raw_name) == 0) { + ui->showToast("Channel already exists"); + return; + } + if (t.name[0] == 0 && slot < 0) slot = i; + } + if (slot < 0) { + ui->showToast("Channel table full"); + return; + } + + ChannelDetails ch; + memset(&ch, 0, sizeof(ch)); + int keylen = decode_base64((const unsigned char*)psk, strlen(psk), ch.channel.secret); + if (keylen != 16 && keylen != 32) { + ui->showToast("PSK must be 16/32 bytes b64"); + return; + } + mesh::Utils::sha256(ch.channel.hash, sizeof(ch.channel.hash), ch.channel.secret, keylen); + StrHelper::strncpy(ch.name, raw_name, sizeof(ch.name)); + if (!the_mesh.setChannel(slot, ch)) { + ui->showToast("Channel add failed"); + return; + } + the_mesh.saveChannels(); + lv_keyboard_set_textarea(addch_kb, NULL); + lv_obj_add_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); + ui->refreshChatsTab(); + ui->showToast("Channel added"); +} + +static void sleep_shield_cb(lv_event_t* e) { + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); +} + +static void tabview_changed_cb(lv_event_t* e) { + ui->refreshChatsTab(); + ui->refreshContactsTab(); + ui->refreshNodeTab(); + mapViewRefresh(); + settingsRefreshRows(); +} + +static void advert_btn_cb(lv_event_t* e) { + bool flood = (intptr_t) lv_event_get_user_data(e) == 1; + bool ok = flood ? the_mesh.advertFlood() : the_mesh.advert(); + ui->showToast(ok ? (flood ? "Flood advert sent" : "Zero-hop advert sent") : "Advert failed"); +} + +static void reboot_btn_cb(lv_event_t* e) { ui->shutdown(true); } + +static void sound_switch_cb(lv_event_t* e) { + auto sw = (lv_obj_t*) lv_event_get_target(e); + bool on = lv_obj_has_state(sw, LV_STATE_CHECKED); + ui->nodePrefs()->buzzer_quiet = on ? 0 : 1; + the_mesh.savePrefs(); + if (on) soundAckTone(); // audible confirmation + ui->showToast(on ? "Sounds on" : "Sounds off"); +} + +// L76K (CASIC) GNSS: enable GPS + BeiDou + GLONASS so acquisition has more +// satellites to work with (board default is GPS + BeiDou only). Volatile +// setting; resent on every GPS start. + +static File gps_log; +static uint32_t gps_log_lines = 0; +static void gpsLogLine(const char* line) { + if (!gps_log) { + if (SD_MMC.cardType() == CARD_NONE || SD_MMC.cardType() == CARD_UNKNOWN) return; + gps_log = SD_MMC.open("/gps_nmea.log", FILE_APPEND); + if (!gps_log) return; + if (gps_log.size() > 2000000) { + gps_log.close(); + gps_log = SD_MMC.open("/gps_nmea.log", FILE_WRITE); + if (!gps_log) return; + } + gps_log.printf("--- boot +%lums ---\n", millis()); + } + gps_log.println(line); + if (++gps_log_lines % 25 == 0) gps_log.flush(); +} + +static void gpsEnableAllConstellations() { + Serial1.print("$PCAS04,7*1E\r\n"); +} + +// The L76K can track strong satellites for many minutes without producing a +// fix, and recovers from a reset. Pulse GNSS reset after 3 minutes of >=4 +// strong satellites with no fix, then re-send the constellation config. +static int gps_kicks = 0; +static unsigned long gps_stuck_since = 0; +static unsigned long gps_next_kick = 0; +static bool gps_reconfig_pending = false; + +static void gpsWatchdogTick() { + if (gps_reconfig_pending) { + gpsEnableAllConstellations(); + gps_reconfig_pending = false; + } + bool tracking = gps_tap.streaming() && gps_tap.strongSats() >= 4; + bool fixed = gps_tap.ggaFix() > 0 || gps_tap.rmcStatus() == 'A'; + if (fixed) { gps_stuck_since = 0; gps_kicks = 0; return; } + if (!tracking) { gps_stuck_since = 0; return; } + if (gps_stuck_since == 0) { gps_stuck_since = millis(); return; } + if (millis() - gps_stuck_since > 180000 && millis() > gps_next_kick) { + MESH_DEBUG_PRINTLN("gps watchdog: %d strong sats, no fix - GNSS reset #%d", + gps_tap.strongSats(), gps_kicks + 1); + board.gnssReset(); + gps_reconfig_pending = true; + gps_kicks++; + gps_next_kick = millis() + 180000; + gps_stuck_since = 0; + } +} + +static void gps_switch_cb(lv_event_t* e) { + auto sw = (lv_obj_t*) lv_event_get_target(e); + bool on = lv_obj_has_state(sw, LV_STATE_CHECKED); + SensorManager* sensors = ui->sensors(); + if (sensors) { + sensors->setSettingValue("gps", on ? "1" : "0"); + ui->nodePrefs()->gps_enabled = on ? 1 : 0; + board.setGnssPower(on); // really off, not just ignored + if (on) gps_reconfig_pending = true; // constellations re-sent once it has booted + the_mesh.savePrefs(); + ui->showToast(on ? "GPS enabled" : "GPS disabled"); + } +} + +// --------------------------------------------------------------------------- +// UITask +// --------------------------------------------------------------------------- +void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs* node_prefs) { + ui = this; + _sensors = sensors; + _node_prefs = node_prefs; + + chatStoreLoad(); + savedPwLoad(); + qrLoad(); + brightLoad(); + polishPrefsLoad(); + soundLoadTonePref(); + gps_tap.echo = false; // set true to mirror raw NMEA to the console + gps_tap.on_line = gpsLogLine; // raw NMEA log on the SD card + soundSetAmpControl([](bool on) { board.setSpeakerAmp(on); }); + soundInit(); // claim I2S DMA memory BEFORE LVGL takes its share + + lv_init(); + lv_tick_set_cb([]() -> uint32_t { return (uint32_t) millis(); }); + + auto gfx = display.lgfxDevice(); // concrete display from target.h + + lv_display_t* disp = lv_display_create(320, 240); + lv_display_set_user_data(disp, gfx); + lv_display_set_flush_cb(disp, lv_flush_cb); + + const uint32_t buf_sz = 320 * 60 * 2; // 60-line partial buffers + uint8_t* buf1 = (uint8_t*) heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM); + uint8_t* buf2 = (uint8_t*) heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM); + if (buf1 == NULL) { // PSRAM exhausted: fall back to internal RAM, single buffer + buf1 = (uint8_t*) heap_caps_malloc(buf_sz, MALLOC_CAP_8BIT); + buf2 = NULL; + } + if (buf1 == NULL) { + Serial.println("FATAL: no memory for LVGL framebuffers"); + while (true) { delay(1000); } // halt visibly rather than crash on flush + } + lv_display_set_buffers(disp, buf1, buf2, buf_sz, LV_DISPLAY_RENDER_MODE_PARTIAL); + + lv_indev_t* indev = lv_indev_create(); + lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); + lv_indev_set_user_data(indev, gfx); + lv_indev_set_read_cb(indev, lv_touch_cb); + + lv_theme_default_init(disp, lv_color_hex(COL_ACCENT), lv_color_hex(COL_ACCENT_D), + true /* dark */, &fa_icons_14); + + buildShell(); + buildRepeaterScr(); + buildPolishOverlays(); // wizard / about / banner / channel options + wizard_pending = !SPIFFS.exists("/setup_done"); + buildSplash(); + display.lgfxDevice()->setBrightness(brightRaw()); // apply stored brightness + refreshChatsTab(); + refreshContactsTab(); + refreshNodeTab(); + + // the saved GPS preference is never auto-applied at boot (gps_active + // starts false regardless), so re-apply it here + if (_node_prefs != NULL && _node_prefs->gps_enabled && _sensors != NULL) { + _sensors->setSettingValue("gps", "1"); + gpsEnableAllConstellations(); + } else { + board.setGnssPower(false); // GPS disabled: cut the receiver's rail + } + board.setGrovePower(grove_on); +} + +void UITask::buildRepeaterScr() { + lv_obj_t* scr = lv_screen_active(); + rep_scr = lv_obj_create(scr); + lv_obj_set_size(rep_scr, 320, 240); + lv_obj_align(rep_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(rep_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(rep_scr, 0, 0); + lv_obj_set_style_radius(rep_scr, 0, 0); + lv_obj_set_style_pad_all(rep_scr, 0, 0); + lv_obj_add_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(rep_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* hdr = lv_obj_create(rep_scr); + lv_obj_set_size(hdr, 320, 26); + lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(hdr, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(hdr, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_set_style_pad_all(hdr, 2, 0); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* back = lv_button_create(hdr); + lv_obj_set_size(back, 40, 20); + lv_obj_align(back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_add_event_cb(back, rep_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* back_lbl = lv_label_create(back); + lv_label_set_text(back_lbl, LV_SYMBOL_LEFT); + lv_obj_center(back_lbl); + + rep_title = lv_label_create(hdr); + lv_label_set_text(rep_title, ""); + lv_label_set_long_mode(rep_title, LV_LABEL_LONG_DOT); + lv_obj_set_width(rep_title, 250); + lv_obj_align(rep_title, LV_ALIGN_LEFT_MID, 50, 0); + + rep_body = lv_obj_create(rep_scr); + lv_obj_set_size(rep_body, 320, 240 - 26); + lv_obj_align(rep_body, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_color(rep_body, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(rep_body, 0, 0); + lv_obj_set_style_pad_all(rep_body, 8, 0); + + rep_kb = lv_keyboard_create(rep_scr); + kbAttachShiftBehavior(rep_kb); + lv_obj_set_size(rep_kb, 320, KB_HEIGHT); + lv_obj_align(rep_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + + addch_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(addch_scr, 320, 240); + lv_obj_align(addch_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(addch_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(addch_scr, 0, 0); + lv_obj_set_style_radius(addch_scr, 0, 0); + lv_obj_set_style_pad_all(addch_scr, 8, 0); + lv_obj_add_flag(addch_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(addch_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* addch_title = lv_label_create(addch_scr); + lv_label_set_text(addch_title, "New channel"); + lv_obj_set_style_text_color(addch_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(addch_title, LV_ALIGN_TOP_LEFT, 0, 0); + + addch_name_ta = lv_textarea_create(addch_scr); + lv_textarea_set_one_line(addch_name_ta, true); + lv_textarea_set_placeholder_text(addch_name_ta, "Channel name"); + lv_textarea_set_max_length(addch_name_ta, 30); + lv_obj_set_width(addch_name_ta, 300); + lv_obj_set_height(addch_name_ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(addch_name_ta, 5, 0); + lv_obj_set_scroll_dir(addch_name_ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(addch_name_ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(addch_name_ta, LV_ALIGN_TOP_MID, 0, 16); + lv_obj_add_event_cb(addch_name_ta, addch_ta_focus_cb, LV_EVENT_ALL, NULL); + + addch_psk_ta = lv_textarea_create(addch_scr); + lv_textarea_set_one_line(addch_psk_ta, true); + lv_textarea_set_placeholder_text(addch_psk_ta, "PSK base64 (blank = random)"); + lv_textarea_set_max_length(addch_psk_ta, 44); + lv_obj_set_width(addch_psk_ta, 300); + lv_obj_set_height(addch_psk_ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(addch_psk_ta, 5, 0); + lv_obj_set_scroll_dir(addch_psk_ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(addch_psk_ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(addch_psk_ta, LV_ALIGN_TOP_MID, 0, 46); + lv_obj_add_event_cb(addch_psk_ta, addch_ta_focus_cb, LV_EVENT_ALL, NULL); + + lv_obj_t* create_btn = lv_button_create(addch_scr); + lv_obj_set_size(create_btn, 140, 32); + lv_obj_align(create_btn, LV_ALIGN_TOP_LEFT, 4, 90); + lv_obj_add_event_cb(create_btn, addch_create_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* cl = lv_label_create(create_btn); + lv_label_set_text(cl, LV_SYMBOL_OK " Create"); + lv_obj_center(cl); + + lv_obj_t* cancel_btn = lv_button_create(addch_scr); + lv_obj_set_size(cancel_btn, 140, 32); + lv_obj_align(cancel_btn, LV_ALIGN_TOP_RIGHT, -4, 90); + lv_obj_set_style_bg_color(cancel_btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(cancel_btn, addch_cancel_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* xl = lv_label_create(cancel_btn); + lv_label_set_text(xl, LV_SYMBOL_CLOSE " Cancel"); + lv_obj_center(xl); + + addch_kb = lv_keyboard_create(addch_scr); + kbAttachShiftBehavior(addch_kb); + lv_obj_set_size(addch_kb, 320, KB_HEIGHT); + lv_obj_align(addch_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(addch_kb, LV_OBJ_FLAG_HIDDEN); + + detail_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(detail_scr, 320, 240); + lv_obj_align(detail_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(detail_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(detail_scr, 0, 0); + lv_obj_set_style_radius(detail_scr, 0, 0); + lv_obj_set_style_pad_all(detail_scr, 8, 0); + lv_obj_add_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(detail_scr, LV_OBJ_FLAG_SCROLLABLE); + + detail_title = lv_label_create(detail_scr); + lv_label_set_text(detail_title, ""); + lv_obj_set_style_text_color(detail_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_text_font(detail_title, &lv_font_montserrat_16, 0); + lv_obj_align(detail_title, LV_ALIGN_TOP_LEFT, 0, 0); + + detail_info = lv_label_create(detail_scr); + lv_label_set_text(detail_info, ""); + lv_obj_set_width(detail_info, 300); + lv_obj_align(detail_info, LV_ALIGN_TOP_LEFT, 0, 26); + + lv_obj_t* b; + lv_obj_t* bl; + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -76); + lv_obj_add_event_cb(b, detail_msg_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_ENVELOPE " Message"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -76); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, path_open_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_SHUFFLE " Set path"); lv_obj_center(bl); + + path_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(path_scr, 320, 240); + lv_obj_align(path_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(path_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(path_scr, 0, 0); + lv_obj_set_style_radius(path_scr, 0, 0); + lv_obj_set_style_pad_all(path_scr, 8, 0); + lv_obj_add_flag(path_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(path_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* pt = lv_label_create(path_scr); + lv_label_set_text(pt, "Path via repeaters (in order)"); + lv_obj_set_style_text_color(pt, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(pt, LV_ALIGN_TOP_LEFT, 0, 0); + + lv_obj_t* ph = lv_label_create(path_scr); + lv_label_set_text(ph, "hop 1 must be within direct range"); + lv_obj_set_style_text_font(ph, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ph, lv_color_hex(COL_MUTED), 0); + lv_obj_align(ph, LV_ALIGN_TOP_RIGHT, 0, 2); + + lv_obj_t* hop_list = lv_obj_create(path_scr); + lv_obj_set_size(hop_list, 304, 158); + lv_obj_align(hop_list, LV_ALIGN_TOP_MID, 0, 18); + lv_obj_set_style_bg_opa(hop_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(hop_list, 0, 0); + lv_obj_set_style_pad_all(hop_list, 0, 0); + lv_obj_set_scroll_dir(hop_list, LV_DIR_VER); + + for (int i = 0; i < PATH_MAX_HOPS; i++) { + path_hop_lbl[i] = lv_label_create(hop_list); + lv_label_set_text_fmt(path_hop_lbl[i], "%d", i + 1); + lv_obj_set_style_text_color(path_hop_lbl[i], lv_color_hex(COL_MUTED), 0); + lv_obj_align(path_hop_lbl[i], LV_ALIGN_TOP_LEFT, 2, 10 + i * 38); + path_dd[i] = lv_dropdown_create(hop_list); + lv_obj_set_size(path_dd[i], 274, 32); + lv_obj_align(path_dd[i], LV_ALIGN_TOP_LEFT, 20, 2 + i * 38); + } + + path_add_btn = lv_button_create(hop_list); + lv_obj_set_size(path_add_btn, 110, 30); + lv_obj_set_style_bg_color(path_add_btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(path_add_btn, path_add_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(path_add_btn); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_PLUS " Add hop"); + lv_obj_center(bl); + pathShowHops(1); + + b = lv_button_create(path_scr); lv_obj_set_size(b, 96, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -4); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, path_flood_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, "Reset (flood)"); lv_obj_center(bl); + + b = lv_button_create(path_scr); lv_obj_set_size(b, 96, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_MID, 0, -4); + lv_obj_add_event_cb(b, path_apply_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_OK " Apply"); lv_obj_center(bl); + + b = lv_button_create(path_scr); lv_obj_set_size(b, 96, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -4); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, path_cancel_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_CLOSE " Cancel"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -40); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, detail_share_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_UPLOAD " Share"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -40); + lv_obj_set_style_bg_color(b, lv_color_hex(0x7A2020), 0); + lv_obj_add_event_cb(b, detail_remove_cb, LV_EVENT_CLICKED, NULL); + detail_remove_lbl = lv_label_create(b); + lv_label_set_text(detail_remove_lbl, LV_SYMBOL_TRASH " Remove"); + lv_obj_center(detail_remove_lbl); + + detail_trace_btn = lv_button_create(detail_scr); lv_obj_set_size(detail_trace_btn, 148, 32); + lv_obj_align(detail_trace_btn, LV_ALIGN_BOTTOM_LEFT, 0, -2); + lv_obj_add_event_cb(detail_trace_btn, detail_trace_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(detail_trace_btn); lv_label_set_text(bl, LV_SYMBOL_GPS " Trace path"); lv_obj_center(bl); + + b = lv_button_create(detail_scr); lv_obj_set_size(b, 148, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -2); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, detail_close_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); lv_label_set_text(bl, LV_SYMBOL_CLOSE " Close"); lv_obj_center(bl); + + trace_scr = lv_obj_create(lv_screen_active()); + lv_obj_set_size(trace_scr, 320, 240); + lv_obj_align(trace_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(trace_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(trace_scr, 0, 0); + lv_obj_set_style_radius(trace_scr, 0, 0); + lv_obj_set_style_pad_all(trace_scr, 8, 0); + lv_obj_add_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_SCROLLABLE); + + trace_title = lv_label_create(trace_scr); + lv_label_set_text(trace_title, ""); + lv_obj_set_style_text_color(trace_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_set_style_text_font(trace_title, &lv_font_montserrat_16, 0); + lv_obj_set_width(trace_title, 304); + lv_label_set_long_mode(trace_title, LV_LABEL_LONG_DOT); + lv_obj_align(trace_title, LV_ALIGN_TOP_LEFT, 0, 0); + + // long hop lists scroll rather than running past the buttons + lv_obj_t* trace_body = lv_obj_create(trace_scr); + lv_obj_set_size(trace_body, 304, 158); + lv_obj_align(trace_body, LV_ALIGN_TOP_LEFT, 0, 26); + lv_obj_set_style_bg_opa(trace_body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(trace_body, 0, 0); + lv_obj_set_style_pad_all(trace_body, 0, 0); + lv_obj_set_scroll_dir(trace_body, LV_DIR_VER); + lv_obj_set_scrollbar_mode(trace_body, LV_SCROLLBAR_MODE_AUTO); + + trace_lbl = lv_label_create(trace_body); + lv_label_set_text(trace_lbl, ""); + lv_obj_set_width(trace_lbl, 296); + lv_label_set_long_mode(trace_lbl, LV_LABEL_LONG_WRAP); + lv_obj_align(trace_lbl, LV_ALIGN_TOP_LEFT, 0, 0); + + b = lv_button_create(trace_scr); lv_obj_set_size(b, 98, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_LEFT, 0, -2); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, trace_setpath_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_SHUFFLE " Set path"); lv_obj_center(bl); + + b = lv_button_create(trace_scr); lv_obj_set_size(b, 98, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_MID, 0, -2); + lv_obj_add_event_cb(b, trace_rerun_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_REFRESH " Trace"); lv_obj_center(bl); + + b = lv_button_create(trace_scr); lv_obj_set_size(b, 98, 32); + lv_obj_align(b, LV_ALIGN_BOTTOM_RIGHT, 0, -2); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(b, trace_close_cb, LV_EVENT_CLICKED, NULL); + bl = lv_label_create(b); + lv_obj_set_style_text_font(bl, &lv_font_montserrat_12, 0); + lv_label_set_text(bl, LV_SYMBOL_CLOSE " Close"); lv_obj_center(bl); + + // sleep shield: topmost, eats the first tap when the screen is dark + sleep_shield = lv_obj_create(lv_layer_top()); + lv_obj_set_size(sleep_shield, 320, 240); + lv_obj_set_style_bg_opa(sleep_shield, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(sleep_shield, 0, 0); + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_event_cb(sleep_shield, sleep_shield_cb, LV_EVENT_CLICKED, NULL); +} + +void UITask::refreshRepeaterScr() { + // detach the keyboard BEFORE cleaning: lv_keyboard_set_textarea dereferences + // its previous textarea, which lv_obj_clean is about to free (use-after-free) + lv_keyboard_set_textarea(rep_kb, NULL); + lv_obj_clean(rep_body); + lv_label_set_text(rep_title, _rep_name); + + if (!_rep_logged_in) { + bool have_saved = savedPwFor(_rep_key) != NULL; + rep_hint_lbl = lv_label_create(rep_body); + lv_label_set_text(rep_hint_lbl, _rep_logging_in ? "Logging in..." + : have_saved ? "Saved password - just tap Login" + : "Admin login required"); + lv_obj_set_style_text_color(rep_hint_lbl, lv_color_hex(have_saved ? COL_ACCENT : COL_MUTED), 0); + lv_obj_align(rep_hint_lbl, LV_ALIGN_TOP_MID, 0, 4); + + rep_pw_ta = lv_textarea_create(rep_body); + lv_textarea_set_one_line(rep_pw_ta, true); + lv_textarea_set_password_mode(rep_pw_ta, true); + lv_textarea_set_placeholder_text(rep_pw_ta, have_saved ? "********" : "Admin password"); + lv_textarea_set_max_length(rep_pw_ta, 40); + lv_obj_set_width(rep_pw_ta, 280); + lv_obj_set_height(rep_pw_ta, LV_SIZE_CONTENT); + lv_obj_set_style_pad_ver(rep_pw_ta, 5, 0); + lv_obj_set_scroll_dir(rep_pw_ta, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(rep_pw_ta, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_opa(rep_pw_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(rep_pw_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_align(rep_pw_ta, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_add_event_cb(rep_pw_ta, rep_pw_event_cb, LV_EVENT_ALL, NULL); + lv_keyboard_set_textarea(rep_kb, rep_pw_ta); + + rep_remember_cb = lv_checkbox_create(rep_body); + lv_checkbox_set_text(rep_remember_cb, "Remember password"); + lv_obj_set_style_text_font(rep_remember_cb, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_remember_cb, lv_color_hex(COL_MUTED), 0); + lv_obj_align(rep_remember_cb, LV_ALIGN_TOP_MID, 0, 58); + lv_obj_add_state(rep_remember_cb, LV_STATE_CHECKED); // on by default + + // flood the login when the stored route has gone stale + rep_flood_cb = lv_checkbox_create(rep_body); + lv_checkbox_set_text(rep_flood_cb, "Flood connect (ignore known path)"); + lv_obj_set_style_text_font(rep_flood_cb, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_flood_cb, lv_color_hex(COL_MUTED), 0); + lv_obj_align(rep_flood_cb, LV_ALIGN_TOP_MID, 0, 80); + if (rep_flood_on) lv_obj_add_state(rep_flood_cb, LV_STATE_CHECKED); // keep the choice + lv_obj_add_event_cb(rep_flood_cb, rep_flood_toggle_cb, LV_EVENT_VALUE_CHANGED, NULL); + + rep_login_btn = lv_button_create(rep_body); + lv_obj_set_size(rep_login_btn, 160, 36); + lv_obj_align(rep_login_btn, LV_ALIGN_TOP_MID, 0, 108); + lv_obj_add_event_cb(rep_login_btn, rep_login_btn_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* blbl = lv_label_create(rep_login_btn); + lv_label_set_text(blbl, LV_SYMBOL_OK " Login"); + lv_obj_center(blbl); + } else { + lv_obj_t* card = lv_obj_create(rep_body); + lv_obj_set_size(card, 300, 92); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(card, 0, 0); + lv_obj_set_style_pad_all(card, 6, 0); + lv_obj_set_scroll_dir(card, LV_DIR_VER); // long replies (neighbour lists) scroll + lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_AUTO); + + rep_card_title = lv_label_create(card); + lv_obj_set_style_text_font(rep_card_title, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(rep_card_title, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(rep_card_title, LV_ALIGN_TOP_LEFT, 0, 0); + lv_label_set_text(rep_card_title, rep_show_reply ? rep_last_cmd : "Status"); + + rep_stats_lbl = lv_label_create(card); + lv_obj_set_width(rep_stats_lbl, 284); + lv_obj_align(rep_stats_lbl, LV_ALIGN_TOP_LEFT, 0, 15); + lv_label_set_long_mode(rep_stats_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(rep_stats_lbl, &lv_font_montserrat_12, 0); + if (rep_show_reply) { + lv_label_set_text(rep_stats_lbl, rep_last_reply); + } else if (rep_stats_valid) { + uint32_t up = rep_stats.total_up_time_secs; + char up_s[20]; + if (up >= 86400) snprintf(up_s, sizeof(up_s), "%lud %luh", (unsigned long)(up / 86400), (unsigned long)((up % 86400) / 3600)); + else snprintf(up_s, sizeof(up_s), "%luh %lum", (unsigned long)(up / 3600), (unsigned long)((up % 3600) / 60)); + lv_label_set_text_fmt(rep_stats_lbl, + "Batt %u.%02uV Up %s\n" + "Noise %d RSSI %d SNR %d\n" + "Sent %lu (f%lu d%lu)\n" + "Recv %lu (f%lu d%lu)\n" + "Air TX %lus RX %lus Q%u E%u", + rep_stats.batt_milli_volts / 1000, (rep_stats.batt_milli_volts % 1000) / 10, up_s, + (int)rep_stats.noise_floor, (int)rep_stats.last_rssi, (int)(rep_stats.last_snr / 4), + (unsigned long)rep_stats.n_packets_sent, (unsigned long)rep_stats.n_sent_flood, (unsigned long)rep_stats.n_sent_direct, + (unsigned long)rep_stats.n_packets_recv, (unsigned long)rep_stats.n_recv_flood, (unsigned long)rep_stats.n_recv_direct, + (unsigned long)rep_stats.total_air_time_secs, (unsigned long)rep_stats.total_rx_air_time_secs, + (unsigned)rep_stats.curr_tx_queue_len, (unsigned)rep_stats.err_events); + } else { + lv_label_set_text(rep_stats_lbl, rep_stats_waiting ? "Fetching status..." : "No status yet - tap refresh"); + lv_obj_set_style_text_color(rep_stats_lbl, lv_color_hex(COL_MUTED), 0); + } + + lv_obj_t* refresh = lv_button_create(rep_body); + lv_obj_set_size(refresh, 44, 26); + lv_obj_align(refresh, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_add_event_cb(refresh, rep_status_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* rl = lv_label_create(refresh); + lv_label_set_text(rl, LV_SYMBOL_REFRESH); + lv_obj_center(rl); + + static const char* action_labels[6] = { + LV_SYMBOL_UPLOAD " Advert", LV_SYMBOL_LOOP " Clock sync", LV_SYMBOL_FILE " Version", + LV_SYMBOL_WIFI " Neighbors", LV_SYMBOL_REFRESH " Clock", LV_SYMBOL_WARNING " Reboot" + }; + for (int i = 0; i < 6; i++) { + lv_obj_t* btn = lv_button_create(rep_body); + lv_obj_set_size(btn, 96, 30); + lv_obj_align(btn, LV_ALIGN_TOP_LEFT, (i % 3) * 102, 98 + (i / 3) * 34); + if (i == 5) lv_obj_set_style_bg_color(btn, lv_color_hex(0x7A2020), 0); + lv_obj_add_event_cb(btn, rep_cmd_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_t* blbl = lv_label_create(btn); + lv_obj_set_style_text_font(blbl, &lv_font_montserrat_12, 0); + lv_label_set_text(blbl, action_labels[i]); + lv_obj_center(blbl); + } + + lv_obj_t* auto_cb = lv_checkbox_create(rep_body); + lv_checkbox_set_text(auto_cb, "auto-refresh"); + lv_obj_set_style_text_font(auto_cb, &lv_font_montserrat_12, 0); + lv_obj_align(auto_cb, LV_ALIGN_TOP_LEFT, 2, 172); + if (rep_auto_refresh) lv_obj_add_state(auto_cb, LV_STATE_CHECKED); + lv_obj_add_event_cb(auto_cb, rep_auto_cb, LV_EVENT_VALUE_CHANGED, NULL); + + lv_obj_t* btn = lv_button_create(rep_body); + lv_obj_set_size(btn, 118, 28); + lv_obj_align(btn, LV_ALIGN_TOP_RIGHT, 0, 166); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, rep_terminal_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* blbl = lv_label_create(btn); + lv_obj_set_style_text_font(blbl, &lv_font_montserrat_12, 0); + lv_label_set_text(blbl, LV_SYMBOL_KEYBOARD " CLI"); + lv_obj_center(blbl); + } +} + +void UITask::openRepeaterManager(const ContactInfo& contact) { + // every visit starts at the login screen + rep_show_reply = false; + _rep_logged_in = false; + _rep_logging_in = false; + if (memcmp(_rep_key, contact.id.pub_key, 6) != 0) { // different repeater + rep_stats_valid = false; + rep_stats_waiting = false; + rep_last_reply[0] = 0; + } + memcpy(_rep_key, contact.id.pub_key, 6); + StrHelper::strncpy(_rep_name, contact.name, sizeof(_rep_name)); + refreshRepeaterScr(); + lv_obj_remove_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::closeRepeaterManager() { + lv_obj_add_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + _rep_logged_in = false; // re-authenticate on the next visit + _rep_logging_in = false; +} + +void UITask::repeaterLogin(const char* password, bool flood) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + if (flood) { + the_mesh.resetPathTo(*c); + the_mesh.saveContacts(); + } + if (the_mesh.uiLogin(*c, password) == MSG_SEND_FAILED) { + showToast("Login send failed"); + } else { + _rep_logging_in = true; + memcpy(_pending_login_key, _rep_key, 6); + _pending_login_deadline = millis() + 30000; + refreshRepeaterScr(); + } +} + +void UITask::sendRepeaterCommand(const char* cmd) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + uint32_t est_timeout; + uint32_t timestamp = rtc_clock.getCurrentTimeUnique(); + if (the_mesh.sendCommandData(*c, timestamp, 0, TXT_TYPE_CLI_DATA, cmd, est_timeout) == MSG_SEND_FAILED) { + showToast("Send failed"); + } else { + chatStorePush(_rep_key, true, timestamp, cmd); // terminal history keeps everything + StrHelper::strncpy(rep_last_cmd, cmd, sizeof(rep_last_cmd)); + rep_show_reply = true; + snprintf(rep_last_reply, sizeof(rep_last_reply), "waiting for reply..."); + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + } +} + +void UITask::openConsoleThread() { + lv_obj_add_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); // manager under terminal, not over it + lv_obj_add_flag(rep_kb, LV_OBJ_FLAG_HIDDEN); + _thread_clear_armed = false; + memcpy(_thread_key, _rep_key, 6); + _thread_is_channel = false; + _thread_is_console = true; + char title[40]; + snprintf(title, sizeof(title), "@%s", _rep_name); + StrHelper::strncpy(_thread_name, title, sizeof(_thread_name)); + lv_label_set_text(thread_title, title); + lv_textarea_set_placeholder_text(thread_ta, "Command..."); + lv_obj_add_flag(thread_qr_btn, LV_OBJ_FLAG_HIDDEN); // canned chat replies make no sense as CLI + refreshThread(); + lv_obj_remove_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + updateThreadSubtitle(); +} + +void UITask::buildShell() { + lv_obj_t* scr = lv_screen_active(); + lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_text_color(scr, lv_color_hex(COL_TXT), 0); + + // --- status bar --- + status_bar = lv_obj_create(scr); + lv_obj_set_size(status_bar, 320, 22); + lv_obj_align(status_bar, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(status_bar, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(status_bar, 0, 0); + lv_obj_set_style_radius(status_bar, 0, 0); + lv_obj_set_style_pad_all(status_bar, 2, 0); + lv_obj_remove_flag(status_bar, LV_OBJ_FLAG_SCROLLABLE); + + lbl_node_name = lv_label_create(status_bar); + lv_label_set_text(lbl_node_name, _node_prefs ? _node_prefs->node_name : "MeshCore"); + lv_obj_set_style_text_color(lbl_node_name, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(lbl_node_name, LV_ALIGN_LEFT_MID, 4, 0); + + lbl_status_right = lv_label_create(status_bar); + lv_label_set_text(lbl_status_right, ""); + lv_obj_set_style_text_color(lbl_status_right, lv_color_hex(COL_MUTED), 0); + lv_obj_align(lbl_status_right, LV_ALIGN_RIGHT_MID, -4, 0); + + // --- tabview --- + tabview = lv_tabview_create(scr); + lv_obj_set_size(tabview, 320, 240 - 22); + lv_obj_align(tabview, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_tabview_set_tab_bar_position(tabview, LV_DIR_BOTTOM); + lv_tabview_set_tab_bar_size(tabview, 36); // icons only + lv_obj_set_style_bg_color(tabview, lv_color_hex(COL_BG), 0); + + tab_chats = lv_tabview_add_tab(tabview, LV_SYMBOL_ENVELOPE); + tab_contacts = lv_tabview_add_tab(tabview, SYMBOL_ADDR_BOOK); + tab_map = lv_tabview_add_tab(tabview, LV_SYMBOL_GPS); + tab_settings = lv_tabview_add_tab(tabview, LV_SYMBOL_SETTINGS); + lv_obj_add_event_cb(tabview, tabview_changed_cb, LV_EVENT_VALUE_CHANGED, NULL); + // tab switching via the tab bar only; swipe gestures belong to the content + // (map panning especially) and were causing accidental page swaps + lv_obj_remove_flag(lv_tabview_get_content(tabview), LV_OBJ_FLAG_SCROLLABLE); + + buildChatsTab(tab_chats); + buildContactsTab(tab_contacts); + mapViewBuild(tab_map, _sensors, this); + buildSettingsTab(tab_settings); + + thread_scr = lv_obj_create(scr); + lv_obj_set_size(thread_scr, 320, 240); + lv_obj_align(thread_scr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(thread_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(thread_scr, 0, 0); + lv_obj_set_style_radius(thread_scr, 0, 0); + lv_obj_set_style_pad_all(thread_scr, 0, 0); + lv_obj_add_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(thread_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* hdr = lv_obj_create(thread_scr); + lv_obj_set_size(hdr, 320, 26); + lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_color(hdr, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(hdr, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_set_style_pad_all(hdr, 2, 0); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* back = lv_button_create(hdr); + lv_obj_set_size(back, 40, 20); + lv_obj_align(back, LV_ALIGN_LEFT_MID, 2, 0); + lv_obj_add_event_cb(back, thread_back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* back_lbl = lv_label_create(back); + lv_label_set_text(back_lbl, LV_SYMBOL_LEFT); + lv_obj_center(back_lbl); + + thread_title = lv_label_create(hdr); + lv_label_set_text(thread_title, ""); + lv_label_set_long_mode(thread_title, LV_LABEL_LONG_DOT); + lv_obj_set_width(thread_title, 106); + lv_obj_align(thread_title, LV_ALIGN_LEFT_MID, 50, 0); + + thread_sub_lbl = lv_label_create(hdr); + lv_label_set_text(thread_sub_lbl, ""); + lv_obj_set_style_text_font(thread_sub_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(thread_sub_lbl, lv_color_hex(COL_MUTED), 0); + lv_label_set_long_mode(thread_sub_lbl, LV_LABEL_LONG_CLIP); + lv_obj_set_width(thread_sub_lbl, 82); + lv_obj_align(thread_sub_lbl, LV_ALIGN_LEFT_MID, 160, 0); + + lv_obj_t* route_btn = lv_button_create(hdr); + lv_obj_set_size(route_btn, 34, 20); + lv_obj_align(route_btn, LV_ALIGN_RIGHT_MID, -40, 0); + lv_obj_set_style_bg_color(route_btn, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(route_btn, thread_route_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* route_lbl = lv_label_create(route_btn); + lv_label_set_text(route_lbl, LV_SYMBOL_SHUFFLE); + lv_obj_center(route_lbl); + + lv_obj_t* clear_btn = lv_button_create(hdr); + lv_obj_set_size(clear_btn, 34, 20); + lv_obj_align(clear_btn, LV_ALIGN_RIGHT_MID, -2, 0); + lv_obj_set_style_bg_color(clear_btn, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(clear_btn, thread_clear_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* clear_lbl = lv_label_create(clear_btn); + lv_label_set_text(clear_lbl, LV_SYMBOL_TRASH); + lv_obj_center(clear_lbl); + + thread_msgs = lv_obj_create(thread_scr); + lv_obj_set_size(thread_msgs, 320, 240 - 26 - 34); + lv_obj_align(thread_msgs, LV_ALIGN_TOP_MID, 0, 26); + lv_obj_set_style_bg_color(thread_msgs, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(thread_msgs, 0, 0); + lv_obj_set_style_pad_all(thread_msgs, 4, 0); + lv_obj_set_flex_flow(thread_msgs, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(thread_msgs, 3, 0); + + thread_input_row = lv_obj_create(thread_scr); + lv_obj_set_size(thread_input_row, 320, 34); + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(thread_input_row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(thread_input_row, 0, 0); + lv_obj_set_style_radius(thread_input_row, 0, 0); + lv_obj_set_style_pad_all(thread_input_row, 3, 0); + lv_obj_remove_flag(thread_input_row, LV_OBJ_FLAG_SCROLLABLE); + + thread_qr_btn = lv_button_create(thread_input_row); + lv_obj_set_size(thread_qr_btn, 30, 28); + lv_obj_align(thread_qr_btn, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_bg_color(thread_qr_btn, lv_color_hex(COL_BG), 0); + lv_obj_add_event_cb(thread_qr_btn, qr_open_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t* qrl = lv_label_create(thread_qr_btn); + lv_label_set_text(qrl, LV_SYMBOL_LIST); + lv_obj_center(qrl); + + thread_ta = lv_textarea_create(thread_input_row); + lv_textarea_set_one_line(thread_ta, true); + lv_textarea_set_placeholder_text(thread_ta, "Message..."); + lv_textarea_set_max_length(thread_ta, 110); + // height = exactly one text line + padding: zero vertical scroll freedom + lv_obj_set_width(thread_ta, 278); + lv_obj_set_height(thread_ta, LV_SIZE_CONTENT); + lv_obj_align(thread_ta, LV_ALIGN_LEFT_MID, 34, 0); + lv_obj_set_style_pad_ver(thread_ta, 5, 0); + lv_obj_set_style_pad_left(thread_ta, 6, 0); + lv_obj_set_scroll_dir(thread_ta, LV_DIR_HOR); // horizontal scroll only + lv_obj_set_scrollbar_mode(thread_ta, LV_SCROLLBAR_MODE_OFF); // no bouncing edge lines + // blinking cursor only while actually typing (focused), not at idle + lv_obj_set_style_opa(thread_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(thread_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(thread_ta, ta_event_cb, LV_EVENT_ALL, NULL); + + thread_kb = lv_keyboard_create(thread_scr); + lv_keyboard_set_textarea(thread_kb, thread_ta); + lv_obj_set_size(thread_kb, 320, KB_HEIGHT); + lv_obj_align(thread_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + kbAttachShiftBehavior(thread_kb); +} + +void UITask::buildChatsTab(lv_obj_t* parent) { + lv_obj_set_style_pad_all(parent, 4, 0); + chats_list = lv_list_create(parent); + lv_obj_set_size(chats_list, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(chats_list, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(chats_list, 0, 0); +} + +void UITask::buildContactsTab(lv_obj_t* parent) { + lv_obj_set_style_pad_all(parent, 4, 0); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 4, 0); + + lv_obj_t* chips = lv_obj_create(parent); + lv_obj_set_size(chips, LV_PCT(100), 28); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 4, 0); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + static const char* names[5] = {"All", "Chat", "Repeater", "Room", "A-Z"}; + for (int i = 0; i < 5; i++) { + lv_obj_t* b = lv_button_create(chips); + lv_obj_set_height(b, 26); + lv_obj_set_flex_grow(b, 1); + lv_obj_set_style_pad_hor(b, 2, 0); + lv_obj_add_event_cb(b, contact_filter_cb, LV_EVENT_CLICKED, (void*)(intptr_t)i); + lv_obj_t* l = lv_label_create(b); + lv_label_set_text(l, names[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_center(l); + filter_btns[i] = b; + } + updateFilterChips(); + + contacts_list = lv_list_create(parent); + lv_obj_set_width(contacts_list, LV_PCT(100)); + lv_obj_set_flex_grow(contacts_list, 1); + lv_obj_set_style_bg_color(contacts_list, lv_color_hex(COL_BG), 0); + lv_obj_set_style_border_width(contacts_list, 0, 0); +} + +void UITask::buildNodeTab(lv_obj_t* parent) { + lv_obj_t* card = lv_obj_create(parent); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(card, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(card, 0, 0); + lv_obj_set_style_pad_all(card, 8, 0); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* hdr = lv_label_create(card); + lv_label_set_text(hdr, LV_SYMBOL_CHARGE " Telemetry"); + lv_obj_set_style_text_color(hdr, lv_color_hex(COL_ACCENT), 0); + node_info_lbl = lv_label_create(card); + lv_label_set_text(node_info_lbl, ""); + lv_obj_set_width(node_info_lbl, LV_PCT(100)); + lv_obj_align_to(node_info_lbl, hdr, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 4); +} + +static void tone_dd_cb(lv_event_t* e) { + lv_obj_t* dd = (lv_obj_t*) lv_event_get_target(e); + soundSetToneStyle((int) lv_dropdown_get_selected(dd)); + soundMessageTone(); // instant preview of the chosen alert +} + +static void ble_switch_cb(lv_event_t* e) { + bool on = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + ble_off_pref = !on; + prefWriteInt("/ble_off", on ? 0 : 1); + if (on) ui->enableBluetooth(); else ui->disableBluetooth(); + ui->showToast(on ? "Bluetooth on" : "Bluetooth off"); +} + +static void ble_auto_dd_cb(lv_event_t* e) { + static const int opts[4] = {0, 10, 30, 60}; + int sel = (int) lv_dropdown_get_selected((lv_obj_t*) lv_event_get_target(e)); + if (sel < 0 || sel > 3) return; + ble_autooff_min = opts[sel]; + prefWriteInt("/ble_auto", ble_autooff_min); +} + +static void grove_switch_cb(lv_event_t* e) { + grove_on = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/grove", grove_on ? 1 : 0); + board.setGrovePower(grove_on); +} + +static void autooff_dd_cb(lv_event_t* e) { + static const uint32_t opts[5] = {30000, 60000, 120000, 300000, 0}; + int sel = (int) lv_dropdown_get_selected((lv_obj_t*) lv_event_get_target(e)); + if (sel < 0 || sel > 4) return; + auto_off_ms = opts[sel]; + prefWriteInt("/autooff", (int) auto_off_ms); +} + +static void units_switch_cb(lv_event_t* e) { + units_miles = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/units", units_miles ? 1 : 0); +} + +static void clock12_switch_cb(lv_event_t* e) { + clock_12h = lv_obj_has_state((lv_obj_t*) lv_event_get_target(e), LV_STATE_CHECKED); + prefWriteInt("/clock12", clock_12h ? 1 : 0); +} + +static void bright_slider_cb(lv_event_t* e) { + lv_obj_t* s = (lv_obj_t*) lv_event_get_target(e); + if (lv_event_get_code(e) == LV_EVENT_VALUE_CHANGED) { + bright_pct = (uint8_t) lv_slider_get_value(s); + display.lgfxDevice()->setBrightness(brightRaw()); + } else if (lv_event_get_code(e) == LV_EVENT_RELEASED) { + brightSave(); + } +} + +// ---- quick replies editor (one reply per line) ---- +static void qredit_open_cb(lv_event_t* e) { + char buf[QR_MAX * QR_TEXT_LEN]; + int off = 0; + buf[0] = 0; + for (int i = 0; i < qr_count; i++) { + off += snprintf(&buf[off], sizeof(buf) - off, "%s\n", qr_texts[i]); + } + lv_textarea_set_text(qredit_ta, buf); + lv_obj_remove_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); +} + +static void qredit_save_cb(lv_event_t* e) { + const char* txt = lv_textarea_get_text(qredit_ta); + qr_count = 0; + const char* p = txt; + while (*p != 0 && qr_count < QR_MAX) { + const char* nl = strchr(p, '\n'); + size_t n = nl != NULL ? (size_t)(nl - p) : strlen(p); + if (n > 0) { + if (n >= QR_TEXT_LEN) n = QR_TEXT_LEN - 1; + memcpy(qr_texts[qr_count], p, n); + qr_texts[qr_count][n] = 0; + qr_count++; + } + if (nl == NULL) break; + p = nl + 1; + } + if (qr_count == 0) qrDefaults(); + qrSave(); + lv_obj_add_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); + ui->showToast("Quick replies saved"); +} + +static void qredit_cancel_cb(lv_event_t* e) { + lv_obj_add_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); +} + +static void qredit_ta_cb(lv_event_t* e) { + lv_event_code_t code = lv_event_get_code(e); + if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) { + lv_obj_remove_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); + } else if (code == LV_EVENT_DEFOCUSED || code == LV_EVENT_CANCEL || code == LV_EVENT_READY) { + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); + } +} + +void UITask::buildSettingsTab(lv_obj_t* parent) { + lv_obj_set_style_pad_all(parent, 8, 0); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 8, 0); + + buildNodeTab(parent); // telemetry card first + + lv_obj_t* row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t* lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_VOLUME_MAX " Sounds"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (_node_prefs && !_node_prefs->buzzer_quiet) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, sound_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 40); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_BELL " Alert tone"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* tone_dd = lv_dropdown_create(row); + lv_obj_set_size(tone_dd, 140, 32); + lv_obj_align(tone_dd, LV_ALIGN_RIGHT_MID, -4, 0); + lv_dropdown_set_options(tone_dd, "Classic\nChirp\nDing dong\nTrill\nRise\nAlarm"); + lv_dropdown_set_selected(tone_dd, soundGetToneStyle()); + lv_obj_add_event_cb(tone_dd, tone_dd_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_GPS " GPS"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (_node_prefs && _node_prefs->gps_enabled) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, gps_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + +#ifndef STANDALONE_NO_BT + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_BLUETOOTH " Bluetooth"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (!ble_off_pref) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, ble_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + ble_sw = sw; + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 40); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_BLUETOOTH " BT power save when idle"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* ba_dd = lv_dropdown_create(row); + lv_obj_set_size(ba_dd, 110, 32); + lv_obj_align(ba_dd, LV_ALIGN_RIGHT_MID, -4, 0); + lv_dropdown_set_options(ba_dd, "Never\n10 min\n30 min\n1 hour"); + lv_dropdown_set_selected(ba_dd, ble_autooff_min == 10 ? 1 : ble_autooff_min == 30 ? 2 : ble_autooff_min == 60 ? 3 : 0); + lv_obj_add_event_cb(ba_dd, ble_auto_dd_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_USB " Grove port power"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (grove_on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, grove_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); +#else + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_USB " Grove port power"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (grove_on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, grove_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); +#endif + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 40); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_POWER " Screen off"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* off_dd = lv_dropdown_create(row); + lv_obj_set_size(off_dd, 120, 32); + lv_obj_align(off_dd, LV_ALIGN_RIGHT_MID, -4, 0); + lv_dropdown_set_options(off_dd, "30 sec\n1 min\n2 min\n5 min\nNever"); + lv_dropdown_set_selected(off_dd, auto_off_ms == 30000 ? 0 : auto_off_ms == 60000 ? 1 : auto_off_ms == 120000 ? 2 : auto_off_ms == 300000 ? 3 : auto_off_ms == 0 ? 4 : 1); + lv_obj_add_event_cb(off_dd, autooff_dd_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_REFRESH " 12-hour clock"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (clock_12h) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, clock12_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_GPS " Distances in miles"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + sw = lv_switch_create(row); + lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -4, 0); + if (units_miles) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, units_switch_cb, LV_EVENT_VALUE_CHANGED, NULL); + + row = lv_obj_create(parent); + lv_obj_set_size(row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(row, lv_color_hex(COL_CARD), 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lbl = lv_label_create(row); + lv_label_set_text(lbl, LV_SYMBOL_EYE_OPEN " Brightness"); + lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 4, 0); + lv_obj_t* slider = lv_slider_create(row); + lv_obj_set_size(slider, 160, 14); + lv_obj_align(slider, LV_ALIGN_RIGHT_MID, -10, 0); + lv_slider_set_range(slider, 10, 100); + lv_slider_set_value(slider, bright_pct, LV_ANIM_OFF); + lv_obj_add_event_cb(slider, bright_slider_cb, LV_EVENT_VALUE_CHANGED, NULL); + lv_obj_add_event_cb(slider, bright_slider_cb, LV_EVENT_RELEASED, NULL); + + lv_obj_t* qr_row = lv_button_create(parent); + lv_obj_set_size(qr_row, LV_PCT(100), 36); + lv_obj_set_style_bg_color(qr_row, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(qr_row, qredit_open_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(qr_row); + lv_label_set_text(lbl, LV_SYMBOL_LIST " Quick replies"); + lv_obj_center(lbl); + + settingsBuildExtras(parent, this); + + lv_obj_t* btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_add_event_cb(btn, advert_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)1); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_UPLOAD " Send advert (flood)"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, advert_btn_cb, LV_EVENT_CLICKED, (void*)(intptr_t)0); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_UPLOAD " Send advert (zero hop)"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, wizard_row_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_SETTINGS " Run setup wizard"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, about_open_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_FILE " About & help"); + lv_obj_center(lbl); + + btn = lv_button_create(parent); + lv_obj_set_size(btn, LV_PCT(100), 36); + lv_obj_add_event_cb(btn, reboot_btn_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(btn); + lv_label_set_text(lbl, LV_SYMBOL_REFRESH " Reboot"); + lv_obj_center(lbl); + + qredit_scr = lv_obj_create(lv_layer_top()); + lv_obj_set_size(qredit_scr, 320, 240); + lv_obj_set_style_bg_color(qredit_scr, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(qredit_scr, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(qredit_scr, 0, 0); + lv_obj_set_style_radius(qredit_scr, 0, 0); + lv_obj_set_style_pad_all(qredit_scr, 8, 0); + lv_obj_add_flag(qredit_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(qredit_scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* qt = lv_label_create(qredit_scr); + lv_label_set_text(qt, "Quick replies"); + lv_obj_set_style_text_color(qt, lv_color_hex(COL_ACCENT), 0); + lv_obj_align(qt, LV_ALIGN_TOP_LEFT, 0, 6); + + lv_obj_t* qb = lv_button_create(qredit_scr); + lv_obj_set_size(qb, 70, 28); + lv_obj_align(qb, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_set_style_bg_color(qb, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(qb, qredit_cancel_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(qb); lv_label_set_text(lbl, LV_SYMBOL_CLOSE); lv_obj_center(lbl); + + qb = lv_button_create(qredit_scr); + lv_obj_set_size(qb, 70, 28); + lv_obj_align(qb, LV_ALIGN_TOP_RIGHT, -76, 0); + lv_obj_add_event_cb(qb, qredit_save_cb, LV_EVENT_CLICKED, NULL); + lbl = lv_label_create(qb); lv_label_set_text(lbl, LV_SYMBOL_OK); lv_obj_center(lbl); + + lv_obj_t* qh = lv_label_create(qredit_scr); + lv_label_set_text_fmt(qh, "One reply per line, up to %d", QR_MAX); + lv_obj_set_style_text_font(qh, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(qh, lv_color_hex(COL_MUTED), 0); + lv_obj_align(qh, LV_ALIGN_TOP_LEFT, 0, 32); + + qredit_ta = lv_textarea_create(qredit_scr); + lv_textarea_set_max_length(qredit_ta, QR_MAX * QR_TEXT_LEN - 1); + lv_obj_set_size(qredit_ta, 304, 172); + lv_obj_align(qredit_ta, LV_ALIGN_TOP_MID, 0, 52); + lv_obj_set_style_opa(qredit_ta, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_opa(qredit_ta, LV_OPA_COVER, LV_PART_CURSOR | LV_STATE_FOCUSED); + lv_obj_add_event_cb(qredit_ta, qredit_ta_cb, LV_EVENT_ALL, NULL); + + qredit_kb = lv_keyboard_create(qredit_scr); + lv_keyboard_set_textarea(qredit_kb, qredit_ta); + kbAttachShiftBehavior(qredit_kb); + lv_obj_set_size(qredit_kb, 320, KB_HEIGHT); + lv_obj_align(qredit_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_flag(qredit_kb, LV_OBJ_FLAG_HIDDEN); +} + +// --------------------------------------------------------------------------- +// refreshers +// --------------------------------------------------------------------------- +void UITask::refreshStatusBar() { + char buf[64]; + + // clock, once the RTC has real time (GPS fix or phone connection syncs it) + uint32_t now = rtc_clock.getCurrentTime(); + if (now > 1600000000UL) { + time_t local = (time_t) now + (time_t) settingsTzOffset() * 3600; + struct tm tmv; + gmtime_r(&local, &tmv); + char namebuf[48]; + if (clock_12h) { + int h12 = tmv.tm_hour % 12; if (h12 == 0) h12 = 12; + snprintf(namebuf, sizeof(namebuf), "%s %d:%02d %s", + _node_prefs ? _node_prefs->node_name : "", h12, tmv.tm_min, tmv.tm_hour < 12 ? "am" : "pm"); + } else { + snprintf(namebuf, sizeof(namebuf), "%s %02d:%02d", + _node_prefs ? _node_prefs->node_name : "", tmv.tm_hour, tmv.tm_min); + } + lv_label_set_text(lbl_node_name, namebuf); + } + + uint16_t mv = getBattMilliVolts(); + int pct = battPercent(mv); + const char* bsym = pct > 80 ? LV_SYMBOL_BATTERY_FULL + : pct > 55 ? LV_SYMBOL_BATTERY_3 + : pct > 30 ? LV_SYMBOL_BATTERY_2 + : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY; + snprintf(buf, sizeof(buf), "%s%s %d %s %d%%", +#ifdef STANDALONE_NO_BT + // no phone link in this build, so report charging instead + _board->isExternalPowered() ? LV_SYMBOL_CHARGE " " : "", +#else + hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", +#endif + LV_SYMBOL_ENVELOPE, chatStoreUnreadTotal(), bsym, pct); + lv_label_set_text(lbl_status_right, buf); + + lv_obj_t* bar = lv_tabview_get_tab_bar(tabview); + lv_obj_t* tab0 = bar != NULL ? lv_obj_get_child(bar, 0) : NULL; + lv_obj_t* tl = tab0 != NULL ? lv_obj_get_child(tab0, 0) : NULL; + if (tl != NULL) { + int unread = chatStoreUnreadTotal(); + if (unread > 0) lv_label_set_text_fmt(tl, LV_SYMBOL_ENVELOPE " %d", unread); + else lv_label_set_text(tl, LV_SYMBOL_ENVELOPE); + } +} + +void UITask::refreshChatsTab() { + lv_obj_clean(chats_list); + int n = chatStoreThreads(chats_row_keys, MAX_THREAD_ROWS); + for (int i = 0; i < n; i++) { + char label[64]; + const char* icon = LV_SYMBOL_ENVELOPE; + int ch_idx; + if (isChannelKey(chats_row_keys[i], &ch_idx)) { + ChannelDetails ch; + if (!the_mesh.getChannel(ch_idx, ch) || ch.name[0] == 0) continue; + channelLabel(label, sizeof(label), ch.name); + icon = LV_SYMBOL_ENVELOPE; + } else { + ContactInfo* c = the_mesh.lookupContactByPubKey(chats_row_keys[i], 6); + if (c != NULL && c->type != ADV_TYPE_CHAT) continue; // repeater consoles: manager only + snprintf(label, sizeof(label), "%s", c ? c->name : "(unknown)"); + } + int unread = chatStoreUnreadGet(chats_row_keys[i]); + if (unread > 0) { + size_t l = strlen(label); + snprintf(label + l, sizeof(label) - l, " (%d)", unread); + } + lv_obj_t* btn = lv_list_add_button(chats_list, icon, label); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, chats_row_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)i); + lv_obj_add_event_cb(btn, chats_row_long_cb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)i); + } + for (int idx = 0; idx < MAX_GROUP_CHANNELS; idx++) { + ChannelDetails ch; + if (!the_mesh.getChannel(idx, ch)) break; + if (ch.name[0] == 0) continue; + uint8_t key[6]; + makeChannelKey(idx, key); + bool listed = false; + for (int i = 0; i < n; i++) { + if (memcmp(chats_row_keys[i], key, 6) == 0) { listed = true; break; } + } + if (listed || n >= MAX_THREAD_ROWS) continue; + memcpy(chats_row_keys[n], key, 6); + char label[40]; + channelLabel(label, sizeof(label), ch.name); + lv_obj_t* btn = lv_list_add_button(chats_list, LV_SYMBOL_ENVELOPE, label); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, chats_row_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)n); + lv_obj_add_event_cb(btn, chats_row_long_cb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)n); + n++; + } + if (n == 0) { + lv_list_add_text(chats_list, "No chats yet - pick a contact"); + } + lv_obj_t* add_btn = lv_list_add_button(chats_list, LV_SYMBOL_PLUS, "Add channel"); + lv_obj_set_style_text_color(add_btn, lv_color_hex(COL_MUTED), 0); + lv_obj_add_event_cb(add_btn, addch_open_cb, LV_EVENT_CLICKED, NULL); +} + +void UITask::refreshContactsTab() { + lv_obj_clean(contacts_list); + + // sort by most recently heard (or by name), honoring the type filter + struct Entry { int idx; uint32_t ts; char name[32]; }; + static Entry order[64]; + int n = 0; + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots() && n < 64; idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] == 0) continue; + if (contact_filter == 1 && c.type != ADV_TYPE_CHAT) continue; + if (contact_filter == 2 && c.type != ADV_TYPE_REPEATER) continue; + if (contact_filter == 3 && c.type != ADV_TYPE_ROOM) continue; + order[n].idx = idx; + order[n].ts = c.last_advert_timestamp; + StrHelper::strncpy(order[n].name, c.name, sizeof(order[n].name)); + n++; + } + for (int i = 1; i < n; i++) { // insertion sort + Entry key = order[i]; + int j = i - 1; + while (j >= 0 && (contacts_by_name ? strcasecmp(order[j].name, key.name) > 0 : order[j].ts < key.ts)) { + order[j + 1] = order[j]; j--; + } + order[j + 1] = key; + } + + int shown = 0; + for (int oi = 0; oi < n; oi++) { + int idx = order[oi].idx; + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) continue; + const char* icon = LV_SYMBOL_ENVELOPE; + if (c.type == ADV_TYPE_REPEATER) icon = SYMBOL_TOWER; + else if (c.type == ADV_TYPE_ROOM) icon = LV_SYMBOL_HOME; + else if (c.type == ADV_TYPE_SENSOR) icon = LV_SYMBOL_TINT; + lv_obj_t* btn = lv_list_add_button(contacts_list, icon, c.name); + lv_obj_set_style_bg_color(btn, lv_color_hex(COL_CARD), 0); + lv_obj_add_event_cb(btn, contact_row_cb, LV_EVENT_SHORT_CLICKED, (void*)(intptr_t)idx); + lv_obj_add_event_cb(btn, contact_row_long_cb, LV_EVENT_LONG_PRESSED, (void*)(intptr_t)idx); + shown++; + } + if (shown == 0) { + lv_list_add_text(contacts_list, contact_filter == 0 ? "No contacts yet - waiting for adverts" : "No contacts of this type"); + } +} + +void UITask::refreshNodeTab() { + if (_node_prefs == NULL) return; + char buf[400]; + char gps_line[120] = "GPS: off"; + if (_sensors != NULL) { + LocationProvider* nmea = _sensors->getLocationProvider(); + if (nmea != NULL && nmea->isValid()) { + snprintf(gps_line, sizeof(gps_line), "GPS: fix, %d sats\n%.5f %.5f", + nmea->satellitesCount(), + nmea->getLatitude() / 1000000., nmea->getLongitude() / 1000000.); + } else if (_node_prefs->gps_enabled) { + // sats in view + best SNR come from GSV: shows RF health long before a fix + snprintf(gps_line, sizeof(gps_line), + "GPS: searching - %d in view (%d strong), best %d dB\nGGA fix=%d used=%d RMC=%c\n%s", + gps_tap.satsInView(), gps_tap.strongSats(), gps_tap.bestSnr(), + gps_tap.ggaFix(), gps_tap.ggaUsed(), gps_tap.rmcStatus(), + gps_kicks > 0 ? "auto-restarted, re-acquiring" : "needs open sky for first fix"); + } + } + snprintf(buf, sizeof(buf), + "Node: %s\n\n" + "Freq: %.3f MHz SF%d\n" + "BW: %.2f CR: %d\n" + "TX: %d dBm\n\n" + "%s\n\n" + "Battery: %d mV\n" + "BLE pin: %u", + _node_prefs->node_name, + _node_prefs->freq, _node_prefs->sf, + _node_prefs->bw, _node_prefs->cr, + _node_prefs->tx_power_dbm, + gps_line, + getBattMilliVolts(), + (unsigned) the_mesh.getBLEPin()); + lv_label_set_text(node_info_lbl, buf); +} + +// --------------------------------------------------------------------------- +// thread view +// --------------------------------------------------------------------------- +void UITask::openRouteForThread() { + if (_thread_is_channel) { + showToast("Channels always flood"); + return; + } + for (int idx = MAX_ANON_CONTACTS; idx < the_mesh.getTotalContactSlots(); idx++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(idx, c)) break; + if (c.name[0] != 0 && memcmp(c.id.pub_key, _thread_key, 6) == 0) { + detail_idx = idx; + path_open_cb(NULL); + return; + } + } + showToast("Contact not found"); +} + +void UITask::clearCurrentThread() { + if (!_thread_clear_armed) { + _thread_clear_armed = true; + showToast("Tap trash again to clear thread"); + return; + } + _thread_clear_armed = false; + chatStoreClearThread(_thread_key); + refreshThread(); + refreshChatsTab(); + showToast("Thread cleared"); +} + +void UITask::openThread(const uint8_t key[6], const char* name) { + memcpy(_thread_key, key, 6); + _thread_clear_armed = false; + chatStoreUnreadClear(key); + refreshStatusBar(); + _thread_is_channel = isChannelKey(key, &_thread_channel_idx); + _thread_is_console = false; + StrHelper::strncpy(_thread_name, name, sizeof(_thread_name)); + lv_label_set_text(thread_title, name); + lv_textarea_set_placeholder_text(thread_ta, "Message..."); + lv_obj_remove_flag(thread_qr_btn, LV_OBJ_FLAG_HIDDEN); + refreshThread(); + lv_obj_remove_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + updateThreadSubtitle(); +} + +void UITask::openContactThread(const ContactInfo& contact) { + openThread(contact.id.pub_key, contact.name); +} + +void UITask::closeThread() { + qrPanelClose(); + lv_obj_add_flag(thread_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(thread_kb, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(thread_input_row, LV_ALIGN_BOTTOM_MID, 0, 0); + if (_thread_is_console) { + _thread_is_console = false; + lv_obj_remove_flag(rep_scr, LV_OBJ_FLAG_HIDDEN); // back to the manager + } else { + refreshChatsTab(); + } +} + +static lv_obj_t* echo_meta_lbl = NULL; +static char echo_meta_base[16]; + +void UITask::refreshThread() { + lv_obj_clean(thread_msgs); + echo_meta_lbl = NULL; + + int count = 0; + while (chatStoreGet(_thread_key, count) != NULL && count < CHAT_STORE_SIZE) count++; + if (count > 50) count = 50; // render the newest 50; full history stays stored + + for (int k = count - 1; k >= 0; k--) { // oldest first, newest at bottom + ChatMsg* m = chatStoreGet(_thread_key, k); + if (m == NULL) continue; + + lv_obj_t* row = lv_obj_create(thread_msgs); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, m->outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + char age[16]; + fmtAge(age, sizeof(age), m->timestamp); + if (m->outgoing) { // time sits left of an outgoing (right-aligned) bubble + lv_obj_t* tl = lv_label_create(row); + lv_label_set_text(tl, age); + lv_obj_set_style_text_font(tl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tl, lv_color_hex(COL_MUTED), 0); + if (m->timestamp == _echo_msg_ts && millis() <= _echo_window_end) { + echo_meta_lbl = tl; // repeat-echo counter rides on this label + StrHelper::strncpy(echo_meta_base, age, sizeof(echo_meta_base)); + if (_echo_count > 0) lv_label_set_text_fmt(tl, "%s " LV_SYMBOL_LOOP "%d", age, _echo_count); + } + } + + lv_obj_t* bubble = lv_label_create(row); + if (m->outgoing && m->status != MSG_STATUS_NONE) { + if (m->status == MSG_STATUS_PENDING && millis() > m->timeout_at) m->status = MSG_STATUS_NO_ACK; + const char* mark = m->status == MSG_STATUS_DELIVERED ? LV_SYMBOL_OK + : m->status == MSG_STATUS_PENDING ? LV_SYMBOL_REFRESH : LV_SYMBOL_WARNING; + lv_label_set_text_fmt(bubble, "%s %s", m->text, mark); + if (m->status == MSG_STATUS_NO_ACK) { // tap to resend + lv_obj_add_flag(bubble, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(bubble, bubble_retry_cb, LV_EVENT_CLICKED, (void*)(intptr_t)k); + } + } else { + lv_label_set_text(bubble, m->text); + } + lv_label_set_long_mode(bubble, LV_LABEL_LONG_WRAP); + lv_obj_set_style_max_width(bubble, 240, 0); + lv_obj_set_style_bg_opa(bubble, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(bubble, lv_color_hex(m->outgoing ? COL_ACCENT_D : COL_CARD), 0); + lv_obj_set_style_radius(bubble, 8, 0); + lv_obj_set_style_pad_all(bubble, 6, 0); + + if (!m->outgoing) { // time sits right of an incoming bubble + lv_obj_t* tl = lv_label_create(row); + lv_label_set_text(tl, age); + lv_obj_set_style_text_font(tl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tl, lv_color_hex(COL_MUTED), 0); + } + } + lv_obj_scroll_to_y(thread_msgs, LV_COORD_MAX, LV_ANIM_OFF); +} + +void UITask::sendFromThread(const char* text) { + uint32_t timestamp = rtc_clock.getCurrentTime(); + + _echo_count = 0; + _echo_window_end = millis() + 60000; + _echo_msg_ts = timestamp; + + if (_thread_is_console) { // repeater terminal: everything is a CLI command + ContactInfo* c = the_mesh.lookupContactByPubKey(_thread_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + uint32_t est_timeout; + timestamp = rtc_clock.getCurrentTimeUnique(); // a repeat within the same second would look like a replay + _echo_msg_ts = timestamp; + if (the_mesh.sendCommandData(*c, timestamp, 0, TXT_TYPE_CLI_DATA, text, est_timeout) == MSG_SEND_FAILED) { + showToast("Send failed"); + } else { + chatStorePush(_thread_key, true, timestamp, text); + } + refreshThread(); + return; + } + + if (_thread_is_channel) { + ChannelDetails ch; + if (!the_mesh.getChannel(_thread_channel_idx, ch) || ch.name[0] == 0) return; + if (the_mesh.sendGroupMessage(timestamp, ch.channel, _node_prefs->node_name, text, strlen(text))) { + chatStorePush(_thread_key, true, timestamp, text); + } else { + showToast("Send failed"); + } + } else { + ContactInfo* c = the_mesh.lookupContactByPubKey(_thread_key, 6); + if (c == NULL) { showToast("Contact gone"); return; } + uint32_t expected_ack, est_timeout; + int result = the_mesh.sendMessage(*c, timestamp, 0, text, expected_ack, est_timeout); + if (result == MSG_SEND_FAILED) { + showToast("Send failed"); + } else { + ChatMsg* m = chatStorePush(_thread_key, true, timestamp, text); + if (m != NULL && expected_ack != 0) { + m->status = MSG_STATUS_PENDING; + m->expected_ack = expected_ack; + // flood acks route back through repeaters and can take a long while; + // est_timeout models the direct case only - be patient like the app is + uint32_t wait = est_timeout * 4; + if (wait < 60000) wait = 60000; + m->timeout_at = millis() + wait; + } + } + } + refreshThread(); +} + +// --------------------------------------------------------------------------- +// toast +// --------------------------------------------------------------------------- +static void toast_del_cb(lv_timer_t* t) { + lv_obj_t* toast = (lv_obj_t*) lv_timer_get_user_data(t); + lv_obj_delete(toast); + lv_timer_delete(t); +} + +void UITask::openContactDetail(int contact_idx) { + ContactInfo c; + if (!the_mesh.getContactByIdx(contact_idx, c) || c.name[0] == 0) return; + detail_idx = contact_idx; + detail_remove_armed = false; + lv_label_set_text(detail_remove_lbl, LV_SYMBOL_TRASH " Remove"); + + lv_label_set_text(detail_title, c.name); + + const char* type_s = c.type == ADV_TYPE_CHAT ? "chat node" + : c.type == ADV_TYPE_REPEATER ? "repeater" + : c.type == ADV_TYPE_ROOM ? "room server" : "sensor"; + + char heard[32] = "never"; + if (c.last_advert_timestamp > 0) { + long secs = (long)rtc_clock.getCurrentTime() - (long)c.last_advert_timestamp; + if (secs < 0) secs = 0; + if (secs < 3600) snprintf(heard, sizeof(heard), "%ldm ago", secs / 60); + else if (secs < 86400) snprintf(heard, sizeof(heard), "%ldh ago", secs / 3600); + else snprintf(heard, sizeof(heard), "%ldd ago", secs / 86400); + } + + char path[32]; + if (c.out_path_len == OUT_PATH_UNKNOWN) strcpy(path, "flood (no path learned yet)"); + else snprintf(path, sizeof(path), "direct, %d hop%s", c.out_path_len, c.out_path_len == 1 ? "" : "s"); + + char pos[80] = "position: unknown"; + if (c.gps_lat != 0 || c.gps_lon != 0) { + double clat = c.gps_lat / 1000000.0, clon = c.gps_lon / 1000000.0; + LocationProvider* nmea = _sensors != NULL ? _sensors->getLocationProvider() : NULL; + if (nmea != NULL && nmea->isValid()) { + double mlat = nmea->getLatitude() / 1000000.0, mlon = nmea->getLongitude() / 1000000.0; + // haversine distance + initial bearing + double dlat = (clat - mlat) * M_PI / 180.0, dlon = (clon - mlon) * M_PI / 180.0; + double a = sin(dlat / 2) * sin(dlat / 2) + + cos(mlat * M_PI / 180.0) * cos(clat * M_PI / 180.0) * sin(dlon / 2) * sin(dlon / 2); + double dist_km = 6371.0 * 2.0 * atan2(sqrt(a), sqrt(1 - a)); + double y = sin(dlon) * cos(clat * M_PI / 180.0); + double x = cos(mlat * M_PI / 180.0) * sin(clat * M_PI / 180.0) - + sin(mlat * M_PI / 180.0) * cos(clat * M_PI / 180.0) * cos(dlon); + int brg = ((int)(atan2(y, x) * 180.0 / M_PI) + 360) % 360; + static const char* dirs[] = {"N","NE","E","SE","S","SW","W","NW"}; + double dist = units_miles ? dist_km * 0.621371 : dist_km; + snprintf(pos, sizeof(pos), "%.2f %s %s (%d deg)\n%.5f %.5f", + dist, units_miles ? "mi" : "km", dirs[((brg + 22) / 45) % 8], brg, clat, clon); + } else { + snprintf(pos, sizeof(pos), "%.5f %.5f", clat, clon); + } + } + + char info[220]; + snprintf(info, sizeof(info), "%s\nlast heard: %s\npath: %s\n%s", type_s, heard, path, pos); + lv_label_set_text(detail_info, info); + // trace only applies to nodes that relay it + if (c.type == ADV_TYPE_REPEATER || c.type == ADV_TYPE_ROOM) { + lv_obj_remove_flag(detail_trace_btn, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(detail_trace_btn, LV_OBJ_FLAG_HIDDEN); + } + lv_obj_remove_flag(detail_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::openTraceForContact(int contact_idx) { + ContactInfo c; + if (!the_mesh.getContactByIdx(contact_idx, c)) return; + detail_idx = contact_idx; + // only repeaters and room servers relay a trace; clients do not + if (c.type != ADV_TYPE_REPEATER && c.type != ADV_TYPE_ROOM) { + showToast("Trace measures repeaters only"); + return; + } + lv_label_set_text_fmt(trace_title, LV_SYMBOL_GPS " Trace path: %s", c.name); + if (c.out_path_len == OUT_PATH_UNKNOWN) { + lv_label_set_text(trace_lbl, "No path to this repeater yet.\n\nUse Set path to choose which\nrepeaters to trace through."); + lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); + return; + } + + // out through the known hops, the target repeater, then back the same way + uint8_t path[MAX_PATH_SIZE * 2 + 1]; + uint8_t n = 0; + for (int i = 0; i < c.out_path_len; i++) path[n++] = c.out_path[i]; + n += c.id.copyHashTo(&path[n]); + for (int i = c.out_path_len - 1; i >= 0; i--) path[n++] = c.out_path[i]; + + uint32_t tag = esp_random(); + if (tag == 0) tag = 1; + int est_timeout = the_mesh.uiTracePath(path, n, tag); + if (est_timeout <= 0) { + showToast("Trace send failed"); + return; + } + + _trace_tag = tag; + _trace_started = millis(); + _trace_deadline = millis() + est_timeout + 3000; + + lv_label_set_text(trace_lbl, "Tracing out and back...\n\nEach repeater on the path reports the\nlevel it heard. The reply lands here\nonly if this node can hear the first\nhop directly."); + lv_obj_remove_flag(trace_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::traceResponse(uint32_t tag, const uint8_t* path_hashes, const uint8_t* path_snrs, + uint8_t hop_count, int8_t final_snr) { + if (_trace_tag == 0 || tag != _trace_tag) return; + _trace_tag = 0; + + unsigned long rtt = millis() - _trace_started; + int out_hops = (hop_count + 1) / 2; // round trip turns around at the far end + + char buf[768]; // up to eight hops out and back, plus the round-trip line + int off = snprintf(buf, sizeof(buf), "Round trip %lu.%lus\n\n", rtt / 1000, (rtt % 1000) / 100); + for (int i = 0; i < hop_count && off < (int) sizeof(buf) - 48; i++) { + char nm[20]; + traceHashName(path_hashes[i], nm, sizeof(nm)); + off += snprintf(&buf[off], sizeof(buf) - off, "%s %.14s %+.1f dB\n", + i < out_hops ? LV_SYMBOL_RIGHT : LV_SYMBOL_LEFT, nm, (int8_t) path_snrs[i] / 4.0); + } + snprintf(&buf[off], sizeof(buf) - off, "%s You %+.1f dB", LV_SYMBOL_LEFT, final_snr / 4.0); + lv_label_set_text(trace_lbl, buf); +} + +void UITask::nodeNameChanged() { + if (_node_prefs != NULL) lv_label_set_text(lbl_node_name, _node_prefs->node_name); +} + +void UITask::updateThreadSubtitle() { + if (lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN)) return; + char buf[32] = ""; + if (!_thread_is_channel && !_thread_is_console) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_thread_key, 6); + if (c != NULL) { + if (c->out_path_len == OUT_PATH_UNKNOWN) strcpy(buf, "flood"); + else if (c->out_path_len == 0) strcpy(buf, "direct"); + else snprintf(buf, sizeof(buf), "%d hop%s", c->out_path_len, c->out_path_len == 1 ? "" : "s"); + } + } + lv_label_set_text(thread_sub_lbl, buf); +} + +void UITask::resendMessage(int k) { + ChatMsg* m = chatStoreGet(_thread_key, k); + if (m == NULL || !m->outgoing || m->status != MSG_STATUS_NO_ACK) return; + char txt[200]; + StrHelper::strncpy(txt, m->text, sizeof(txt)); + sendFromThread(txt); + showToast("Resent"); +} + +void UITask::openChannelOptions(const uint8_t key[6]) { + int idx; + ChannelDetails ch; + if (!isChannelKey(key, &idx) || !the_mesh.getChannel(idx, ch) || ch.name[0] == 0) return; + chopt_idx = idx; + memcpy(chopt_key, key, 6); + chopt_remove_armed = false; + lv_label_set_text(chopt_remove_lbl, LV_SYMBOL_TRASH " Remove channel"); + lv_label_set_text_fmt(chopt_title, "#%s", ch.name); + lv_textarea_set_text(chopt_ta, ch.name); + lv_obj_remove_flag(chopt_scr, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::finishSetupWizard(int preset_idx, const char* name, int tz_hours, bool apply) { + if (apply) { + settingsApplyPreset(preset_idx); + if (name != NULL && name[0] != 0 && _node_prefs != NULL) { + StrHelper::strncpy(_node_prefs->node_name, name, sizeof(_node_prefs->node_name)); + the_mesh.savePrefs(); + nodeNameChanged(); + } + settingsSetTzOffset(tz_hours); + refreshStatusBar(); + refreshNodeTab(); + showToast("Setup complete"); + } + File f = SPIFFS.open("/setup_done", "w"); + if (f) { f.print(1); f.close(); } + wizard_pending = false; + lv_obj_add_flag(wiz_scr, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(wiz_kb, LV_OBJ_FLAG_HIDDEN); +} + +void UITask::msgEchoHeard() { + if (millis() > _echo_window_end) return; // stale echo, window closed + _echo_count++; + if (echo_meta_lbl != NULL) { + lv_label_set_text_fmt(echo_meta_lbl, "%s " LV_SYMBOL_LOOP "%d", echo_meta_base, _echo_count); + } +} + +void UITask::showToast(const char* text) { + lv_obj_t* toast = lv_obj_create(lv_layer_top()); + lv_obj_set_size(toast, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(toast, lv_color_hex(COL_ACCENT_D), 0); + lv_obj_set_style_radius(toast, 8, 0); + lv_obj_set_style_pad_all(toast, 8, 0); + lv_obj_align(toast, LV_ALIGN_TOP_MID, 0, 30); + lv_obj_t* lbl = lv_label_create(toast); + lv_label_set_text(lbl, text); + lv_timer_create(toast_del_cb, 2000, toast); +} + +// --------------------------------------------------------------------------- +// mesh events +// --------------------------------------------------------------------------- +void UITask::msgRead(int msgcount) { + _msgcount = msgcount; + refreshStatusBar(); +} + +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { + _msgcount = msgcount; + + bool in_open_thread = false; + uint8_t msg_key[6] = {0}; + bool have_key = false; + ContactInfo* from = the_mesh.searchContactsByPrefix(from_name); + if (from != NULL) { + memcpy(msg_key, from->id.pub_key, 6); + have_key = true; + } else { + for (int idx = 0; idx < MAX_GROUP_CHANNELS; idx++) { + ChannelDetails ch; + if (!the_mesh.getChannel(idx, ch)) break; + if (ch.name[0] == 0 || strcmp(ch.name, from_name) != 0) continue; + makeChannelKey(idx, msg_key); + have_key = true; + break; + } + } + if (have_key) { + chatStorePush(msg_key, false, rtc_clock.getCurrentTime(), text); + in_open_thread = !lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN) + && memcmp(_thread_key, msg_key, 6) == 0; + if (!in_open_thread) chatStoreUnreadBump(msg_key); + } + + if (in_open_thread) { + refreshThread(); + } else { + if (have_key) showNotifBanner(msg_key, from_name, text); + else { + char buf[48]; + snprintf(buf, sizeof(buf), LV_SYMBOL_ENVELOPE " %s", from_name); + showToast(buf); + } + refreshChatsTab(); + } + refreshStatusBar(); +} + +void UITask::msgAck(uint32_t ack_crc) { + if (chatStoreAck(ack_crc)) { + if (!lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN)) refreshThread(); + } +} + +void UITask::loginResult(const uint8_t* pub_key, bool success) { + // only react to the login we're actually waiting on (phone app logins and + // stale responses for other repeaters shouldn't clear state or toast) + if (_pending_login_deadline != 0 && memcmp(_pending_login_key, pub_key, 6) == 0) { + _pending_login_deadline = 0; + showToast(success ? "Login OK" : "Login failed"); + } + if (memcmp(_rep_key, pub_key, 6) == 0) { + _rep_logging_in = false; + _rep_logged_in = success; + if (success && rep_pending_pw[0] != 0 && rep_pending_remember) { + savedPwSet(_rep_key, rep_pending_pw); + } else if (!success && rep_pending_pw[0] != 0) { + savedPwForget(_rep_key); // stale saved password: drop it + } + rep_pending_pw[0] = 0; + if (success) { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c != NULL && c->out_path_len != OUT_PATH_UNKNOWN) rep_flood_on = false; + } + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + if (success && rep_auto_refresh) requestRepeaterStatus(); // opt-in + } +} + +const char* UITask::savedRepeaterPw() { + return savedPwFor(_rep_key); +} + +void UITask::requestRepeaterStatus() { + ContactInfo* c = the_mesh.lookupContactByPubKey(_rep_key, 6); + if (c == NULL) return; + if (the_mesh.uiRequestStatus(*c) == MSG_SEND_FAILED) { + showToast("Status request failed"); + } else { + rep_stats_waiting = true; + rep_stats_valid = false; + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + } +} + +void UITask::cliResponse(const char* from_name, const char* text) { + ContactInfo* from = the_mesh.searchContactsByPrefix(from_name); + if (from == NULL) return; + chatStorePush(from->id.pub_key, false, rtc_clock.getCurrentTime(), text); + + if (memcmp(_rep_key, from->id.pub_key, 6) == 0 + && !lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) { + StrHelper::strncpy(rep_last_reply, text, sizeof(rep_last_reply)); + refreshRepeaterScr(); + return; + } + if (!lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN) + && memcmp(_thread_key, from->id.pub_key, 6) == 0) { + refreshThread(); // terminal is open: reply appears in place + } else { + char buf[48]; + snprintf(buf, sizeof(buf), LV_SYMBOL_KEYBOARD " %s replied", from_name); + showToast(buf); + } +} + +void UITask::statusResponse(const uint8_t* pub_key, const uint8_t* data, int len) { + if (memcmp(_rep_key, pub_key, 6) != 0 || len <= 0) return; + memset(&rep_stats, 0, sizeof(rep_stats)); + memcpy(&rep_stats, data, len < (int)sizeof(rep_stats) ? len : (int)sizeof(rep_stats)); + rep_stats_valid = true; + rep_stats_waiting = false; + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); +} + +void UITask::notify(UIEventType t) { + if (_node_prefs != NULL && _node_prefs->buzzer_quiet) return; // sounds off + switch (t) { + case UIEventType::contactMessage: + case UIEventType::newContactMessage: + soundMessageTone(); + break; + case UIEventType::channelMessage: + case UIEventType::roomMessage: + soundChannelTone(); + break; + case UIEventType::ack: + soundAckTone(); + break; + default: + break; + } +} + +void UITask::shutdown(bool restart) { + if (restart) _board->reboot(); + else _board->powerOff(); +} + +void UITask::loop() { + lv_timer_handler(); + chatStoreFlushLoop(); + + if (millis() > _next_status_refresh) { + refreshStatusBar(); + if (!lv_obj_has_flag(thread_scr, LV_OBJ_FLAG_HIDDEN)) { + updateThreadSubtitle(); // route can change as paths are learned + for (int k = 0; ; k++) { + ChatMsg* m = chatStoreGet(_thread_key, k); + if (m == NULL) break; + if (m->outgoing && m->status == MSG_STATUS_PENDING && millis() > m->timeout_at) { + refreshThread(); + break; + } + } + } else if (lv_tabview_get_tab_active(tabview) == 3) { // Settings: telemetry card + refreshNodeTab(); + } else if (lv_tabview_get_tab_active(tabview) == 2) { // Map tab: track GPS + mapViewRefresh(); + } + if (rep_auto_refresh && !lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN) && _rep_logged_in) { + static unsigned long next_rep_auto = 0; + if (millis() > next_rep_auto) { + next_rep_auto = millis() + 30000; + requestRepeaterStatus(); // keep the admin dashboard live + } + } + if (_trace_tag != 0 && millis() > _trace_deadline) { + _trace_tag = 0; + if (!lv_obj_has_flag(trace_scr, LV_OBJ_FLAG_HIDDEN)) { + lv_label_set_text(trace_lbl, + "No reply - trace timed out.\n\nA trace returns through the same\nhops, so hop 1 must be a repeater\nthis node can hear directly. Check\nthe path with Set path, or a hop is\noffline."); + } + } + _next_status_refresh = millis() + 2000; + } + +#ifdef SEEED_WIO_TRACKER_L2 + // WAKE button (top edge, on the IO expander): toggles screen lock + if (millis() > _next_wake_poll) { + _next_wake_poll = millis() + 150; + bool pressed = board.readWakeButton(); + if (pressed && !_wake_prev) { + if (_display_asleep) { + screenPower(false); + display.lgfxDevice()->setBrightness(brightRaw()); + bright_dimmed = false; + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + lv_display_trigger_activity(NULL); + _display_asleep = false; + } else { + display.lgfxDevice()->setBrightness(0); + bright_dimmed = false; + lv_obj_remove_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + _display_asleep = true; + _lock_grace = millis() + 1200; + screenPower(true); + } + } + _wake_prev = pressed; + } +#endif + + // low battery: warn and power off (never while externally powered) + static unsigned long next_batt_check = 30000; + if (millis() > next_batt_check) { + next_batt_check = millis() + 30000; + uint16_t mv = getBattMilliVolts(); + // re-applied each pass: the stack restarts fast advertising on disconnect + static unsigned long last_ble_conn = 0; + if (hasConnection()) { last_ble_conn = millis(); ble_slow = false; } + bool want_slow = ble_autooff_min > 0 && !ble_off_pref && isBluetoothEnabled() && !hasConnection() && + millis() - last_ble_conn > (unsigned long) ble_autooff_min * 60000UL; + if (want_slow && !ble_slow) bleSlowAdvertising(true); + static bool low_warned = false; + if (_board->isExternalPowered()) low_warned = false; + if (mv > 500 && mv < 3450 && !low_warned && !_board->isExternalPowered()) { + low_warned = true; + showToast(LV_SYMBOL_BATTERY_1 " Battery low - charge soon"); + soundChannelTone(); + } + if (mv > 500 && mv < 3250 && !_board->isExternalPowered()) { + display.lgfxDevice()->setBrightness(brightRaw()); + showToast("LOW BATTERY - shutting down"); + lv_refr_now(NULL); + delay(3000); + shutdown(false); + } + } + + // backlight auto-dim on inactivity; the sleep shield eats the wake-up tap + // (stay awake while externally powered - LCD has no burn-in concern) + uint32_t idle = lv_display_get_inactive_time(NULL); + if (!_display_asleep && idle > autoOffMs() && _board->isExternalPowered()) { + lv_display_trigger_activity(NULL); + idle = 0; + } + if (!_display_asleep) { + bool want_dim = idle > autoOffMs() - DIM_LEAD_MILLIS && idle <= autoOffMs(); + if (want_dim && !bright_dimmed) { + uint8_t dim = brightRaw() / 4; + display.lgfxDevice()->setBrightness(dim < 12 ? 12 : dim); + bright_dimmed = true; + } else if (!want_dim && bright_dimmed && idle < autoOffMs()) { + display.lgfxDevice()->setBrightness(brightRaw()); + bright_dimmed = false; + } + } + if (!_display_asleep && idle > autoOffMs()) { + display.lgfxDevice()->setBrightness(0); + bright_dimmed = false; + lv_obj_remove_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + _display_asleep = true; + screenPower(true); + } else if (_display_asleep && idle < 1000 && millis() > _lock_grace) { + screenPower(false); + display.lgfxDevice()->setBrightness(brightRaw()); + lv_obj_add_flag(sleep_shield, LV_OBJ_FLAG_HIDDEN); + _display_asleep = false; + } + +#ifndef STANDALONE_NO_BT + // Bluetooth off preference: apply once the interface is up + if (!ble_pref_applied && millis() > 4000) { + ble_pref_applied = true; + if (ble_off_pref && isBluetoothEnabled()) disableBluetooth(); + } +#endif + + static unsigned long next_gps_diag = 0; + if (millis() > next_gps_diag) { + next_gps_diag = millis() + 10000; + if (_node_prefs != NULL && _node_prefs->gps_enabled && _sensors != NULL) { + gpsWatchdogTick(); + LocationProvider* n = _sensors->getLocationProvider(); + MESH_DEBUG_PRINTLN("gps: used=%d valid=%d inview=%d strong=%d bestsnr=%d gga=%d/%d rmc=%c nmea=%d | heap int=%u largest=%u", + n != NULL ? (int)n->satellitesCount() : -1, + n != NULL ? (int)n->isValid() : 0, + gps_tap.satsInView(), gps_tap.strongSats(), gps_tap.bestSnr(), + gps_tap.ggaFix(), gps_tap.ggaUsed(), gps_tap.rmcStatus(), (int) gps_tap.streaming(), + (unsigned) heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned) heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); + } + } + + if (_pending_login_deadline != 0 && millis() > _pending_login_deadline) { + _pending_login_deadline = 0; + _rep_logging_in = false; + if (!lv_obj_has_flag(rep_scr, LV_OBJ_FLAG_HIDDEN)) refreshRepeaterScr(); + showToast("Login: no response - check password/range"); + } +} diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h new file mode 100644 index 0000000000..535387d117 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../AbstractUITask.h" +#include "../NodePrefs.h" +#include "ChatStore.h" + +struct ContactInfo; + +// LVGL-based standalone UI: status bar + bottom tabs (Chats/Contacts/Node/ +// Settings), message threads with bubbles, on-screen keyboard. +class UITask : public AbstractUITask { + SensorManager* _sensors; + NodePrefs* _node_prefs; + int _msgcount; + bool _display_asleep; + unsigned long _next_status_refresh; + uint8_t _pending_login_key[6] = {0}; + unsigned long _pending_login_deadline = 0; + bool _wake_prev = false; + unsigned long _next_wake_poll = 0; + unsigned long _lock_grace = 0; // suppress auto-wake right after button lock + bool _thread_clear_armed = false; + + // active thread (chat screen) + uint8_t _thread_key[6] = {0}; + bool _thread_is_channel = false; + bool _thread_is_console = false; // repeater terminal: sends are CLI commands + int _thread_channel_idx = -1; + char _thread_name[36] = {0}; + + // repeater manager state + uint8_t _rep_key[6] = {0}; + char _rep_name[36] = {0}; + bool _rep_logged_in = false; + bool _rep_logging_in = false; + + // trace route state (contact detail Trace button) + uint32_t _trace_tag = 0; + unsigned long _trace_started = 0; + unsigned long _trace_deadline = 0; + + // repeat-echo counter for the last message sent from the open thread + int _echo_count = 0; + unsigned long _echo_window_end = 0; + uint32_t _echo_msg_ts = 0; // which bubble the counter belongs to + + void updateThreadSubtitle(); // route (flood/hops) + echo count in header + + void buildShell(); + void buildRepeaterScr(); + void refreshRepeaterScr(); + void buildChatsTab(lv_obj_t* parent); + void buildContactsTab(lv_obj_t* parent); + void buildNodeTab(lv_obj_t* parent); + void buildSettingsTab(lv_obj_t* parent); + void refreshStatusBar(); + +public: + void refreshChatsTab(); + void refreshContactsTab(); + void refreshNodeTab(); + UITask(mesh::MainBoard* board, MultiSerialInterface* serial) + : AbstractUITask(board, serial), _sensors(NULL), _node_prefs(NULL), + _msgcount(0), _display_asleep(false), _next_status_refresh(0) { } + + void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); + + // navigation / actions (called from LVGL event callbacks) + void openThread(const uint8_t key[6], const char* name); + void openContactThread(const ContactInfo& contact); + void openConsoleThread(); // repeater terminal on _rep_key + void closeThread(); + void refreshThread(); + void sendFromThread(const char* text); + void openRepeaterManager(const ContactInfo& contact); + void closeRepeaterManager(); + void repeaterLogin(const char* password, bool flood = false); + void sendRepeaterCommand(const char* cmd); + bool threadIsConsole() const { return _thread_is_console; } + void showToast(const char* text); + void nodeNameChanged(); // refresh status bar after settings edit + void openContactDetail(int contact_idx); // long-press on contact / map marker + void clearCurrentThread(); // trash button in thread header (armed) + void openRouteForThread(); // route button in DM thread header + void openTraceForContact(int contact_idx); // trace button in contact detail + void requestRepeaterStatus(); // GUI manager status refresh + void resendMessage(int k); // tap a not-delivered bubble + void openChannelOptions(const uint8_t key[6]); // long-press a channel row + void finishSetupWizard(int preset_idx, const char* name, int tz_hours, bool apply); + const char* savedRepeaterPw(); // saved password for current repeater, or NULL + int getMsgCount() const { return _msgcount; } + NodePrefs* nodePrefs() { return _node_prefs; } + SensorManager* sensors() { return _sensors; } + + // from AbstractUITask + void msgRead(int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void msgAck(uint32_t ack_crc) override; + void msgEchoHeard() override; + void loginResult(const uint8_t* pub_key, bool success) override; + void statusResponse(const uint8_t* pub_key, const uint8_t* data, int len) override; + void cliResponse(const char* from_name, const char* text) override; + void traceResponse(uint32_t tag, const uint8_t* path_hashes, const uint8_t* path_snrs, uint8_t hop_count, int8_t final_snr) override; + void notify(UIEventType t = UIEventType::none) override; + void loop() override; + + void shutdown(bool restart = false); +}; + +// phone-style shift for LVGL keyboards: one-shot upper case, double-tap for caps lock +void kbAttachShiftBehavior(lv_obj_t* kb); diff --git a/examples/companion_radio/ui-lvgl/fa_icons_14.cpp b/examples/companion_radio/ui-lvgl/fa_icons_14.cpp new file mode 100644 index 0000000000..f904a8e297 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/fa_icons_14.cpp @@ -0,0 +1,198 @@ +/******************************************************************************* + * Size: 14 px + * Bpp: 4 + * Opts: --font fa-solid-900.ttf -r 0xF519 -r 0xF2B9 -r 0xF185 -r 0xF186 --size 14 --bpp 4 --format lvgl --lv-font-name fa_icons_14 --no-compress -o fa_icons_14.c + ******************************************************************************/ + +#ifdef LV_LVGL_H_INCLUDE_SIMPLE +#include "lvgl.h" +#else +#include "lvgl/lvgl.h" +#endif + +LV_FONT_DECLARE(lv_font_montserrat_14) + +#ifdef __cplusplus +extern "C" { +#endif + +extern const lv_font_t fa_icons_14; + +#ifndef FA_ICONS_14 +#define FA_ICONS_14 1 +#endif + +#if FA_ICONS_14 + +/*----------------- + * BITMAPS + *----------------*/ + +/*Store the image of the glyphs*/ +static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = { + /* U+F185 */ + 0x0, 0x0, 0x0, 0x12, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0xb, 0xc0, 0x0, 0x0, 0x0, 0x0, + 0x61, 0x3, 0xff, 0x40, 0x15, 0x0, 0x0, 0xe, + 0xfc, 0xcf, 0xfd, 0xcf, 0xf0, 0x0, 0x0, 0x9f, + 0xf8, 0x33, 0x7f, 0xfa, 0x0, 0x0, 0x4, 0xf5, + 0x6d, 0xd6, 0x4f, 0x50, 0x0, 0x5, 0xdc, 0x3f, + 0xff, 0xf4, 0xbd, 0x50, 0xc, 0xff, 0x88, 0xff, + 0xff, 0x97, 0xff, 0xc0, 0x4d, 0xf9, 0x7f, 0xff, + 0xf8, 0x9f, 0xd5, 0x0, 0x6, 0xe1, 0xdf, 0xfe, + 0x1e, 0x70, 0x0, 0x0, 0x7f, 0xb1, 0x55, 0x1b, + 0xf8, 0x0, 0x0, 0xc, 0xff, 0xfb, 0xbe, 0xff, + 0xd0, 0x0, 0x0, 0xd9, 0x47, 0xff, 0x84, 0x9d, + 0x0, 0x0, 0x0, 0x0, 0xe, 0xe0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x66, 0x0, 0x0, 0x0, + 0x0, + + /* U+F186 */ + 0x0, 0x0, 0x0, 0x1, 0x10, 0x0, 0x0, 0x0, + 0x0, 0x5c, 0xff, 0xf2, 0x0, 0x0, 0x0, 0xa, + 0xff, 0xfe, 0x20, 0x0, 0x0, 0x0, 0xaf, 0xff, + 0xf3, 0x0, 0x0, 0x0, 0x5, 0xff, 0xff, 0xb0, + 0x0, 0x0, 0x0, 0xd, 0xff, 0xff, 0x70, 0x0, + 0x0, 0x0, 0x1f, 0xff, 0xff, 0x60, 0x0, 0x0, + 0x0, 0x3f, 0xff, 0xff, 0x80, 0x0, 0x0, 0x0, + 0x2f, 0xff, 0xff, 0xd0, 0x0, 0x0, 0x0, 0xf, + 0xff, 0xff, 0xf6, 0x0, 0x0, 0x0, 0xb, 0xff, + 0xff, 0xff, 0x50, 0x0, 0x0, 0x3, 0xff, 0xff, + 0xff, 0xfb, 0x52, 0x30, 0x0, 0x7f, 0xff, 0xff, + 0xff, 0xff, 0xd1, 0x0, 0x6, 0xff, 0xff, 0xff, + 0xfc, 0x10, 0x0, 0x0, 0x17, 0xbd, 0xc9, 0x40, + 0x0, + + /* U+F2B9 */ + 0x3, 0x44, 0x44, 0x44, 0x43, 0x10, 0xc, 0xff, + 0xff, 0xff, 0xff, 0xfe, 0x0, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xf2, 0xf, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xa2, 0xff, 0xff, 0xb4, 0xaf, 0xff, 0xff, + 0x3f, 0xff, 0xf1, 0x0, 0xef, 0xff, 0x40, 0xff, + 0xff, 0x0, 0xe, 0xff, 0xf3, 0xf, 0xff, 0xfb, + 0x49, 0xff, 0xff, 0xf3, 0xff, 0xfe, 0xac, 0xad, + 0xff, 0xfb, 0x2f, 0xfd, 0x0, 0x0, 0xb, 0xff, + 0x20, 0xff, 0xa0, 0x0, 0x0, 0x7f, 0xf8, 0x1f, + 0xfe, 0x88, 0x88, 0x8d, 0xff, 0xf3, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xf6, 0xf, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x20, 0x9f, 0xff, 0xff, 0xff, 0xff, + 0xb0, 0x0, + + /* U+F519 */ + 0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, + 0x10, 0x6f, 0x60, 0x0, 0x0, 0x0, 0x0, 0x0, + 0xd, 0xf0, 0xbf, 0x11, 0x61, 0x0, 0x52, 0x0, + 0x54, 0x8, 0xf4, 0xed, 0x6, 0xf6, 0xb, 0xff, + 0x30, 0xed, 0x4, 0xf7, 0xfc, 0x7, 0xf4, 0xf, + 0xff, 0x70, 0xcf, 0x3, 0xf8, 0xee, 0x5, 0xf6, + 0xd, 0xff, 0x50, 0xed, 0x5, 0xf7, 0xaf, 0x20, + 0x10, 0x3f, 0xdf, 0xb0, 0x10, 0x9, 0xf3, 0x3d, + 0x50, 0x0, 0xaf, 0x4c, 0xf2, 0x0, 0xb, 0xb0, + 0x0, 0x0, 0x1, 0xfc, 0x4, 0xf9, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x8, 0xfd, 0xbb, 0xff, 0x0, + 0x0, 0x0, 0x0, 0x0, 0xe, 0xff, 0xff, 0xff, + 0x60, 0x0, 0x0, 0x0, 0x0, 0x5f, 0x80, 0x0, + 0x1f, 0xd0, 0x0, 0x0, 0x0, 0x0, 0xcf, 0x10, + 0x0, 0x9, 0xf4, 0x0, 0x0, 0x0, 0x2, 0xfa, + 0x0, 0x0, 0x2, 0xfa, 0x0, 0x0, 0x0, 0x0, + 0x62, 0x0, 0x0, 0x0, 0x62, 0x0, 0x0 +}; + + +/*--------------------- + * GLYPH DESCRIPTION + *--------------------*/ + +static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = { + {.bitmap_index = 0, .adv_w = 0, .box_w = 0, .box_h = 0, .ofs_x = 0, .ofs_y = 0} /* id = 0 reserved */, + {.bitmap_index = 0, .adv_w = 224, .box_w = 15, .box_h = 15, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 113, .adv_w = 224, .box_w = 14, .box_h = 15, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 218, .adv_w = 196, .box_w = 13, .box_h = 15, .ofs_x = 0, .ofs_y = -2}, + {.bitmap_index = 316, .adv_w = 280, .box_w = 18, .box_h = 15, .ofs_x = 0, .ofs_y = -2} +}; + +/*--------------------- + * CHARACTER MAPPING + *--------------------*/ + +static const uint16_t unicode_list_0[] = { + 0x0, 0x1, 0x134, 0x394 +}; + +/*Collect the unicode lists and glyph_id offsets*/ +static const lv_font_fmt_txt_cmap_t cmaps[] = +{ + { + .range_start = 61829, .range_length = 917, .glyph_id_start = 1, + .unicode_list = unicode_list_0, .glyph_id_ofs_list = NULL, .list_length = 4, .type = LV_FONT_FMT_TXT_CMAP_SPARSE_TINY + } +}; + + + +/*-------------------- + * ALL CUSTOM DATA + *--------------------*/ + +#if LVGL_VERSION_MAJOR == 8 +/*Store all the custom data of the font*/ +static lv_font_fmt_txt_glyph_cache_t cache; +#endif + +#if LVGL_VERSION_MAJOR >= 8 +static const lv_font_fmt_txt_dsc_t font_dsc = { +#else +static lv_font_fmt_txt_dsc_t font_dsc = { +#endif + .glyph_bitmap = glyph_bitmap, + .glyph_dsc = glyph_dsc, + .cmaps = cmaps, + .kern_dsc = NULL, + .kern_scale = 0, + .cmap_num = 1, + .bpp = 4, + .kern_classes = 0, + .bitmap_format = 0, +#if LVGL_VERSION_MAJOR == 8 + .cache = &cache +#endif +}; + + + +/*----------------- + * PUBLIC FONT + *----------------*/ + +/*Initialize a public general font descriptor*/ +#if LVGL_VERSION_MAJOR >= 8 +const lv_font_t fa_icons_14 = { +#else +lv_font_t fa_icons_14 = { +#endif + .get_glyph_dsc = lv_font_get_glyph_dsc_fmt_txt, /*Function pointer to get glyph's data*/ + .get_glyph_bitmap = lv_font_get_bitmap_fmt_txt, /*Function pointer to get glyph's bitmap*/ + .line_height = 15, /*The maximum line height required by the font*/ + .base_line = 2, /*Baseline measured from the bottom of the line*/ +#if !(LVGL_VERSION_MAJOR == 6 && LVGL_VERSION_MINOR == 0) + .subpx = LV_FONT_SUBPX_NONE, +#endif +#if LV_VERSION_CHECK(7, 4, 0) || LVGL_VERSION_MAJOR >= 8 + .underline_position = -1, + .underline_thickness = 1, +#endif + .dsc = &font_dsc, /*The custom font data. Will be accessed by `get_glyph_bitmap/dsc` */ +#if LV_VERSION_CHECK(8, 2, 0) || LVGL_VERSION_MAJOR >= 9 + .fallback = &lv_font_montserrat_14, +#endif + .user_data = NULL, +}; + + + +#endif /*#if FA_ICONS_14*/ + +#ifdef __cplusplus +} /* extern "C" */ +#endif + diff --git a/examples/companion_radio/ui-lvgl/lv_psram_pool.h b/examples/companion_radio/ui-lvgl/lv_psram_pool.h new file mode 100644 index 0000000000..ede3b2b017 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/lv_psram_pool.h @@ -0,0 +1,10 @@ +#pragma once + +// LVGL's builtin allocator pool, placed in PSRAM: keeps every widget, image +// decode, and cache allocation out of internal RAM so the Bluetooth stack +// and SD driver always have room for their connection-time bursts. +#include + +static inline void* lv_psram_pool_alloc(size_t sz) { + return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); +} diff --git a/examples/companion_radio/ui-lvgl/meshcore_logo.cpp b/examples/companion_radio/ui-lvgl/meshcore_logo.cpp new file mode 100644 index 0000000000..4abd15d87b --- /dev/null +++ b/examples/companion_radio/ui-lvgl/meshcore_logo.cpp @@ -0,0 +1,37 @@ +// MeshCore wordmark for the boot splash. The bitmap is the same one the other +// display UIs draw; it is expanded to a 2x alpha mask so LVGL can scale it into +// place and tint it from the widget style. +#include +#include "../ui-new/icons.h" +#include "lv_psram_pool.h" + +#define LOGO_W 128 +#define LOGO_H 13 +#define LOGO_ZOOM 2 + +static lv_image_dsc_t logo_dsc; + +const lv_image_dsc_t* meshcoreLogoImage() { + if (logo_dsc.data != NULL) return &logo_dsc; + + const uint32_t w = LOGO_W * LOGO_ZOOM, h = LOGO_H * LOGO_ZOOM; + uint8_t* mask = (uint8_t *) lv_psram_pool_alloc(w * h); + if (mask == NULL) return NULL; + + for (uint32_t y = 0; y < h; y++) { + const uint8_t* row = &meshcore_logo[(y / LOGO_ZOOM) * (LOGO_W / 8)]; + for (uint32_t x = 0; x < w; x++) { + uint32_t sx = x / LOGO_ZOOM; + mask[y * w + x] = (row[sx >> 3] & (0x80 >> (sx & 7))) ? 0xff : 0x00; + } + } + + logo_dsc.header.magic = LV_IMAGE_HEADER_MAGIC; + logo_dsc.header.cf = LV_COLOR_FORMAT_A8; + logo_dsc.header.w = w; + logo_dsc.header.h = h; + logo_dsc.header.stride = w; + logo_dsc.data_size = w * h; + logo_dsc.data = mask; + return &logo_dsc; +} diff --git a/src/helpers/ui/UIScreen.h b/src/helpers/ui/UIScreen.h index 6aa1d69c10..8f694d7d5a 100644 --- a/src/helpers/ui/UIScreen.h +++ b/src/helpers/ui/UIScreen.h @@ -20,6 +20,7 @@ class UIScreen { public: virtual int render(DisplayDriver& display) =0; // return value is number of millis until next render virtual bool handleInput(char c) { return false; } + virtual bool handleTouch(int x, int y) { return false; } // logical display coords, tap-on-release virtual void poll() { } }; diff --git a/variants/wio-tracker-l2/GpsTap.h b/variants/wio-tracker-l2/GpsTap.h new file mode 100644 index 0000000000..020f5e9b18 --- /dev/null +++ b/variants/wio-tracker-l2/GpsTap.h @@ -0,0 +1,139 @@ +#pragma once + +#include + +// Transparent wrapper around the GNSS UART. MicroNMEA ignores GSV sentences, +// so this watches the byte stream for them and keeps a running count of +// satellites in view, strong satellites and best SNR, which separates RF +// faults from a receiver that only lacks sky view. It also records the +// fix flags straight from GGA/RMC so a parser problem upstream can't hide a +// real fix, and can hand every complete line to a logger (SD card). +class GpsTapStream : public Stream { + Stream& _src; + char _line[120]; + uint8_t _len = 0; + struct Talker { char id[3]; uint8_t in_view; uint8_t strong; uint8_t strong_acc; unsigned long at; }; + Talker _talkers[6] = {}; + int _best_snr = 0; + unsigned long _best_at = 0; + unsigned long _last_gsv = 0; + int _gga_fix = -1; // GGA fix quality (0 = no fix) + int _gga_used = 0; // GGA satellites used + char _rmc_status = '?'; // RMC status: A = valid, V = void + unsigned long _last_fix_sentence = 0; + + static const unsigned long FRESH_MS = 6000; + static const int STRONG_DB = 30; + + // pointer to comma-separated field n (0 = sentence id), NULL if absent + static const char* field(const char* s, int n) { + const char* p = s; + for (int i = 0; i < n; i++) { + p = strchr(p, ','); + if (p == NULL) return NULL; + p++; + } + return p; + } + + void parseGsv(const char* s) { + // $GPGSV,total,msg,in_view,prn,elev,az,snr,...*cs + char talker[3] = {s[1], s[2], 0}; + const char* f; + int total = (f = field(s, 1)) ? atoi(f) : 0; + int msg = (f = field(s, 2)) ? atoi(f) : 0; + int in_view = (f = field(s, 3)) ? atoi(f) : -1; + if (in_view < 0) return; + + int strong_here = 0; + for (int k = 0; k < 4; k++) { + const char* snr = field(s, 7 + 4 * k); + if (snr == NULL || *snr == ',' || *snr == '*' || *snr == 0) continue; + int v = atoi(snr); + if (v >= STRONG_DB) strong_here++; + if (v > _best_snr || millis() - _best_at > FRESH_MS) { _best_snr = v; _best_at = millis(); } + } + + int slot = -1; + for (int i = 0; i < 6; i++) { + if (strcmp(_talkers[i].id, talker) == 0) { slot = i; break; } + if (slot < 0 && _talkers[i].id[0] == 0) slot = i; + } + if (slot < 0) return; + Talker& t = _talkers[slot]; + memcpy(t.id, talker, 3); + t.in_view = (uint8_t) in_view; + if (msg <= 1) t.strong_acc = 0; + t.strong_acc += strong_here; + if (msg >= total) t.strong = t.strong_acc; + t.at = millis(); + _last_gsv = millis(); + } + + void parseFixFlags(const char* s) { + const char* type = s + 3; + if (strncmp(type, "GGA", 3) == 0) { + const char* f; + _gga_fix = (f = field(s, 6)) && *f != ',' ? atoi(f) : 0; + _gga_used = (f = field(s, 7)) && *f != ',' ? atoi(f) : 0; + _last_fix_sentence = millis(); + } else if (strncmp(type, "RMC", 3) == 0) { + const char* f = field(s, 2); + _rmc_status = (f != NULL && *f != ',' && *f != 0) ? *f : '?'; + _last_fix_sentence = millis(); + } + } + + void feed(int c) { + if (echo) Serial.write((uint8_t) c); + if (c == '\n' || c == '\r') { + if (_len > 6 && _line[0] == '$') { + _line[_len] = 0; + if (on_line != NULL) on_line(_line); + if (strncmp(_line + 3, "GSV", 3) == 0) parseGsv(_line); + else parseFixFlags(_line); + } + _len = 0; + } else if (_len < sizeof(_line) - 1) { + _line[_len++] = (char) c; + } + } + +public: + bool echo = false; // mirror raw NMEA to USB Serial + void (*on_line)(const char* line) = NULL; // complete-sentence hook (e.g. SD log) + + explicit GpsTapStream(Stream& src) : _src(src) {} + + int satsInView() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].in_view; + } + return n; + } + int strongSats() const { + int n = 0; + for (int i = 0; i < 6; i++) { + if (_talkers[i].id[0] != 0 && millis() - _talkers[i].at < FRESH_MS) n += _talkers[i].strong; + } + return n; + } + int bestSnr() const { return millis() - _best_at < FRESH_MS ? _best_snr : 0; } + bool streaming() const { return millis() - _last_gsv < FRESH_MS; } + int ggaFix() const { return _gga_fix; } + int ggaUsed() const { return _gga_used; } + char rmcStatus() const { return _rmc_status; } + + // Stream interface: pass-through with a tap on read() + int available() override { return _src.available(); } + int peek() override { return _src.peek(); } + int read() override { + int c = _src.read(); + if (c >= 0) feed(c); + return c; + } + void flush() override { _src.flush(); } + size_t write(uint8_t b) override { return _src.write(b); } + size_t write(const uint8_t* buf, size_t n) override { return _src.write(buf, n); } +}; diff --git a/variants/wio-tracker-l2/README.md b/variants/wio-tracker-l2/README.md new file mode 100644 index 0000000000..0e5dfa73d0 --- /dev/null +++ b/variants/wio-tracker-l2/README.md @@ -0,0 +1,118 @@ +# MeshCore for Seeed Wio Tracker L2 Pro + +MeshCore port for the **Seeed Studio Wio Tracker L2 Pro** (pre-release hardware; +pin map may change on production units). + +## Hardware + +| Component | Detail | +|-----------|--------| +| MCU module | Wio-S3: ESP32-S3, 16 MB flash (QIO), 8 MB PSRAM (OPI) | +| LoRa | SX1262, 862-930 MHz (SCK 4, MISO 5, MOSI 6, CS 21, RST 7, BUSY 8, DIO1 9, DIO2 = RF switch, TCXO 1.8 V) | +| GNSS | Quectel L76K (GPS/BeiDou/GLONASS/QZSS), UART: module TX to GPIO 18, module RX from GPIO 17, 9600 baud | +| Display | 3.2" 320x240 NV3031B on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46), 75 MHz | +| Touch | GT911 capacitive, I2C 0x5D | +| Backlight | LP5814 LED driver, I2C 0x2C | +| I/O expander | TCA9535, I2C 0x21 - gates power/reset for GNSS, LCD, touch, SD, speaker amp, battery ADC, Grove | +| Battery ADC | ADS1115, I2C 0x48, AIN0 through x2 divider | +| USB-C detect | AW35615 CC controller, I2C 0x22 (charge detection) | +| Audio | ES8311 codec (speaker, alert tones) + ES7243E mic ADC (unused) on I2S | +| I2C bus | SDA 47, SCL 48 | +| Buttons | User/Boot = GPIO 0; Wake/lock button on expander P00 | +| SD card | SDIO 1-bit: CLK 2, CMD 3, D0 1 - offline map tiles, logs | + +## Firmware flavors + +| Env | What it does | +|-----|--------------| +| `Wio_Tracker_L2_companion_radio_ble` | **Phone companion.** Standard display UI, MeshCore app over BLE (pairing PIN `123456`), on-screen Bluetooth toggle page. | +| `Wio_Tracker_L2_standalone_lvgl` | **Standalone.** Full touch UI, Bluetooth compiled out (USB CLI only). | +| `Wio_Tracker_L2_companion_radio_usb` | Standard display companion over USB-C serial. | +| `Wio_Tracker_L2_repeater` | Standalone mesh repeater. | +| `Wio_Tracker_L2_room_server` | Standalone room/BBS server. | + +## Touch UI (`ui-lvgl`) + +LVGL 9 interface for the standalone build - no phone involved: + +- **Chats**: direct messages and `#channels` with persisted history, delivery + states (sent / delivered / failed with tap-to-resend), repeat-echo counter, + quick replies, and a live flood/direct/hops route indicator per contact. +- **Contacts**: type filters, name/recency sort, detail view (last heard, + path, distance/bearing), manual path picker (up to eight hops), zero-hop + contact share, and path trace for repeaters. A trace is sent out along the + chosen hops and back through the same ones, so the reply is only received + when the first hop is within direct range. +- **Repeater admin**: saved passwords, status dashboard with auto-refresh, + one-tap advert / clock sync / version / neighbors / reboot, full CLI. +- **Map**: offline tiles from SD with pan/zoom, own position, tappable node + markers, day and night tile sets (sun/moon toggle). +- **Settings**: radio presets + full parameter editor with repeat mode, + TX power, node name, timezone, brightness with auto-dim, screen timeout, + alert tone styles, 12/24 h clock, km/mi units, node backup/restore to SD, + factory reset. +- First-boot wizard (region / name / timezone), notification banner, + unread badges, About screen with the node's public key. + +## Offline maps + +The map reads 256 px OSM raster tiles from a FAT32 card in the usual layout, +so a tile folder prepared for any other mesh device works unchanged: + +``` +/maps/{z}/{x}/{y}.png +``` + +Nothing else is required. Copy that folder to the card root and the map +renders it. + +Two optional extras this variant understands: + +- **Packed tiles.** `/maps/{z}/{x}.pak` holds one tile column per file: + `'TPK1' | u32 y_min | u32 y_max | u32 offsets[n+1] | PNG blobs` + (little-endian; equal offsets mark a missing tile). Loose tiles are read + when no pack covers a column, so the two can be mixed. Packing matters only + for very large sets: a few thousand pack files copy to a card in minutes, + where the equivalent millions of loose PNGs take many hours and waste most + of the card on cluster overhead. +- **A night tile set** in `/maps_dark/`, same layout either way. The map's + sun/moon button swaps sets when one is present, and dims the day tiles when + it is not. + +Helper scripts live in `variants/wio-tracker-l2/tools/`: `tile_downloader.py` +(fetch or render tiles), `tile_packer.py` (pack columns) and +`tile_darkener.py` (derive a night set from the day set). None of them are +needed if you already have a tile folder. + +## Build & flash + +```bash +# from the MeshCore repo root +pio run -e Wio_Tracker_L2_standalone_lvgl + +# bootloader mode if needed: hold User/Boot, tap RST, release - then: +pio run -e Wio_Tracker_L2_standalone_lvgl -t upload +``` + +The board enumerates as a native USB-CDC port (auto-reset via 1200-bps touch +is enabled). For the BLE companion build, pair from the MeshCore app with the +PIN shown on the device screen. + +## Port notes + +- **Everything hangs off the TCA9535 expander.** `WioTrackerL2Board::begin()` + replays Seeed's power-up sequence; order and delays matter (especially the + 500 ms LCD reset settle). If the expander probe fails, the firmware still + runs but GPS/display/battery reads are dead. +- **Battery** is read via the ADS1115 at +/-4.096 V FSR and mapped through a + LiPo discharge curve. +- **GNSS**: GPS+BeiDou+GLONASS are enabled at start (`$PCAS04,7`). A watchdog + resets the module if it tracks 4+ strong satellites for minutes without + producing a fix. Raw NMEA can be logged to SD for diagnosis. +- **Power**: CPU drops to 80 MHz while the screen sleeps; the speaker amp and + I2S clocks run only while a tone plays; the GNSS and Grove rails are gated; + idle Bluetooth switches to slow advertising. +- **Display orientation**: `offset_rotation=1` (landscape). If a unit shows + the UI upside-down, use 3 in `WioTrackerL2Display.h`. +- Pin map source: `meshtastic/firmware` PR #10909 and `meshtastic/device-ui` + (`wio-l2` branches). diff --git a/variants/wio-tracker-l2/Sound.cpp b/variants/wio-tracker-l2/Sound.cpp new file mode 100644 index 0000000000..b84f4a7719 --- /dev/null +++ b/variants/wio-tracker-l2/Sound.cpp @@ -0,0 +1,157 @@ +#include "Sound.h" + +#include + +#include +#include +#include +#include +#include "AudioBoard.h" // pschatzmann/arduino-audio-driver +#include +#include + +// Wio-S3 audio wiring: ES8311 codec on I2C (0x18), I2S MCLK 10 / BCK 11 / +// WS 12 / DOUT 16; speaker amp power (PA EN) is raised by the board init. +#define SND_I2S_MCLK 10 +#define SND_I2S_BCK 11 +#define SND_I2S_WS 12 +#define SND_I2S_DOUT 16 +#define SND_SAMPLE_RATE 44100 + +static DriverPins snd_pins; +static AudioBoard snd_codec(AudioDriverES8311, snd_pins); +static bool snd_ready = false; +static void (*snd_amp_ctl)(bool) = NULL; // board hook: speaker amp power + +void soundSetAmpControl(void (*fn)(bool on)) { snd_amp_ctl = fn; } + +void soundInit() { + // i2s_driver_install crashes inside IDF's cleanup if its DMA allocation + // fails (LoadProhibited boot loop) - refuse to try without clear headroom + if (heap_caps_get_free_size(MALLOC_CAP_DMA) < 60000) { + MESH_DEBUG_PRINTLN("sound: low DMA heap - sound disabled this boot"); + return; + } + + snd_pins.addI2C(PinFunction::CODEC, Wire); + snd_pins.addI2S(PinFunction::CODEC, SND_I2S_MCLK, SND_I2S_BCK, SND_I2S_WS, SND_I2S_DOUT, -1); + + CodecConfig cfg; + cfg.input_device = ADC_INPUT_NONE; + cfg.output_device = DAC_OUTPUT_ALL; + cfg.i2s.bits = BIT_LENGTH_16BITS; + cfg.i2s.rate = RATE_44K; + if (!snd_codec.begin(cfg)) { + MESH_DEBUG_PRINTLN("sound: ES8311 init failed - sound disabled"); + return; + } + snd_codec.setVolume(70); + + i2s_config_t i2s_cfg = {}; + i2s_cfg.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX); + i2s_cfg.sample_rate = SND_SAMPLE_RATE; + i2s_cfg.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + i2s_cfg.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + i2s_cfg.communication_format = I2S_COMM_FORMAT_STAND_I2S; + i2s_cfg.intr_alloc_flags = 0; + i2s_cfg.dma_buf_count = 2; // keep the DMA footprint minimal + i2s_cfg.dma_buf_len = 256; + i2s_cfg.tx_desc_auto_clear = true; + if (i2s_driver_install(I2S_NUM_0, &i2s_cfg, 0, NULL) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s driver install failed"); + return; + } + i2s_pin_config_t pin_cfg = {}; + pin_cfg.mck_io_num = SND_I2S_MCLK; + pin_cfg.bck_io_num = SND_I2S_BCK; + pin_cfg.ws_io_num = SND_I2S_WS; + pin_cfg.data_out_num = SND_I2S_DOUT; + pin_cfg.data_in_num = I2S_PIN_NO_CHANGE; + if (i2s_set_pin(I2S_NUM_0, &pin_cfg) != ESP_OK) { + MESH_DEBUG_PRINTLN("sound: i2s pin config failed"); + return; + } + i2s_stop(I2S_NUM_0); // clocks off until a tone plays (GNSS-adjacent EMI, battery) + snd_ready = true; + MESH_DEBUG_PRINTLN("sound: ES8311 + I2S ready"); +} + +bool soundReady() { return snd_ready; } + +void soundBeep(int freq_hz, int duration_ms) { + if (!snd_ready) return; + if (snd_amp_ctl) snd_amp_ctl(true); + i2s_start(I2S_NUM_0); + delay(3); // amp settle before the first samples + const int total = SND_SAMPLE_RATE * duration_ms / 1000; + const float amp = 6000.0f; // gentle level; codec volume does the rest + static int16_t buf[256 * 2]; // 256 stereo frames per chunk + + int written_frames = 0; + while (written_frames < total) { + int chunk = total - written_frames; + if (chunk > 256) chunk = 256; + for (int i = 0; i < chunk; i++) { + float t = (float)(written_frames + i) / SND_SAMPLE_RATE; + // short attack/decay envelope so the beep doesn't click + float env = 1.0f; + int pos = written_frames + i; + if (pos < 220) env = pos / 220.0f; + else if (pos > total - 220) env = (total - pos) / 220.0f; + int16_t s = (int16_t)(amp * env * sinf(2.0f * (float)M_PI * freq_hz * t)); + buf[i * 2] = s; + buf[i * 2 + 1] = s; + } + size_t written_bytes = 0; + i2s_write(I2S_NUM_0, buf, chunk * 2 * sizeof(int16_t), &written_bytes, portMAX_DELAY); + written_frames += chunk; + } + i2s_zero_dma_buffer(I2S_NUM_0); + i2s_stop(I2S_NUM_0); + if (snd_amp_ctl) snd_amp_ctl(false); +} + +// selectable alert melodies; {0,0} terminates a style early +struct ToneNote { uint16_t freq; uint16_t ms; }; +static const ToneNote TONE_STYLES[SOUND_TONE_STYLES][5] = { + {{880, 90}, {1175, 120}, {0, 0}}, // Classic + {{1400, 40}, {1800, 40}, {2200, 60}, {0, 0}}, // Chirp + {{1319, 140}, {988, 180}, {0, 0}}, // Ding dong + {{1568, 45}, {1245, 45}, {1568, 45}, {1245, 90}, {0, 0}}, // Trill + {{659, 70}, {880, 70}, {1175, 70}, {1568, 110}, {0, 0}}, // Rise + {{1047, 120}, {523, 120}, {1047, 120}, {0, 0}}, // Alarm +}; +static int tone_style = 0; + +void soundLoadTonePref() { + File f = SPIFFS.open("/tone", "r"); + if (f) { + int v = f.parseInt(); + f.close(); + if (v >= 0 && v < SOUND_TONE_STYLES) tone_style = v; + } +} + +void soundSetToneStyle(int style) { + if (style < 0 || style >= SOUND_TONE_STYLES) return; + tone_style = style; + File f = SPIFFS.open("/tone", "w"); + if (f) { + f.print(style); + f.close(); + } +} + +int soundGetToneStyle() { return tone_style; } + +void soundMessageTone() { + const ToneNote* n = TONE_STYLES[tone_style]; + for (int i = 0; i < 5 && n[i].freq != 0; i++) soundBeep(n[i].freq, n[i].ms); +} + +void soundChannelTone() { + // softer cousin of the chosen alert: first note, dropped a fourth + soundBeep(TONE_STYLES[tone_style][0].freq * 3 / 4, 100); +} + +void soundAckTone() { soundBeep(1568, 50); } diff --git a/variants/wio-tracker-l2/Sound.h b/variants/wio-tracker-l2/Sound.h new file mode 100644 index 0000000000..9e5f633c0b --- /dev/null +++ b/variants/wio-tracker-l2/Sound.h @@ -0,0 +1,19 @@ +#pragma once + +// Board audio backend for the Wio Tracker L2 Pro (ES8311 codec + speaker). +// UI code includes "Sound.h" and gets whatever the variant provides; all +// functions are safe no-ops if the codec failed to initialize. + +#define SOUND_TONE_STYLES 6 // Classic, Chirp, Ding dong, Trill, Rise, Alarm + +void soundInit(); // call once after Wire is up (board.begin done) +void soundSetAmpControl(void (*fn)(bool on)); // board hook to gate the speaker amp power +bool soundReady(); +void soundBeep(int freq_hz, int duration_ms); // short blocking tone +void soundMessageTone(); // incoming DM (plays the selected alert style) +void soundChannelTone(); // incoming channel message (softer variant) +void soundAckTone(); // delivery ack / confirm + +void soundLoadTonePref(); // read /tone from SPIFFS (safe before soundInit) +void soundSetToneStyle(int style); // select + persist an alert style +int soundGetToneStyle(); diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.cpp b/variants/wio-tracker-l2/WioTrackerL2Board.cpp new file mode 100644 index 0000000000..edeaacce52 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.cpp @@ -0,0 +1,204 @@ +#include "WioTrackerL2Board.h" + +// TCA9535 register map +static const uint8_t TCA_REG_OUTPUT0 = 0x02; +static const uint8_t TCA_REG_OUTPUT1 = 0x03; +static const uint8_t TCA_REG_CONFIG0 = 0x06; // 1 = input, 0 = output +static const uint8_t TCA_REG_CONFIG1 = 0x07; + +// ADS1115 registers / config bits +static const uint8_t ADS_REG_CONVERSION = 0x00; +static const uint8_t ADS_REG_CONFIG = 0x01; +// OS=1 (start single shot) | MUX=100 (AIN0 vs GND) | PGA=001 (+/-4.096V) +// | MODE=1 (single shot) | DR=100 (128 SPS) | COMP_QUE=11 (disabled) +static const uint16_t ADS_CFG_BATT = 0x8000 | 0x4000 | 0x0200 | 0x0100 | 0x0080 | 0x0003; +// +/-4.096V FSR -> 125uV/LSB; battery is behind a x2 divider -> 0.25 mV/LSB +static const float ADS_MV_PER_LSB = 0.125f * 2.0f; + +bool WioTrackerL2Board::expWriteReg(uint8_t reg, uint8_t val) { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write(reg); + Wire.write(val); + return Wire.endTransmission() == 0; +} + +void WioTrackerL2Board::expSetOutput(uint8_t pin, bool initial_level) { + uint8_t port = pin >> 3, bit = pin & 7; + // set desired level first so the pin doesn't glitch when direction flips + if (initial_level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); + + cfg_shadow[port] &= ~(1 << bit); // 0 = output + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expSetInput(uint8_t pin) { + uint8_t port = pin >> 3, bit = pin & 7; + cfg_shadow[port] |= (1 << bit); + expWriteReg(port ? TCA_REG_CONFIG1 : TCA_REG_CONFIG0, cfg_shadow[port]); +} + +void WioTrackerL2Board::expWritePin(uint8_t pin, bool level) { + uint8_t port = pin >> 3, bit = pin & 7; + if (level) out_shadow[port] |= (1 << bit); + else out_shadow[port] &= ~(1 << bit); + expWriteReg(port ? TCA_REG_OUTPUT1 : TCA_REG_OUTPUT0, out_shadow[port]); +} + +// Power-up sequence mirrors Seeed's reference firmware for this board +// (order and delays matter, especially the LCD reset settle time). +bool WioTrackerL2Board::initExpander() { + Wire.beginTransmission(TCA9535_ADDR); + if (Wire.endTransmission() != 0) { + return false; // expander not responding + } + + expSetInput(EXP_PIN_WAKE_BTN); + expSetInput(EXP_PIN_I2C_IRQ); + expSetInput(EXP_PIN_SD_DETECT); + + expSetOutput(EXP_PIN_OTG_EN, LOW); + delay(10); + expSetOutput(EXP_PIN_PA_EN, LOW); // amp only powered while a tone plays + delay(10); + expSetOutput(EXP_PIN_TF_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_BAT_ADC_EN, HIGH); + delay(10); + expSetOutput(EXP_PIN_GNSS_EN, HIGH); + delay(10); + // GNSS reset is active HIGH; hold 10ms then release LOW so the L76K runs + expSetOutput(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + // user LED is driven active-low through the expander; idle level HIGH = off + expSetOutput(EXP_PIN_USER_LED, HIGH); + delay(10); + expSetOutput(EXP_PIN_GROVE_EN, HIGH); + delay(10); + + expSetOutput(EXP_PIN_LCD_EN, HIGH); + delay(50); + expSetOutput(EXP_PIN_LCD_RST, HIGH); + delay(5); + expWritePin(EXP_PIN_LCD_RST, LOW); + delay(10); + expWritePin(EXP_PIN_LCD_RST, HIGH); + delay(500); // NV3031B needs a long settle after reset before init commands + expSetOutput(EXP_PIN_LCD_CS, HIGH); + delay(10); + + // GT911 touch reset sequence: INT low during reset selects I2C addr 0x5D + expSetOutput(EXP_PIN_TP_RST, LOW); + expSetOutput(EXP_PIN_TP_INT, LOW); + delay(10); + expWritePin(EXP_PIN_TP_RST, HIGH); + delay(60); + + // capture WAKE button idle level (polarity unknown on alpha hardware) + int inputs = expReadInputs(); + wake_btn_baseline = inputs >= 0 ? (uint8_t)(inputs & 1) : 0; + + return true; +} + +void WioTrackerL2Board::begin() { + // GNSS UART: NMEA arrives at ~500 B/s and the main loop can stall for + // hundreds of ms on SD tile decodes; the 256-byte default overflowed + Serial1.setRxBufferSize(1024); + + uint32_t t0 = millis(); + while (!Serial && millis() - t0 < 3000) { delay(50); } + MESH_DEBUG_PRINTLN("WioTrackerL2Board: ESP32Board init"); + + ESP32Board::begin(); // starts Wire on PIN_BOARD_SDA/PIN_BOARD_SCL (47/48) + + pinMode(PIN_USER_BTN, INPUT_PULLUP); + pinMode(P_LORA_MISO, INPUT_PULLUP); + + MESH_DEBUG_PRINTLN("WioTrackerL2Board: TCA9535 expander init"); + expander_ok = initExpander(); + if (!expander_ok) { + Serial.println("ERROR: TCA9535 IO expander not found - peripherals unpowered!"); + } + + Wire.beginTransmission(0x22); + aw_ok = Wire.endTransmission() == 0; + MESH_DEBUG_PRINTLN("WioTrackerL2Board: init done"); + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; // LoRa packet woke us from deep sleep + } + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +void WioTrackerL2Board::setLed(bool on) { + // P10 doubles as GNSS wakeup and idles HIGH; blink = brief LOW pulses so + // the GPS never sees a sustained low level + if (expander_ok) { + expWritePin(EXP_PIN_USER_LED, !on); + } +} + +int WioTrackerL2Board::expReadInputs() { + Wire.beginTransmission(TCA9535_ADDR); + Wire.write((uint8_t)0x00); // input port 0 register + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)TCA9535_ADDR, 2) != 2) return -1; + int lo = Wire.read(); + int hi = Wire.read(); + return lo | (hi << 8); +} + +bool WioTrackerL2Board::isExternalPowered() { + if (!aw_ok) return false; + Wire.beginTransmission(0x22); + Wire.write((uint8_t)0x40); // STATUS0 + if (Wire.endTransmission() != 0) return false; + if (Wire.requestFrom(0x22, 1) != 1) return false; + return (Wire.read() & 0x80) != 0; // VBUSOK bit +} + +bool WioTrackerL2Board::readWakeButton() { + if (!expander_ok) return false; + int v = expReadInputs(); + if (v < 0) return false; + return ((uint8_t)(v & 1)) != wake_btn_baseline; +} + +int16_t WioTrackerL2Board::adsReadRaw() { + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONFIG); + Wire.write((uint8_t)(ADS_CFG_BATT >> 8)); + Wire.write((uint8_t)(ADS_CFG_BATT & 0xFF)); + if (Wire.endTransmission() != 0) return -1; + + delay(10); // 128 SPS -> ~8ms conversion time + + Wire.beginTransmission(ADS1115_ADDR); + Wire.write(ADS_REG_CONVERSION); + if (Wire.endTransmission() != 0) return -1; + if (Wire.requestFrom((int)ADS1115_ADDR, 2) != 2) return -1; + + // sequence the two reads explicitly: operand evaluation order of <<| + // is unspecified and each read dequeues a byte + uint8_t hi = Wire.read(); + uint8_t lo = Wire.read(); + int16_t raw = ((int16_t)hi << 8) | lo; + return raw < 0 ? 0 : raw; +} + +uint16_t WioTrackerL2Board::getBattMilliVolts() { + if (!expander_ok) return 0; // BAT_ADC_EN rail never came up + + int16_t raw = adsReadRaw(); + if (raw < 0) return 0; + + return (uint16_t)(raw * ADS_MV_PER_LSB); +} diff --git a/variants/wio-tracker-l2/WioTrackerL2Board.h b/variants/wio-tracker-l2/WioTrackerL2Board.h new file mode 100644 index 0000000000..c5644e2f20 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Board.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include "helpers/ESP32Board.h" + +// --------------------------------------------------------------------------- +// Seeed Wio Tracker L2 Pro (Wio-S3 module: ESP32-S3 + SX1262) +// +// Nearly all peripheral power rails and reset lines are behind a TCA9535 +// 16-bit I2C IO expander at address 0x21. Nothing (GNSS, LCD, touch, SD, +// battery ADC, speaker amp) works until the expander is configured, so +// begin() must run before any display / GPS / sensor init. +// +// Battery voltage is read through an ADS1115 16-bit I2C ADC at 0x48, +// channel AIN0, behind a x2 resistive divider (rail gated by expander). +// --------------------------------------------------------------------------- + +#define TCA9535_ADDR 0x21 +#define ADS1115_ADDR 0x48 + +// TCA9535 pin numbering: 0..7 = port 0 (P00..P07), 8..15 = port 1 (P10..P17) +#define EXP_PIN_WAKE_BTN 0 // input - side WAKE button +#define EXP_PIN_I2C_IRQ 1 // input - shared I2C IRQ +#define EXP_PIN_SD_DETECT 2 // input - microSD card detect +#define EXP_PIN_TP_INT 3 // output - touch panel interrupt (driven for reset seq) +#define EXP_PIN_LCD_CS 4 // output - LCD chip select (idle high) +#define EXP_PIN_LCD_EN 5 // output - LCD power enable +#define EXP_PIN_LCD_RST 6 // output - LCD reset +#define EXP_PIN_GROVE_EN 7 // output - Grove port power +#define EXP_PIN_TP_RST 8 // output - touch panel reset +#define EXP_PIN_GNSS_RST 9 // output - GNSS reset (active HIGH) +#define EXP_PIN_USER_LED 10 // output - mesh/user LED (also GNSS wakeup) +#define EXP_PIN_OTG_EN 11 // output - USB OTG power +#define EXP_PIN_PA_EN 12 // output - speaker amp power +#define EXP_PIN_GNSS_EN 13 // output - GNSS power +#define EXP_PIN_TF_EN 14 // output - microSD power +#define EXP_PIN_BAT_ADC_EN 15 // output - battery ADC divider enable + +class WioTrackerL2Board : public ESP32Board { +public: + void begin(); + + uint16_t getBattMilliVolts() override; + + const char* getManufacturerName() const override { + return "Seeed Wio Tracker L2 Pro"; + } + + void onBeforeTransmit() override { setLed(true); } + void onAfterTransmit() override { setLed(false); } + + // Mesh/user LED lives on the IO expander, not a GPIO + void setLed(bool on); + + // speaker amplifier power (expander P12); off when idle to keep the GNSS + // antenna away from class-D switching noise and to save battery + void setSpeakerAmp(bool on) { expWritePin(EXP_PIN_PA_EN, on); } + + // hardware reset pulse to the L76K GNSS (active HIGH on this board); the + // module keeps almanac/ephemeris across it, so this is a warm restart + // GNSS rail: off saves the whole receiver when GPS is disabled; on re-runs + // the power-up reset so the module comes back cleanly + void setGnssPower(bool on) { + if (on) { + expWritePin(EXP_PIN_GNSS_EN, HIGH); + delay(10); + gnssReset(); + } else { + expWritePin(EXP_PIN_GNSS_EN, LOW); + } + } + // Grove expansion port rail (nothing on-board depends on it) + void setGrovePower(bool on) { expWritePin(EXP_PIN_GROVE_EN, on); } + + void gnssReset() { + expWritePin(EXP_PIN_GNSS_RST, HIGH); + delay(10); + expWritePin(EXP_PIN_GNSS_RST, LOW); + } + + // WAKE button on expander P00: pressed = level differs from boot baseline + bool readWakeButton(); + + // VBUS presence via the AW35615 USB-C controller (I2C 0x22) + bool isExternalPowered() override; + + bool expanderOK() const { return expander_ok; } + +private: + uint8_t out_shadow[2] = { 0xFF, 0xFF }; // TCA9535 output regs default high + uint8_t cfg_shadow[2] = { 0xFF, 0xFF }; // 1 = input (power-on default) + bool expander_ok = false; + bool aw_ok = false; // AW35615 USB-C controller responded at probe + uint8_t wake_btn_baseline = 0; // idle level of P00, captured at init + + int expReadInputs(); // 16-bit input register pair, -1 on error + + bool expWriteReg(uint8_t reg, uint8_t val); + void expSetOutput(uint8_t pin, bool initial_level); + void expSetInput(uint8_t pin); + void expWritePin(uint8_t pin, bool level); + bool initExpander(); + + int16_t adsReadRaw(); +}; diff --git a/variants/wio-tracker-l2/WioTrackerL2Display.h b/variants/wio-tracker-l2/WioTrackerL2Display.h new file mode 100644 index 0000000000..d9089a64d4 --- /dev/null +++ b/variants/wio-tracker-l2/WioTrackerL2Display.h @@ -0,0 +1,210 @@ +#pragma once + +#include +#include + +#define LGFX_USE_V1 +#include + +// --------------------------------------------------------------------------- +// Wio Tracker L2 Pro display stack: +// - NV3031B 320x240 panel on quad-SPI (SCLK 42, IO0-3 = 41/40/39/38, CS 46) +// - GT911 capacitive touch on I2C 0x5D (SDA 47 / SCL 48) +// - LP5814 4-channel LED driver on I2C 0x2C used as backlight +// Panel power/reset lines are on the TCA9535 expander and are already +// sequenced by WioTrackerL2Board::begin() before this driver initializes. +// --------------------------------------------------------------------------- + +#ifndef L2_SPI_FREQUENCY + #define L2_SPI_FREQUENCY 75000000 +#endif + +#define LP5814_I2C_ADDR 0x2C + +// LP5814 used as backlight controller (all 4 channels in parallel) +class WioTrackerL2Backlight : public lgfx::v1::ILight { + static constexpr uint8_t REG_DEVICE_CONFIG0 = 0x00; + static constexpr uint8_t REG_MAX_CURRENT = 0x01; + static constexpr uint8_t REG_ENABLE_CONTROL = 0x02; + static constexpr uint8_t REG_DIM_MODE = 0x04; + static constexpr uint8_t REG_ENGINE_MODE = 0x05; + static constexpr uint8_t REG_UPDATE = 0x0F; + static constexpr uint8_t REG_LED0_DC = 0x14; + static constexpr uint8_t REG_LED0_PWM = 0x18; + + uint8_t _brightness = 153; // 60% + + void writeReg(uint8_t reg, uint8_t value) { + Wire.beginTransmission(LP5814_I2C_ADDR); + Wire.write(reg); + Wire.write(value); + Wire.endTransmission(); + } + +public: + bool init(uint8_t brightness) override { + Wire.beginTransmission(LP5814_I2C_ADDR); + if (Wire.endTransmission() != 0) { + return false; // LP5814 not found + } + + writeReg(REG_DEVICE_CONFIG0, 0x01); // chip enable + writeReg(REG_MAX_CURRENT, 0x01); // 51 mA max current + writeReg(REG_ENABLE_CONTROL, 0x00); // outputs off while configuring + writeReg(REG_DIM_MODE, 0x4E); + writeReg(REG_ENGINE_MODE, 0xF0); + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_DC + i, 200); + } + writeReg(REG_ENABLE_CONTROL, 0x0F); // enable all 4 channels + writeReg(REG_UPDATE, 0x55); // latch (LP5814 requires 0x55) + delay(5); + + setBrightness(brightness); + return true; + } + + void setBrightness(uint8_t brightness) override { + for (uint8_t i = 0; i < 4; i++) { + writeReg(REG_LED0_PWM + i, brightness); + } + _brightness = brightness; + } + + uint8_t getBrightness() const { return _brightness; } + + virtual ~WioTrackerL2Backlight() = default; +}; + +class LGFX_WioTrackerL2 : public lgfx::LGFX_Device { + lgfx::Panel_NV3031B _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Touch_GT911 _touch_instance; + WioTrackerL2Backlight _light_instance; + +public: + bool init_impl(bool use_reset, bool use_clear) override { + // bring up backlight controller while the I2C bus is still clean + _light_instance.init(_light_instance.getBrightness()); + + bool result = LGFX_Device::init_impl(use_reset, use_clear); + + // GT911 probe can leave the ESP32 I2C peripheral with a stuck BUSY flag; + // cycling Wire resets it so later LP5814/sensor traffic doesn't time out + Wire.end(); + Wire.begin(47, 48); + + return result; + } + + LGFX_WioTrackerL2(void) { + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 3; + cfg.freq_write = L2_SPI_FREQUENCY; + cfg.freq_read = 16000000; + cfg.pin_sclk = 42; + // quad SPI data pins + cfg.pin_io0 = 41; + cfg.pin_io1 = 40; + cfg.pin_io2 = 39; + cfg.pin_io3 = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 46; + cfg.pin_rst = -1; // reset is on the IO expander, done in board init + cfg.pin_busy = -1; + cfg.panel_width = 240; // native portrait orientation + cfg.panel_height = 320; + cfg.memory_width = 240; + cfg.memory_height = 320; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; // panel mounted landscape (320x240) + cfg.invert = true; + cfg.rgb_order = true; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + { + auto cfg = _touch_instance.config(); + cfg.pin_cs = -1; + cfg.x_min = 0; + cfg.x_max = 239; + cfg.y_min = 0; + cfg.y_max = 319; + cfg.pin_int = -1; // INT is on the IO expander + cfg.offset_rotation = 2; + cfg.i2c_port = 0; + cfg.i2c_addr = 0x5D; + cfg.pin_sda = 47; + cfg.pin_scl = 48; + cfg.bus_shared = false; + cfg.freq = 400000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + + _panel_instance.setLight(&_light_instance); + setPanel(&_panel_instance); + } +}; + +class WioTrackerL2Display : public LGFXDisplay { + LGFX_WioTrackerL2 disp; +public: + WioTrackerL2Display() : LGFXDisplay(320, 240, disp) {} + + // direct access to the LGFX device (LVGL flush/touch glue) + lgfx::LGFX_Device* lgfxDevice() { return &disp; } + + // reliable touch read in logical (UI_ZOOM-scaled) coords; returns false when + // not touched (LGFXDisplay::getTouch reads an uninitialized point on release) + bool readTouch(int& x, int& y) { + lgfx::touch_point_t tp; + if (disp.getTouch(&tp, 1) == 0) return false; + x = tp.x / UI_ZOOM; + y = tp.y / UI_ZOOM; + return true; + } + + // shadows LGFXDisplay::begin() (non-virtual, called on the concrete type in + // main.cpp) - identical except rotation: our panel config already carries + // offset_rotation=1 for the landscape mounting, so no extra rotation here + bool begin() { + // dark theme: deep navy ground, MeshCore blue accents (RGB565) + UIColor::window_bkg = 0x0885; // dark navy + UIColor::title_bkg = 0x1299; // meshcore blue + UIColor::title_txt = 0xFFFF; + UIColor::primary_txt = 0xE73C; // near-white + UIColor::secondary_txt = 0x8D59; // muted blue-gray + UIColor::warning_txt = 0xFD20; // orange + UIColor::popup_bkg = 0x1299; + UIColor::popup_txt = 0xFFFF; + UIColor::corp_blue = 0x5DBF; // sky blue (own messages, icons) + + turnOn(); + display->init(); + display->setRotation(0); + display->setColorDepth(8); + // The zoomed sprite blit stops a pixel short of the panel edge, so those + // pixels keep whatever the panel powered up with (they showed as coloured + // lines down the right side and along the bottom). Clear the panel once. + display->fillScreen(UIColor::window_bkg); + display->setBrightness(153); + display->setTextColor(TFT_WHITE); + + buffer.setColorDepth(8); + buffer.setPsram(true); + buffer.createSprite(width(), height()); + + return true; + } +}; diff --git a/variants/wio-tracker-l2/nv3031b_odr_fix.cpp b/variants/wio-tracker-l2/nv3031b_odr_fix.cpp new file mode 100644 index 0000000000..ca4033ea76 --- /dev/null +++ b/variants/wio-tracker-l2/nv3031b_odr_fix.cpp @@ -0,0 +1,15 @@ +// LovyanGFX's Panel_NV3031B declares `init_cmds` as an in-class +// `static constexpr` array. Under C++17 that is implicitly inline, but the +// Arduino ESP32 core builds with gnu++14 where an ODR-used static constexpr +// member still needs an out-of-class definition - without this the link +// fails with "undefined reference to lgfx::v1::Panel_NV3031B::init_cmds". +#include + +#if __cplusplus < 201703L +namespace lgfx { +inline namespace v1 { +constexpr uint8_t Panel_NV3031B::init_cmds[]; +constexpr uint8_t Panel_NV3031B::CMD_INIT_DELAY; +} +} +#endif diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini new file mode 100644 index 0000000000..0182574e90 --- /dev/null +++ b/variants/wio-tracker-l2/platformio.ini @@ -0,0 +1,187 @@ +; Seeed Wio Tracker L2 Pro (alpha) - Wio-S3 module: ESP32-S3R8 + SX1262 +; Pin map derived from Seeed's Meshtastic support branch (meshtastic/firmware +; PR #10909 + device-ui `wio-l2` branch). +[Wio_Tracker_L2] +extends = esp32_base +board = seeed_wio_tracker_l2 +build_flags = ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/wio-tracker-l2 + -D SEEED_WIO_TRACKER_L2 + -D PIN_BOARD_SDA=47 + -D PIN_BOARD_SCL=48 + -D PIN_USER_BTN=0 + -D USE_SX1262 + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D P_LORA_NSS=21 + -D P_LORA_RESET=7 + -D P_LORA_BUSY=8 + -D P_LORA_DIO_1=9 + -D P_LORA_SCLK=4 + -D P_LORA_MISO=5 + -D P_LORA_MOSI=6 + -D SX126X_DIO2_AS_RF_SWITCH=true + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D ENV_INCLUDE_GPS=1 + -D PIN_GPS_TX=18 ; L76K TX -> ESP32 RX + -D PIN_GPS_RX=17 ; L76K RX <- ESP32 TX + -D GPS_BAUD_RATE=9600 + -D ENV_SKIP_GPS_DETECT=1 ; GPS is always present, powered via IO expander + -D DISPLAY_CLASS=WioTrackerL2Display + -D UI_ZOOM=2 + ; disable ALL env sensor I2C probes: this bus carries six onboard chips + ; (TCA9535 0x21, AW35615 0x22, LP5814 0x2C, ADS1115 0x48, GT911 0x5D, + ; ES8311 0x18) and stray probe matches can hang sensors.begin() + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 + -D ENV_INCLUDE_LPS22HB=0 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/wio-tracker-l2> + + + + + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lovyan03/LovyanGFX @ ^1.2.25 + https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip + +; -------- Companion: BLE phone app + standalone touch UI -------------------- +[env:Wio_Tracker_L2_companion_radio_ble] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 ; LCD, no burn-in worry; 1 min screen timeout +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Companion: USB serial link + standalone touch UI ------------------ +[env:Wio_Tracker_L2_companion_radio_usb] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D ENABLE_USB_INTERFACE + -D HAS_TOUCH=1 + -D AUTO_OFF_MILLIS=60000 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + +; -------- Companion: BLE + full LVGL touch UI (next-gen interface) ---------- +[Wio_Tracker_L2_lvgl] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-lvgl + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D AUTO_OFF_MILLIS=60000 + -D LV_CONF_SKIP=1 + -D LV_COLOR_DEPTH=16 + -D LV_FONT_MONTSERRAT_12=1 + -D LV_FONT_MONTSERRAT_14=1 + -D LV_FONT_MONTSERRAT_16=1 + -D LV_FONT_MONTSERRAT_28=1 ; splash title + ; emoji font support (default font is set via the theme in UITask.cpp) + -D LV_LVGL_H_INCLUDE_SIMPLE + -D UI_LVGL=1 ; suppress main.cpp 'Loading...' banner - LVGL splash instead + -D CHAT_STORE_SIZE=120 ; message history depth (persisted to SPIFFS) + ; map tiles: PNG decode + POSIX FS (ESP32 VFS -> /sdcard) + decoded-image cache + -D LV_USE_LODEPNG=1 + -D LV_USE_FS_POSIX=1 + -D LV_FS_POSIX_LETTER=65 + -D LV_FS_POSIX_PATH='"/sdcard"' + -D LV_CACHE_DEF_SIZE=2097152 + ; all LVGL memory in one PSRAM pool: internal RAM stays free for BT/SD bursts + -D LV_USE_STDLIB_MALLOC=LV_STDLIB_BUILTIN + -D LV_MEM_SIZE=3670016 + -D LV_MEM_ADR=0 + -D LV_MEM_POOL_INCLUDE='"lv_psram_pool.h"' + -D LV_MEM_POOL_ALLOC=lv_psram_pool_alloc +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-lvgl/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + lvgl/lvgl @ 9.2.2 ; pinned: UI is validated against this exact release + +; Standalone: full touch UI, no Bluetooth (USB CLI only). The BLE companion +; builds use the standard display UI instead - with a phone attached, the +; phone is the interface. STANDALONE_NO_BT compiles out the BT settings rows. +[env:Wio_Tracker_L2_standalone_lvgl] +extends = Wio_Tracker_L2_lvgl +build_flags = + ${Wio_Tracker_L2_lvgl.build_flags} + -D ENABLE_USB_INTERFACE + -D STANDALONE_NO_BT=1 +build_src_filter = ${Wio_Tracker_L2_lvgl.build_src_filter} + - + +; -------- Standalone repeater: extends mesh coverage, no phone needed ------- +[env:Wio_Tracker_L2_repeater] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Repeater"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_repeater/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} + +; -------- Standalone room server: shared message board / BBS ---------------- +[env:Wio_Tracker_L2_room_server] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -D ADVERT_NAME='"WioL2 Room"' + -D ADVERT_LAT=0.0 + -D ADVERT_LON=0.0 + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + +<../examples/simple_room_server> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/wio-tracker-l2/target.cpp b/variants/wio-tracker-l2/target.cpp new file mode 100644 index 0000000000..e865464b31 --- /dev/null +++ b/variants/wio-tracker-l2/target.cpp @@ -0,0 +1,35 @@ +#include +#include "target.h" + +WioTrackerL2Board board; + +static SPIClass spi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, spi); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); +GpsTapStream gps_tap(Serial1); // GSV sats-in-view / SNR watcher +MicroNMEALocationProvider gps(gps_tap, &rtc_clock); +EnvironmentSensorManager sensors(gps); + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true); +#endif + +bool radio_init() { + MESH_DEBUG_PRINTLN("radio_init: rtc + sx1262 init"); + fallback_clock.begin(); + rtc_clock.begin(Wire); // Wire already running on 47/48 from board.begin() + + bool ok = radio.std_init(&spi); + MESH_DEBUG_PRINTLN(ok ? "radio_init: SX1262 OK" : "radio_init: SX1262 FAILED"); + return ok; +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); // create new random identity +} diff --git a/variants/wio-tracker-l2/target.h b/variants/wio-tracker-l2/target.h new file mode 100644 index 0000000000..11a66e4ec1 --- /dev/null +++ b/variants/wio-tracker-l2/target.h @@ -0,0 +1,30 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include "WioTrackerL2Board.h" +#include +#include +#include +#include +#include "GpsTap.h" +#ifdef DISPLAY_CLASS + #include "WioTrackerL2Display.h" + #include +#endif + +extern WioTrackerL2Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern EnvironmentSensorManager sensors; +extern GpsTapStream gps_tap; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +bool radio_init(); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/wio-tracker-l2/tools/tile_darkener.py b/variants/wio-tracker-l2/tools/tile_darkener.py new file mode 100644 index 0000000000..059dcef088 --- /dev/null +++ b/variants/wio-tracker-l2/tools/tile_darkener.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Builds a dark tile set from the packed day tiles. + +Luminance inversion with preserved hue: invert Y in YCbCr, gently compress +toward dark so labels come out light on a near-black ground and water/parks +keep their (darkened) colors. Reads maps/{z}/{x}.pak, writes the same format +to packs_dark/, palette-quantized so the dark set stays close to the day +set's size. Resumable: existing outputs are skipped. +""" +import io +import os +import struct +import sys +from multiprocessing import Pool + +from PIL import Image + +SRC = "tiles_stage/packs" +DST = "tiles_stage/packs_dark" + +def darken_png(data: bytes) -> bytes: + img = Image.open(io.BytesIO(data)).convert("RGB").convert("YCbCr") + y, cb, cr = img.split() + y = y.point(lambda v: 16 + (255 - v) * 220 // 255) # invert, lift blacks + out = Image.merge("YCbCr", (y, cb, cr)).convert("RGB").quantize(colors=192) + buf = io.BytesIO() + out.save(buf, format="PNG", optimize=False) + return buf.getvalue() + +def process_pak(job) -> int: + z, name = job + src = os.path.join(SRC, z, name) + dst = os.path.join(DST, z, name) + if os.path.exists(dst): + return 0 + with open(src, "rb") as f: + raw = f.read() + if raw[:4] != b"TPK1": + return 0 + y0, y1 = struct.unpack_from(" None: + jobs = [] + for z in sorted(os.listdir(SRC), key=lambda s: int(s) if s.isdigit() else 99): + zdir = os.path.join(SRC, z) + if not os.path.isdir(zdir): + continue + for name in os.listdir(zdir): + if name.endswith(".pak"): + jobs.append((z, name)) + print(f"PLAN: {len(jobs)} paks to darken", flush=True) + done = tiles = 0 + with Pool(processes=8) as pool: + for cnt in pool.imap_unordered(process_pak, jobs, chunksize=4): + done += 1 + tiles += cnt + if done % 250 == 0: + print(f"MILESTONE: {done}/{len(jobs)} paks, {tiles} tiles darkened", flush=True) + print(f"DONE: {done} paks, {tiles} tiles darkened", flush=True) + +if __name__ == "__main__": + main() diff --git a/variants/wio-tracker-l2/tools/tile_downloader.py b/variants/wio-tracker-l2/tools/tile_downloader.py new file mode 100644 index 0000000000..1799aed2da --- /dev/null +++ b/variants/wio-tracker-l2/tools/tile_downloader.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Offline map tile downloader for the Wio Tracker L2 Pro SD card. + +Downloads OSM raster tiles into /maps/{z}/{x}/{y}.png - the layout +Meshtastic MUI reads and the planned MeshCore map screen will share. + +Polite by design: single-threaded, ~2 req/s, resumable (skips existing +files), retries with backoff. Re-run any time to fill gaps or add areas. +""" + +import math +import os +import sys +import time +import threading +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +DEST = sys.argv[1] if len(sys.argv) > 1 else "/Volumes/L2MAPS" +# defaults are polite for public OSM; for a local render server use e.g. +# TILE_URL="http://localhost:8080/tile/{z}/{x}/{y}.png" TILE_WORKERS=8 TILE_DELAY=0 +TILE_URL = os.environ.get("TILE_URL", "https://tile.openstreetmap.org/{z}/{x}/{y}.png") +USER_AGENT = "L2Pro-offline-map-prep/1.0 (personal one-time use, throttled)" +WORKERS = int(os.environ.get("TILE_WORKERS", "2")) +DELAY_SECS = float(os.environ.get("TILE_DELAY", "0.2")) +MILESTONE_EVERY = 2500 + +# (name, lon_min, lat_min, lon_max, lat_max, z_min, z_max) +AREAS = [ + ("fargo_moorhead", -97.00, 46.70, -96.60, 47.00, 10, 17), + ("bismarck_mandan", -100.95, 46.70, -100.60, 46.90, 10, 17), + ("i94_corridor", -100.95, 46.55, -96.60, 47.05, 8, 12), + ("nd_region", -104.10, 45.80, -96.00, 49.10, 5, 9), + # --- expansion pack --- + ("grand_forks", -97.15, 47.85, -96.95, 48.00, 10, 17), + ("i29_corridor", -97.30, 45.93, -96.60, 49.00, 8, 12), + ("minot", -101.40, 48.18, -101.20, 48.30, 10, 15), + ("jamestown", -98.78, 46.85, -98.62, 46.95, 10, 15), + ("valley_city", -98.05, 46.88, -97.95, 46.96, 10, 15), + ("devils_lake", -98.92, 48.08, -98.80, 48.16, 10, 15), + ("wahpeton", -96.65, 46.23, -96.55, 46.31, 10, 15), + ("dickinson", -102.85, 46.83, -102.72, 46.92, 10, 15), + ("williston", -103.70, 48.11, -103.55, 48.21, 10, 15), + ("mn_lakes", -96.00, 46.20, -94.00, 47.10, 9, 13), + ("msp_metro", -93.55, 44.70, -92.90, 45.25, 10, 16), + ("nd_blanket", -104.10, 45.80, -95.20, 49.10, 10, 13), + # widened Fargo-Moorhead metro at EVERY zoom the map UI offers + ("fargo_metro_wide", -97.10, 46.65, -96.50, 47.05, 5, 17), + # statewide street level: both states finish z5-15 before any z16 starts, + # so a full card degrades gracefully (z16 is ~75% of the tile count) + ("nd_full", -104.10, 45.80, -96.50, 49.10, 5, 15), + ("mn_full", -97.30, 43.45, -89.45, 49.40, 5, 15), + ("nd_full_z16", -104.10, 45.80, -96.50, 49.10, 16, 16), + ("mn_full_z16", -97.30, 43.45, -89.45, 49.40, 16, 16), +] + +MIN_FREE_BYTES = 2 * 1024 ** 3 # stop writing when the card has < 2GB left + + +def tile_range(lon_min, lat_min, lon_max, lat_max, z): + def to_tile(lon, lat): + n = 2 ** z + x = int((lon + 180.0) / 360.0 * n) + lat_r = math.radians(lat) + y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n) + return max(0, min(n - 1, x)), max(0, min(n - 1, y)) + + x0, y1 = to_tile(lon_min, lat_min) # note: y grows southward + x1, y0 = to_tile(lon_max, lat_max) + return range(x0, x1 + 1), range(y0, y1 + 1) + + +def iter_tiles(): + seen = set() + for name, lon_min, lat_min, lon_max, lat_max, z_min, z_max in AREAS: + for z in range(z_min, z_max + 1): + xs, ys = tile_range(lon_min, lat_min, lon_max, lat_max, z) + for x in xs: + for y in ys: + key = (z, x, y) + if key not in seen: + seen.add(key) + yield key + + +def fetch(url): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read() + + +counts_lock = threading.Lock() +done = skipped = failed = processed = 0 +total = 0 + + +def bump(kind): + global done, skipped, failed, processed + with counts_lock: + if kind == "done": done += 1 + elif kind == "skip": skipped += 1 + else: failed += 1 + processed += 1 + if processed % MILESTONE_EVERY == 0: + pct = processed * 100 // total + print(f"MILESTONE: {processed}/{total} ({pct}%) done={done} skipped={skipped} failed={failed}", flush=True) + + +card_full = False + + +def card_has_room(): + global card_full + if card_full: + return False + st = os.statvfs(DEST) + if st.f_bavail * st.f_frsize < MIN_FREE_BYTES: + card_full = True + print("CARD FULL: below free-space floor, skipping remaining downloads", flush=True) + return False + return True + + +def handle(tile): + z, x, y = tile + path = os.path.join(DEST, "maps", str(z), str(x), f"{y}.png") + if os.path.exists(path) and os.path.getsize(path) > 0: + bump("skip") + return + if not card_has_room(): + bump("fail") + return + os.makedirs(os.path.dirname(path), exist_ok=True) + for attempt in range(4): + try: + data = fetch(TILE_URL.format(z=z, x=x, y=y)) + with open(path, "wb") as f: + f.write(data) + bump("done") + time.sleep(DELAY_SECS) + return + except Exception as e: + wait = 2 ** attempt * 5 + print(f"RETRY z{z}/{x}/{y} attempt {attempt + 1}: {e} (wait {wait}s)", flush=True) + time.sleep(wait) + print(f"ERROR: gave up on z{z}/{x}/{y}", flush=True) + bump("fail") + + +def main(): + global total + tiles = list(iter_tiles()) + total = len(tiles) + print(f"PLAN: {total} unique tiles -> {DEST}/maps ({WORKERS} workers)", flush=True) + + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + list(pool.map(handle, tiles)) + + print(f"DONE: total={total} downloaded={done} skipped={skipped} failed={failed}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/variants/wio-tracker-l2/tools/tile_packer.py b/variants/wio-tracker-l2/tools/tile_packer.py new file mode 100644 index 0000000000..913287c4a4 --- /dev/null +++ b/variants/wio-tracker-l2/tools/tile_packer.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Packs loose z/x/y.png tiles into per-column pak files: maps/{z}/{x}.pak + +Format 'TPK1' (all uint32 little-endian): + magic 'TPK1' | y_min | y_max | offsets[n+1] (n = y_max - y_min + 1) +Tile i's PNG bytes live at [offsets[i], offsets[i+1]); equal offsets = no tile. +Offsets are absolute file positions. 4.5M tiny files become ~30k paks, so a +FAT SD card copies at raw bandwidth instead of file-creation rate. +""" +import os +import struct +import sys +from concurrent.futures import ThreadPoolExecutor + +SRC = sys.argv[1] if len(sys.argv) > 1 else "tiles_stage/maps" +DST = sys.argv[2] if len(sys.argv) > 2 else "tiles_stage/packs" + +def pack_column(z: str, x: str) -> int: + xdir = os.path.join(SRC, z, x) + ys = sorted(int(n[:-4]) for n in os.listdir(xdir) if n.endswith(".png")) + if not ys: + return 0 + y0, y1 = ys[0], ys[-1] + n = y1 - y0 + 1 + out = os.path.join(DST, z, f"{x}.pak") + tmp = out + ".tmp" + os.makedirs(os.path.dirname(out), exist_ok=True) + header_size = 12 + 4 * (n + 1) + offsets = [header_size] + blobs = [] + have = set(ys) + for y in range(y0, y1 + 1): + if y in have: + with open(os.path.join(xdir, f"{y}.png"), "rb") as f: + b = f.read() + blobs.append(b) + offsets.append(offsets[-1] + len(b)) + else: + blobs.append(b"") + offsets.append(offsets[-1]) + with open(tmp, "wb") as f: + f.write(b"TPK1" + struct.pack(" None: + jobs = [] + for z in sorted(os.listdir(SRC), key=lambda s: int(s) if s.isdigit() else 99): + zdir = os.path.join(SRC, z) + if not os.path.isdir(zdir): + continue + for x in os.listdir(zdir): + if os.path.isdir(os.path.join(zdir, x)): + # skip columns already packed with a plausible size + out = os.path.join(DST, z, f"{x}.pak") + if not os.path.exists(out): + jobs.append((z, x)) + print(f"PLAN: {len(jobs)} columns to pack", flush=True) + done = tiles = 0 + with ThreadPoolExecutor(max_workers=8) as pool: + for cnt in pool.map(lambda j: pack_column(*j), jobs): + done += 1 + tiles += cnt + if done % 2000 == 0: + print(f"MILESTONE: {done}/{len(jobs)} columns, {tiles} tiles packed", flush=True) + print(f"DONE: {done} columns, {tiles} tiles packed", flush=True) + +if __name__ == "__main__": + main()