Files

988 lines
30 KiB
C++

#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <MeshCore.h>
#include <Packet.h>
// Forward declarations
class UITask;
class MyMesh;
extern MyMesh the_mesh;
// ---------------------------------------------------------------------------
// TraceScreen
// ---------------------------------------------------------------------------
// Standalone trace path tool for the T-Deck Pro. The user builds a repeater
// chain from the contacts list or by typing comma-separated hash values, sends
// a PAYLOAD_TYPE_TRACE packet direct-routed through the chain, and views
// per-hop SNR results.
//
// Path size (1-byte or 2-byte hashes) follows the device's path_hash_mode
// setting but can be toggled on this screen.
//
// The trace packet is created via Mesh::createTrace() and sent via
// Mesh::sendDirect(). Each repeater in the chain checks if its pub_key
// prefix matches the next hash in the payload; if so, it appends its receive
// SNR*4 to the packet's path field and forwards. When the packet reaches
// the end of its given path, onTraceRecv() fires on the receiving node.
//
// For round-trip traces the user should build a symmetric path
// (e.g. A,B,C,B,A) and must be able to hear the last repeater directly.
// ---------------------------------------------------------------------------
#define TRACE_MAX_HOPS 16
#define TRACE_TIMEOUT_MS 30000 // 30 second timeout
#define TRACE_EDIT_BUF 80 // Max chars for typed path
class TraceScreen : public UIScreen {
public:
enum ScreenState {
STATE_BUILD, // Building the path
STATE_PICK_HOP, // Picking a repeater from contacts
STATE_RUNNING, // Trace sent, waiting for response
STATE_RESULTS // Showing results
};
// Trace result data (filled by onTraceResult callback)
struct TraceResult {
uint8_t hashes[TRACE_MAX_HOPS * 2]; // Hash bytes (1 or 2 per hop)
int8_t snrs[TRACE_MAX_HOPS]; // SNR*4 per hop
int8_t final_snr; // SNR of the response arriving back
int hopCount; // Number of hops that responded
int totalHops; // Total hops in the path
uint32_t duration_ms; // Round-trip time
bool valid;
};
private:
UITask* _task;
mesh::RTCClock* _rtc;
ScreenState _state;
// Path being built
uint8_t _pathBuf[TRACE_MAX_HOPS * 2]; // Hash bytes (max 2 bytes per hop)
int _hopCount;
int _bytesPerHop; // 1 or 2
// Menu navigation (STATE_BUILD)
int _menuSel;
// Inline text editor (for Type Path)
bool _editing;
char _editBuf[TRACE_EDIT_BUF];
int _editPos;
// Repeater picker (STATE_PICK_HOP)
static const int MAX_REPEATERS = 200;
uint16_t* _repIdx; // Indices into contact table (PSRAM)
int _repCount;
int _repSel;
int _repScroll;
// Trace state (STATE_RUNNING / STATE_RESULTS)
uint32_t _traceTag;
uint32_t _traceAuth;
unsigned long _traceSentAt;
TraceResult _result;
// Results scroll
int _resultScroll;
bool _wantExit;
// --- Menu helpers (STATE_BUILD) ---
// Menu layout:
// 0: Mode selector (1-byte / 2-byte)
// 1: Type Path (inline text editor)
// 2..hopCount+1: each hop
// next: + Add repeater (if < TRACE_MAX_HOPS)
// next: Remove last (if hopCount > 0)
// next: Run Trace (if hopCount > 0)
// last: Exit
enum MenuItem {
MENU_PATH_SIZE = 0,
MENU_TYPE_PATH = 1,
MENU_HOP_BASE = 2,
MENU_ADD_HOP = 200,
MENU_REMOVE_LAST,
MENU_RUN_TRACE,
MENU_EXIT
};
int buildMenuCount() const {
int count = 2; // Mode + Type Path
count += _hopCount;
if (_hopCount < TRACE_MAX_HOPS) count++; // Add hop
if (_hopCount > 0) count++; // Remove last
if (_hopCount > 0) count++; // Run Trace
count++; // Exit
return count;
}
MenuItem menuItemAt(int idx) const {
if (idx == 0) return MENU_PATH_SIZE;
if (idx == 1) return MENU_TYPE_PATH;
int pos = 2;
for (int h = 0; h < _hopCount; h++) {
if (idx == pos) return (MenuItem)(MENU_HOP_BASE + h);
pos++;
}
if (_hopCount < TRACE_MAX_HOPS) {
if (idx == pos) return MENU_ADD_HOP;
pos++;
}
if (_hopCount > 0) {
if (idx == pos) return MENU_REMOVE_LAST;
pos++;
}
if (_hopCount > 0) {
if (idx == pos) return MENU_RUN_TRACE;
pos++;
}
return MENU_EXIT;
}
// Build repeater list from contacts
void buildRepeaterList() {
_repCount = 0;
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
for (uint32_t i = 0; i < numContacts && _repCount < MAX_REPEATERS; i++) {
if (the_mesh.getContactByIdx(i, c)) {
if (c.type == ADV_TYPE_REPEATER) {
_repIdx[_repCount++] = (uint16_t)i;
}
}
}
}
// Look up contact name from hash prefix
bool findNameForHash(const uint8_t* hash, int hashLen, char* name, size_t nameLen) const {
uint32_t numContacts = the_mesh.getNumContacts();
ContactInfo c;
// First pass: repeaters only
for (uint32_t i = 0; i < numContacts; i++) {
if (the_mesh.getContactByIdx(i, c) && c.type == ADV_TYPE_REPEATER) {
if (memcmp(c.id.pub_key, hash, hashLen) == 0) {
strncpy(name, c.name, nameLen);
name[nameLen - 1] = '\0';
return true;
}
}
}
// Second pass: any contact
for (uint32_t i = 0; i < numContacts; i++) {
if (the_mesh.getContactByIdx(i, c)) {
if (memcmp(c.id.pub_key, hash, hashLen) == 0) {
strncpy(name, c.name, nameLen);
name[nameLen - 1] = '\0';
return true;
}
}
}
return false;
}
// Parse comma-separated decimal values from edit buffer into path
// Returns number of hops parsed, or -1 on error
int parseTypedPath() {
if (_editBuf[0] == '\0') return 0;
uint8_t tmpPath[TRACE_MAX_HOPS * 2];
int hops = 0;
const char* p = _editBuf;
while (*p && hops < TRACE_MAX_HOPS) {
// Skip whitespace/commas
while (*p == ',' || *p == ' ') p++;
if (*p == '\0') break;
// Parse hex number (companion app uses hex hash values)
char* end;
long val = strtol(p, &end, 16);
if (end == p) return -1; // No digits found
p = end;
if (_bytesPerHop == 1) {
if (val < 0 || val > 255) return -1;
tmpPath[hops] = (uint8_t)val;
} else {
if (val < 0 || val > 65535) return -1;
// Big-endian storage: hash display = (pub_key[0] << 8) | pub_key[1]
// So val >> 8 is pub_key[0], val & 0xFF is pub_key[1]
tmpPath[hops * 2] = (uint8_t)((val >> 8) & 0xFF);
tmpPath[hops * 2 + 1] = (uint8_t)(val & 0xFF);
}
hops++;
}
if (hops > 0) {
memcpy(_pathBuf, tmpPath, hops * _bytesPerHop);
_hopCount = hops;
}
return hops;
}
// Build display string from current path (for showing in edit field)
void pathToEditBuf() {
_editBuf[0] = '\0';
_editPos = 0;
for (int i = 0; i < _hopCount; i++) {
char tmp[8];
if (_bytesPerHop == 1) {
snprintf(tmp, sizeof(tmp), "%02X", _pathBuf[i]);
} else {
uint16_t val = ((uint16_t)_pathBuf[i * 2] << 8) | _pathBuf[i * 2 + 1];
snprintf(tmp, sizeof(tmp), "%04X", val);
}
if (i > 0) {
if (_editPos < TRACE_EDIT_BUF - 1) _editBuf[_editPos++] = ',';
}
int tlen = strlen(tmp);
if (_editPos + tlen < TRACE_EDIT_BUF - 1) {
memcpy(&_editBuf[_editPos], tmp, tlen);
_editPos += tlen;
}
}
_editBuf[_editPos] = '\0';
}
// Truncate long names to maxLen chars + "..." for display
static void truncateName(char* name, int maxLen = 10) {
if ((int)strlen(name) > maxLen) {
name[maxLen] = '\0';
// Remove trailing space before ellipsis
while (maxLen > 0 && name[maxLen - 1] == ' ') {
name[--maxLen] = '\0';
}
strcat(name, "...");
}
}
// Draw signal bars (3 bars) based on SNR
void drawSignalBars(DisplayDriver& display, int x, int y, int8_t snr4) {
float snr = snr4 / 4.0f;
// 3 bars: low >= -5, mid >= 3, high >= 8
int bars = 0;
if (snr >= -5.0f) bars = 1;
if (snr >= 3.0f) bars = 2;
if (snr >= 8.0f) bars = 3;
int barW = 3;
int gap = 1;
int heights[] = { 4, 7, 10 };
for (int b = 0; b < 3; b++) {
int bx = x + b * (barW + gap);
int by = y + 10 - heights[b];
if (b < bars) {
display.setColor(DisplayDriver::GREEN);
} else {
display.setColor(DisplayDriver::DARK);
}
display.fillRect(bx, by, barW, heights[b]);
}
}
public:
TraceScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _state(STATE_BUILD),
_hopCount(0), _bytesPerHop(2),
_menuSel(0), _editing(false), _editPos(0),
_repCount(0), _repSel(0), _repScroll(0),
_traceTag(0), _traceAuth(0), _traceSentAt(0),
_resultScroll(0), _wantExit(false) {
memset(_pathBuf, 0, sizeof(_pathBuf));
memset(_editBuf, 0, sizeof(_editBuf));
memset(&_result, 0, sizeof(_result));
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_repIdx = (uint16_t*)ps_calloc(MAX_REPEATERS, sizeof(uint16_t));
#else
_repIdx = new uint16_t[MAX_REPEATERS]();
#endif
}
bool wantsExit() const { return _wantExit; }
bool isEditing() const { return _editing; }
// --- Public helpers for T5S3 long-press → virtual keyboard integration ---
// True if the highlighted menu row is the Type Path entry (STATE_BUILD only).
bool isOnTypePathRow() const {
return _state == STATE_BUILD && menuItemAt(_menuSel) == MENU_TYPE_PATH;
}
// Returns the current path formatted as a comma-separated string, suitable
// for pre-populating an external text editor (e.g. the T5S3 virtual keyboard).
// The returned pointer references an internal buffer and is valid until the
// next call to this method or to setTypedPath()/parseTypedPath().
const char* getCurrentPathAsText() {
pathToEditBuf();
return _editBuf;
}
// Apply a path typed externally (via virtual keyboard submission).
// Replaces the working path buffer with whatever parses out of `text`
// and ensures the inline editor flag is cleared so the menu redraws cleanly.
void setTypedPath(const char* text) {
if (!text) return;
strncpy(_editBuf, text, sizeof(_editBuf) - 1);
_editBuf[sizeof(_editBuf) - 1] = '\0';
parseTypedPath();
_editing = false;
}
void enter(int pathHashMode) {
_state = STATE_BUILD;
_hopCount = 0;
_menuSel = 0;
_editing = false;
_editPos = 0;
memset(_editBuf, 0, sizeof(_editBuf));
_repSel = 0;
_repScroll = 0;
_wantExit = false;
_resultScroll = 0;
memset(_pathBuf, 0, sizeof(_pathBuf));
memset(&_result, 0, sizeof(_result));
// Default to device path hash mode (clamped to 1 or 2 for trace)
_bytesPerHop = (pathHashMode >= 1) ? 2 : 1;
}
// Called by MyMesh::onTraceRecv() via UITask
void onTraceResult(uint32_t tag, uint8_t flags,
const uint8_t* path_snrs, const uint8_t* path_hashes,
uint8_t path_byte_len, int8_t final_snr) {
if (_state != STATE_RUNNING) return;
if (tag != _traceTag) return; // Not our trace
uint8_t pathSz = flags & 0x03;
int numHops = (pathSz > 0) ? (path_byte_len >> pathSz) : path_byte_len;
_result.valid = true;
_result.totalHops = numHops;
_result.final_snr = final_snr;
_result.duration_ms = millis() - _traceSentAt;
// Copy hash data
int copyBytes = path_byte_len;
if (copyBytes > (int)sizeof(_result.hashes)) copyBytes = sizeof(_result.hashes);
memcpy(_result.hashes, path_hashes, copyBytes);
// Count SNR entries (= number of hops that actually forwarded)
int snrCount = numHops;
if (snrCount > TRACE_MAX_HOPS) snrCount = TRACE_MAX_HOPS;
_result.hopCount = snrCount;
for (int i = 0; i < snrCount; i++) {
_result.snrs[i] = (int8_t)path_snrs[i];
}
_state = STATE_RESULTS;
_resultScroll = 0;
Serial.printf("[Trace] Result received: %d hops, %dms\n", numHops, _result.duration_ms);
}
// --- Render ---
int render(DisplayDriver& display) override {
// Header
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.print("Trace Path");
display.drawRect(0, 11, display.width(), 1);
if (_state == STATE_BUILD) {
return renderBuild(display);
} else if (_state == STATE_PICK_HOP) {
return renderPicker(display);
} else if (_state == STATE_RUNNING) {
return renderRunning(display);
} else {
return renderResults(display);
}
}
private:
int renderBuild(DisplayDriver& display) {
char tmp[TRACE_EDIT_BUF + 16];
int y = 14;
int lineH = 11;
int menuCount = buildMenuCount();
int maxVisible = (display.height() - y - 14) / lineH;
if (maxVisible < 1) maxVisible = 1;
// Scroll window
int scrollTop = 0;
if (_menuSel >= scrollTop + maxVisible) scrollTop = _menuSel - maxVisible + 1;
if (_menuSel < scrollTop) scrollTop = _menuSel;
for (int vi = 0; vi < maxVisible && (scrollTop + vi) < menuCount; vi++) {
int idx = scrollTop + vi;
MenuItem item = menuItemAt(idx);
char prefix = (idx == _menuSel) ? '>' : ' ';
display.setCursor(0, y);
display.setColor(DisplayDriver::LIGHT);
switch (item) {
case MENU_PATH_SIZE:
snprintf(tmp, sizeof(tmp), "%c Mode: %d-byte", prefix, _bytesPerHop);
display.print(tmp);
if (idx == _menuSel) {
const char* hint = "(A/D)";
display.setCursor(display.width() - display.getTextWidth(hint) - 4, y);
display.print(hint);
}
break;
case MENU_TYPE_PATH:
if (_editing) {
// Active text editor with cursor
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), " Path: %s_", _editBuf);
display.print(tmp);
} else if (_hopCount > 0) {
// Show current path as decimal values
char pathStr[TRACE_EDIT_BUF];
pathStr[0] = '\0';
int pos = 0;
for (int i = 0; i < _hopCount && pos < (int)sizeof(pathStr) - 8; i++) {
if (i > 0) pathStr[pos++] = ',';
if (_bytesPerHop == 1) {
pos += snprintf(&pathStr[pos], sizeof(pathStr) - pos, "%02X", _pathBuf[i]);
} else {
uint16_t val = ((uint16_t)_pathBuf[i * 2] << 8) | _pathBuf[i * 2 + 1];
pos += snprintf(&pathStr[pos], sizeof(pathStr) - pos, "%04X", val);
}
}
snprintf(tmp, sizeof(tmp), "%c Path: %s", prefix, pathStr);
display.print(tmp);
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
snprintf(tmp, sizeof(tmp), "%c Type Path: [Long press]", prefix);
#else
snprintf(tmp, sizeof(tmp), "%c Type Path: [Press Enter]", prefix);
#endif
display.print(tmp);
}
break;
case MENU_ADD_HOP:
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "%c + Add repeater...", prefix);
display.print(tmp);
break;
case MENU_REMOVE_LAST:
snprintf(tmp, sizeof(tmp), "%c - Remove last", prefix);
display.print(tmp);
break;
case MENU_RUN_TRACE:
display.setColor(DisplayDriver::YELLOW);
snprintf(tmp, sizeof(tmp), "%c Run Trace", prefix);
display.print(tmp);
break;
case MENU_EXIT:
snprintf(tmp, sizeof(tmp), "%c Exit", prefix);
display.print(tmp);
break;
default:
// Hop line
if (item >= MENU_HOP_BASE && item < MENU_HOP_BASE + TRACE_MAX_HOPS) {
int hopIdx = item - MENU_HOP_BASE;
int offset = hopIdx * _bytesPerHop;
char hopName[24];
uint16_t hashVal;
if (_bytesPerHop == 1) {
hashVal = _pathBuf[offset];
} else {
hashVal = ((uint16_t)_pathBuf[offset] << 8) | _pathBuf[offset + 1];
}
if (findNameForHash(&_pathBuf[offset], _bytesPerHop, hopName, sizeof(hopName))) {
truncateName(hopName);
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "%c%d: %s (%X)", prefix, hopIdx + 1,
hopName, hashVal);
} else {
snprintf(tmp, sizeof(tmp), "%c%d: (%X)", prefix, hopIdx + 1, hashVal);
}
display.print(tmp);
}
break;
}
y += lineH;
}
// Footer
int footerY = display.height() - 12;
display.setTextSize(1);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY);
if (_editing) {
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel Tap:Apply");
#else
display.print("Sh+Del:Cancel Enter:Apply");
#endif
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Exit Tap:Sel");
#else
display.print("Sh+Del:Exit W/S:Nav Ent:Sel");
#endif
}
return 5000;
}
int renderPicker(DisplayDriver& display) {
char tmp[48];
int y = 14;
int lineH = 11;
int maxVisible = (display.height() - y - 14) / lineH;
if (maxVisible < 1) maxVisible = 1;
if (_repCount == 0) {
display.setCursor(0, y);
display.setColor(DisplayDriver::RED);
display.print("No repeaters in contacts");
y += lineH;
display.setColor(DisplayDriver::LIGHT);
display.print("Press Q to go back");
} else {
// Clamp scroll
if (_repSel >= _repCount) _repSel = _repCount - 1;
if (_repSel < 0) _repSel = 0;
if (_repSel < _repScroll) _repScroll = _repSel;
if (_repSel >= _repScroll + maxVisible) _repScroll = _repSel - maxVisible + 1;
for (int vi = 0; vi < maxVisible && (_repScroll + vi) < _repCount; vi++) {
int idx = _repScroll + vi;
uint16_t contactIdx = _repIdx[idx];
ContactInfo c;
if (!the_mesh.getContactByIdx(contactIdx, c)) continue;
char prefix = (idx == _repSel) ? '>' : ' ';
display.setCursor(0, y);
// Show name + decimal hash value
char filteredName[24];
display.translateUTF8ToBlocks(filteredName, c.name, sizeof(filteredName));
truncateName(filteredName, 14); // Picker has more room
uint16_t hashVal;
if (_bytesPerHop == 1) {
hashVal = c.id.pub_key[0];
} else {
hashVal = ((uint16_t)c.id.pub_key[0] << 8) | c.id.pub_key[1];
}
snprintf(tmp, sizeof(tmp), "%c %s (%X)", prefix, filteredName, hashVal);
display.setColor((idx == _repSel) ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
display.print(tmp);
y += lineH;
}
}
// Footer
int footerY = display.height() - 12;
display.setTextSize(1);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back Tap:Add");
#else
display.print("Sh+Del:Back W/S:Scroll Ent:Add");
#endif
return 5000;
}
int renderRunning(DisplayDriver& display) {
int y = 14;
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, y);
display.print("Tracing...");
y += 14;
// Show path summary
display.setColor(DisplayDriver::LIGHT);
char tmp[48];
snprintf(tmp, sizeof(tmp), "%d hops, %d-byte mode", _hopCount, _bytesPerHop);
display.setCursor(0, y);
display.print(tmp);
y += 14;
// Elapsed time
unsigned long elapsed = millis() - _traceSentAt;
snprintf(tmp, sizeof(tmp), "Elapsed: %lu ms", elapsed);
display.setCursor(0, y);
display.print(tmp);
y += 14;
// Timeout bar
int barW = display.width() - 20;
int barH = 4;
int barX = 10;
display.setColor(DisplayDriver::DARK);
display.drawRect(barX, y, barW, barH);
int fill = (int)((unsigned long)barW * elapsed / TRACE_TIMEOUT_MS);
if (fill > barW) fill = barW;
display.setColor(DisplayDriver::GREEN);
display.fillRect(barX, y, fill, barH);
// Check timeout
if (elapsed >= TRACE_TIMEOUT_MS) {
_state = STATE_RESULTS;
_result.valid = false;
_result.duration_ms = TRACE_TIMEOUT_MS;
Serial.println("[Trace] Timeout");
}
// Footer
int footerY = display.height() - 12;
display.setTextSize(1);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
#else
display.print("Sh+Del:Cancel");
#endif
return 500; // Fast refresh for elapsed timer
}
int renderResults(DisplayDriver& display) {
char tmp[48];
int y = 14;
int lineH = 12;
if (!_result.valid) {
display.setColor(DisplayDriver::RED);
display.setCursor(0, y);
display.print("Trace timed out");
y += lineH;
display.setColor(DisplayDriver::LIGHT);
snprintf(tmp, sizeof(tmp), "No response after %ds", TRACE_TIMEOUT_MS / 1000);
display.setCursor(0, y);
display.print(tmp);
} else {
// Duration header
display.setColor(DisplayDriver::GREEN);
snprintf(tmp, sizeof(tmp), "Complete: %dms", (int)_result.duration_ms);
display.setCursor(0, y);
display.print(tmp);
y += lineH + 2;
int maxVisible = (display.height() - y - 14) / lineH;
if (maxVisible < 1) maxVisible = 1;
// Clamp scroll
int totalItems = _result.hopCount + 1; // hops + final SNR line
if (_resultScroll > totalItems - maxVisible) _resultScroll = totalItems - maxVisible;
if (_resultScroll < 0) _resultScroll = 0;
for (int vi = 0; vi < maxVisible && (_resultScroll + vi) < totalItems; vi++) {
int idx = _resultScroll + vi;
display.setCursor(0, y);
if (idx < _result.hopCount) {
// Hop entry
int offset = idx * _bytesPerHop;
char hopName[20];
bool resolved = findNameForHash(&_result.hashes[offset], _bytesPerHop,
hopName, sizeof(hopName));
if (resolved) truncateName(hopName);
float snr = _result.snrs[idx] / 4.0f;
display.setColor(resolved ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
if (resolved) {
snprintf(tmp, sizeof(tmp), "%d: %s", idx + 1, hopName);
} else {
uint16_t hashVal;
if (_bytesPerHop == 1) {
hashVal = _result.hashes[offset];
} else {
hashVal = ((uint16_t)_result.hashes[offset] << 8) | _result.hashes[offset + 1];
}
snprintf(tmp, sizeof(tmp), "%d: (%X)", idx + 1, hashVal);
}
display.print(tmp);
// SNR value on right
snprintf(tmp, sizeof(tmp), "%.1fdB", snr);
int snrW = display.getTextWidth(tmp);
int barsW = 14;
display.setCursor(display.width() - snrW - barsW - 4, y);
display.setColor(DisplayDriver::LIGHT);
display.print(tmp);
// Signal bars
drawSignalBars(display, display.width() - barsW - 1, y, _result.snrs[idx]);
} else {
// Final SNR (response arriving back at this node)
float snr = _result.final_snr / 4.0f;
display.setColor(DisplayDriver::YELLOW);
snprintf(tmp, sizeof(tmp), "Return SNR: %.1fdB", snr);
display.print(tmp);
drawSignalBars(display, display.width() - 15, y, _result.final_snr);
}
y += lineH;
}
}
// Footer
int footerY = display.height() - 12;
display.setTextSize(1);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Back Tap:New Trace");
#else
display.print("Sh+Del:Back Ent:New Trace");
#endif
return 5000;
}
public:
// --- Input handling ---
bool handleInput(char c) override {
// Text editing mode consumes all keys
if (_editing) {
return handleEditInput(c);
}
switch (_state) {
case STATE_BUILD: return handleBuildInput(c);
case STATE_PICK_HOP: return handlePickerInput(c);
case STATE_RUNNING: return handleRunningInput(c);
case STATE_RESULTS: return handleResultsInput(c);
}
return false;
}
private:
// --- Text editor for typed path ---
bool handleEditInput(char c) {
// Enter: apply typed path
if (c == '\r' || c == 13) {
int parsed = parseTypedPath();
if (parsed < 0) {
Serial.println("[Trace] Failed to parse typed path");
// Stay in edit mode -- user can fix
} else {
Serial.printf("[Trace] Parsed %d hops from typed path\n", parsed);
_editing = false;
}
return true;
}
// Shift+Del: cancel edit
if (c == KEY_CANCEL) {
_editing = false;
return true;
}
// Backspace
if (c == '\b') {
if (_editPos > 0) {
_editPos--;
_editBuf[_editPos] = '\0';
}
return true;
}
// Accept hex digits, commas, spaces
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|| c == ',' || c == ' ') {
if (_editPos < TRACE_EDIT_BUF - 1) {
_editBuf[_editPos++] = c;
_editBuf[_editPos] = '\0';
}
return true;
}
return true; // Consume all keys in edit mode
}
bool handleBuildInput(char c) {
int menuCount = buildMenuCount();
// W - up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_menuSel > 0) _menuSel--;
return true;
}
// S - down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_menuSel < menuCount - 1) _menuSel++;
return true;
}
// A/D - toggle mode on path size row
if ((c == 'a' || c == 'A' || c == 'd' || c == 'D') && menuItemAt(_menuSel) == MENU_PATH_SIZE) {
_bytesPerHop = (_bytesPerHop == 1) ? 2 : 1;
// Changing mode clears path (byte layout is different)
_hopCount = 0;
memset(_pathBuf, 0, sizeof(_pathBuf));
return true;
}
// Shift+Del - exit
if (c == KEY_CANCEL) {
_wantExit = true;
return true;
}
// Enter - select
if (c == '\r' || c == 13) {
MenuItem item = menuItemAt(_menuSel);
switch (item) {
case MENU_TYPE_PATH:
// Enter edit mode -- pre-fill with current path if any
pathToEditBuf();
_editing = true;
return true;
case MENU_ADD_HOP:
buildRepeaterList();
_repSel = 0;
_repScroll = 0;
_state = STATE_PICK_HOP;
return true;
case MENU_REMOVE_LAST:
if (_hopCount > 0) {
_hopCount--;
if (_menuSel >= buildMenuCount()) _menuSel = buildMenuCount() - 1;
}
return true;
case MENU_RUN_TRACE:
return sendTrace();
case MENU_EXIT:
_wantExit = true;
return true;
default:
break;
}
return true;
}
return false;
}
bool handlePickerInput(char c) {
// W - up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_repSel > 0) _repSel--;
return true;
}
// S - down
if (c == 's' || c == 'S' || c == 0xF1) {
if (_repSel < _repCount - 1) _repSel++;
return true;
}
// Shift+Del - back to build
if (c == KEY_CANCEL) {
_state = STATE_BUILD;
return true;
}
// Enter - add selected repeater
if (c == '\r' || c == 13) {
if (_repCount > 0 && _repSel >= 0 && _repSel < _repCount) {
ContactInfo contact;
if (the_mesh.getContactByIdx(_repIdx[_repSel], contact)) {
int offset = _hopCount * _bytesPerHop;
memcpy(&_pathBuf[offset], contact.id.pub_key, _bytesPerHop);
_hopCount++;
uint16_t hashVal = ((uint16_t)contact.id.pub_key[0] << 8)
| contact.id.pub_key[1];
Serial.printf("[Trace] Added hop %d: %s (%X)\n",
_hopCount, contact.name, hashVal);
}
_state = STATE_BUILD;
_menuSel = _hopCount + 1; // Point to row after last hop
}
return true;
}
return false;
}
bool handleRunningInput(char c) {
// Shift+Del - cancel
if (c == KEY_CANCEL) {
_state = STATE_BUILD;
return true;
}
return true; // Consume all keys while running
}
bool handleResultsInput(char c) {
// W - scroll up
if (c == 'w' || c == 'W' || c == 0xF2) {
if (_resultScroll > 0) _resultScroll--;
return true;
}
// S - scroll down
if (c == 's' || c == 'S' || c == 0xF1) {
_resultScroll++;
return true;
}
// Shift+Del - back to build screen (keep path)
if (c == KEY_CANCEL) {
_state = STATE_BUILD;
_menuSel = 0;
return true;
}
// Enter - new trace (re-run with same path)
if (c == '\r' || c == 13) {
return sendTrace();
}
return false;
}
// --- Send trace ---
bool sendTrace() {
if (_hopCount <= 0) return true;
// Generate random tag and auth code
the_mesh.getRNG()->random((uint8_t*)&_traceTag, 4);
the_mesh.getRNG()->random((uint8_t*)&_traceAuth, 4);
// flags: lower 2 bits = path_sz
// path_sz 0 = 1-byte hashes, path_sz 1 = 2-byte hashes
uint8_t pathSz = (_bytesPerHop == 2) ? 1 : 0;
uint8_t flags = pathSz;
mesh::Packet* pkt = the_mesh.createTrace(_traceTag, _traceAuth, flags);
if (!pkt) {
Serial.println("[Trace] Failed to create trace packet (pool empty)");
return true;
}
// Path bytes to send
uint8_t pathByteLen = _hopCount * _bytesPerHop;
// sendDirect for TRACE appends path to payload and sets path_len=0
the_mesh.sendDirect(pkt, _pathBuf, pathByteLen);
_traceSentAt = millis();
_state = STATE_RUNNING;
memset(&_result, 0, sizeof(_result));
Serial.printf("[Trace] Sent: tag=0x%08X, %d hops, %d-byte, %d path bytes\n",
_traceTag, _hopCount, _bytesPerHop, pathByteLen);
Serial.printf("[Trace] Path hex:");
for (int i = 0; i < pathByteLen; i++) {
Serial.printf(" %02X", _pathBuf[i]);
}
Serial.println();
return true;
}
};