From dcccc9733b0bf6d1a5cbb67c9d05f0c3755ae863 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 14:16:37 +0200 Subject: [PATCH] Bot: global keyboard in BotScreen, placeholder expansion via MsgExpand.h - Replace BotScreen's custom embedded keyboard with the shared global keyboard layout (KB_* constants): char rows at KB_CHARS_Y, special row at KB_SPECIAL_Y=48 with 5 buttons ([^][Sp][Del][{}][OK]), fixing the previous layout that rendered below the 64 px display boundary. Proportional column scaling between char rows (10 cols) and special row (5 cols). [{}] opens the placeholder picker overlay. - Add MsgExpand.h: standalone inline expandMsg() with no framework dependencies, expanding {loc} and {time} placeholders. Used by both MyMesh (bot channel/DM replies) and QuickMsgScreen, replacing the duplicate expandBotMsg() static and the inline expandMsg() method. - Bump FIRMWARE_VERSION to v1.15.3. - ToolsScreen: remove key-hint footer line. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MsgExpand.h | 42 ++++ examples/companion_radio/MyMesh.cpp | 19 +- examples/companion_radio/MyMesh.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 224 ++++++++++++--------- 4 files changed, 187 insertions(+), 100 deletions(-) create mode 100644 examples/companion_radio/MsgExpand.h diff --git a/examples/companion_radio/MsgExpand.h b/examples/companion_radio/MsgExpand.h new file mode 100644 index 00000000..ef18f28e --- /dev/null +++ b/examples/companion_radio/MsgExpand.h @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include +#include + +// Expands {loc} and {time} placeholders in tmpl into out (out_len bytes). +// lat/lon — GPS coordinates; gps_valid must be true for {loc} to show coords +// utc_ts — unix timestamp from RTC (0 or <1e9 = no valid time) +// tz_hours — local timezone offset from UTC (-12..+14) +inline void expandMsg(const char* tmpl, char* out, int out_len, + double lat, double lon, bool gps_valid, + uint32_t utc_ts, int8_t tz_hours) { + int oi = 0; + const char* p = tmpl; + while (*p && oi < out_len - 1) { + if (strncmp(p, "{loc}", 5) == 0) { + char lb[32]; + if (gps_valid) + snprintf(lb, sizeof(lb), "%.5f,%.5f", lat, lon); + else + strcpy(lb, "no GPS"); + int ll = strlen(lb); + if (oi + ll < out_len - 1) { memcpy(out + oi, lb, ll); oi += ll; } + p += 5; + } else if (strncmp(p, "{time}", 6) == 0) { + if (utc_ts > 1000000000UL) { + uint32_t local_ts = utc_ts + (int32_t)tz_hours * 3600; + time_t t = (time_t)local_ts; + struct tm* ti = gmtime(&t); + char tb[8]; + snprintf(tb, sizeof(tb), "%02d:%02d", ti->tm_hour, ti->tm_min); + int tl = strlen(tb); + if (oi + tl < out_len - 1) { memcpy(out + oi, tb, tl); oi += tl; } + } + p += 6; + } else { + out[oi++] = *p++; + } + } + out[oi] = '\0'; +} diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1147577b..6b2457d3 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include "MyMesh.h" +#include "MsgExpand.h" #include // needed for PlatformIO #include @@ -508,6 +509,7 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk sendFloodScoped(*scope, pkt, delay_millis); } + void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection @@ -533,8 +535,13 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t } if (matched) { uint32_t ts = getRTCClock()->getCurrentTime(); + char expanded[200]; + expandMsg(_prefs.bot_reply, expanded, sizeof(expanded), + sensors.node_lat, sensors.node_lon, + sensors.node_lat != 0.0 || sensors.node_lon != 0.0, + ts, _prefs.tz_offset_hours); uint32_t expected_ack, est_timeout; - if (sendMessage(from, ts, 0, _prefs.bot_reply, expected_ack, est_timeout) != MSG_SEND_FAILED) + if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) _bot_last_reply_ms = millis(); } } @@ -616,10 +623,14 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe ChannelDetails ch; if (getChannel(channel_idx, ch)) { uint32_t ts = getRTCClock()->getCurrentTime(); - int rlen = strlen(_prefs.bot_reply); - if (sendGroupMessage(ts, ch.channel, _prefs.node_name, _prefs.bot_reply, rlen)) { + char expanded[200]; + expandMsg(_prefs.bot_reply, expanded, sizeof(expanded), + sensors.node_lat, sensors.node_lon, + sensors.node_lat != 0.0 || sensors.node_lon != 0.0, + ts, _prefs.tz_offset_hours); + int rlen = strlen(expanded); + if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) _bot_last_reply_ms = millis(); - } } } } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 46651b88..c9eee993 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -12,7 +12,7 @@ #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.15.1" +#define FIRMWARE_VERSION "v1.15.3" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8bc86c23..4def736f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,7 +1,7 @@ #include "UITask.h" #include -#include #include "../MyMesh.h" +#include "../MsgExpand.h" #include "target.h" #ifdef WIFI_SSID #include @@ -853,45 +853,20 @@ class QuickMsgScreen : public UIScreen { static const int HIST_START_Y = 11; void expandMsg(const char* tmpl, char* out, int out_len) const { - int oi = 0; - const char* p = tmpl; - while (*p && oi < out_len - 1) { - if (strncmp(p, "{loc}", 5) == 0) { + double lat = 0, lon = 0; + bool gps_valid = false; #if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - if (loc && loc->isValid()) { - char lb[32]; - snprintf(lb, sizeof(lb), "%.5f,%.5f", - loc->getLatitude() / 1000000.0, loc->getLongitude() / 1000000.0); - int ll = strlen(lb); - if (oi + ll < out_len - 1) { memcpy(out + oi, lb, ll); oi += ll; } - } else { - const char* s = "no GPS"; int sl = 6; - if (oi + sl < out_len - 1) { memcpy(out + oi, s, sl); oi += sl; } - } -#else - const char* s = "no GPS"; int sl = 6; - if (oi + sl < out_len - 1) { memcpy(out + oi, s, sl); oi += sl; } -#endif - p += 5; - } else if (strncmp(p, "{time}", 6) == 0) { - uint32_t ts = rtc_clock.getCurrentTime(); - if (ts > 1000000000UL) { - NodePrefs* np = _task->getNodePrefs(); - ts += (int32_t)(np ? np->tz_offset_hours : 0) * 3600; - time_t t = (time_t)ts; - struct tm* ti = gmtime(&t); - char tb[8]; - snprintf(tb, sizeof(tb), "%02d:%02d", ti->tm_hour, ti->tm_min); - int tl = strlen(tb); - if (oi + tl < out_len - 1) { memcpy(out + oi, tb, tl); oi += tl; } - } - p += 6; - } else { - out[oi++] = *p++; - } + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + lat = loc->getLatitude() / 1000000.0; + lon = loc->getLongitude() / 1000000.0; + gps_valid = true; } - out[oi] = '\0'; +#endif + NodePrefs* np = _task->getNodePrefs(); + ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, + rtc_clock.getCurrentTime(), + np ? np->tz_offset_hours : 0); } void setupMsgPick() { @@ -2097,17 +2072,16 @@ class BotScreen : public UIScreen { // keyboard state (reused for trigger and reply fields) int _kb_field; // -1=off, 2=trigger, 3=reply - char _kb_buf[140]; + char _kb_buf[KB_MAX_LEN + 1]; int _kb_len; int _kb_maxlen; int _kb_row, _kb_col; bool _kb_caps; + bool _kb_ph_mode; + int _kb_ph_sel; int _sel; - static const char KB_CHARS[4][10]; - static const int KB_ROWS = 4, KB_COLS = 10; - // channel + DM contact caches (refreshed on enter) static const int MAX_BOT_DM = 16; @@ -2171,52 +2145,79 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_field >= 0) { - // keyboard mode + // keyboard mode — uses same layout as global keyboard const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:"; - char preview[24]; - const char* disp = _kb_buf; - int dlen = _kb_len; - if (dlen > 16) { disp = _kb_buf + (dlen - 16); dlen = 16; } - snprintf(preview, sizeof(preview), "%s %.*s_", label, dlen, disp); - display.setCursor(0, 0); + const char* disp_start = _kb_buf; + int disp_len = _kb_len; + if (disp_len > 20) { disp_start = _kb_buf + (disp_len - 20); disp_len = 20; } + char preview[28]; + snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start); + display.setCursor(0, KB_TEXT_Y); display.print(preview); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, KB_SEP_Y, display.width(), 1); - for (int row = 0; row < KB_ROWS; row++) { - int y = 14 + row * 11; - for (int col = 0; col < KB_COLS; col++) { + // char rows + for (int row = 0; row < KB_ROWS_CHAR; row++) { + int y = KB_CHARS_Y + row * KB_CELL_H; + for (int col = 0; col < KB_COLS_CHAR; col++) { bool sel = (_kb_row == row && _kb_col == col); char ch = KB_CHARS[row][col]; - if (_kb_caps && ch >= 'a' && ch <= 'z') ch -= 32; - char buf[2] = { ch == ' ' ? '_' : ch, '\0' }; - int cx = col * 12; + if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; + int cx = col * KB_CELL_W; if (sel) { - display.fillRect(cx, y - 1, 11, 10); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, y - 1, KB_CELL_W - 1, KB_CELL_H); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(cx + 3, y); - display.print(buf); - display.setColor(DisplayDriver::LIGHT); + display.print(ch_buf); } } - // special row - const char* spec[] = { "[^]", "[Sp]", "[Del]", "[OK]" }; - for (int i = 0; i < 4; i++) { - bool sel = (_kb_row == KB_ROWS && _kb_col == i); + + // special row: [^] [Sp] [Del] [{}] [OK] — 5 buttons at 25px each + const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" }; + for (int i = 0; i < KB_SPECIAL; i++) { + bool sel = (_kb_row == KB_ROWS_CHAR && _kb_col == i); bool active = (i == 0 && _kb_caps); - int sx = i * 32; + int sx = i * 25; if (sel || active) { - display.fillRect(sx, 58, 31, 9); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } - display.setCursor(sx + 2, 59); + display.setCursor(sx + 1, KB_SPECIAL_Y); display.print(spec[i]); display.setColor(DisplayDriver::LIGHT); } + + // placeholder picker overlay + if (_kb_ph_mode) { + display.setColor(DisplayDriver::DARK); + display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + display.setCursor(24, 21); + display.print("Placeholder:"); + display.fillRect(20, 30, 88, 1); + for (int i = 0; i < KB_PH_COUNT; i++) { + int py = 33 + i * 10; + if (i == _kb_ph_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(21, py - 1, 86, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(24, py); + display.print(KB_PH_LIST[i]); + } + display.setColor(DisplayDriver::LIGHT); + } return 50; } @@ -2284,33 +2285,74 @@ public: bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); if (_kb_field >= 0) { - // keyboard input - if (cancel) { _kb_field = -1; return true; } - if (up && _kb_row > 0) { _kb_row--; return true; } - if (down && _kb_row < KB_ROWS) { - _kb_row++; - if (_kb_row == KB_ROWS && _kb_col > 3) _kb_col = 3; + // placeholder picker overlay takes priority + if (_kb_ph_mode) { + if (c == KEY_UP && _kb_ph_sel > 0) { _kb_ph_sel--; return true; } + if (c == KEY_DOWN && _kb_ph_sel < KB_PH_COUNT - 1) { _kb_ph_sel++; return true; } + if (c == KEY_ENTER) { + const char* ph = KB_PH_LIST[_kb_ph_sel]; + int ph_len = strlen(ph); + if (_kb_len + ph_len <= _kb_maxlen) { + memcpy(_kb_buf + _kb_len, ph, ph_len); + _kb_len += ph_len; + _kb_buf[_kb_len] = '\0'; + } + _kb_ph_mode = false; + return true; + } + if (cancel) { _kb_ph_mode = false; return true; } + return true; + } + + if (cancel) { _kb_field = -1; return true; } + if (up) { + if (_kb_row > 0) { + _kb_row--; + if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward + _kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL; + } + return true; + } + if (down) { + if (_kb_row < KB_ROWS_CHAR) { + _kb_row++; + if (_kb_row == KB_ROWS_CHAR) // entering special row + _kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR; + } + return true; + } + if (left) { + if (_kb_col > 0) _kb_col--; + return true; + } + if (right) { + int max_col = (_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; + if (_kb_col < max_col) _kb_col++; return true; } - if (left && _kb_col > 0) { _kb_col--; return true; } - if (right && _kb_col < (_kb_row < KB_ROWS ? KB_COLS - 1 : 3)) { _kb_col++; return true; } if (enter) { - if (_kb_row < KB_ROWS) { - // character press + if (_kb_row < KB_ROWS_CHAR) { if (_kb_len < _kb_maxlen) { char ch = KB_CHARS[_kb_row][_kb_col]; - if (_kb_caps && ch >= 'a' && ch <= 'z') ch -= 32; + if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; _kb_buf[_kb_len++] = ch; _kb_buf[_kb_len] = '\0'; } } else { - // special row switch (_kb_col) { case 0: _kb_caps = !_kb_caps; break; - case 1: if (_kb_len < _kb_maxlen) { _kb_buf[_kb_len++] = ' '; _kb_buf[_kb_len] = '\0'; } break; - case 2: if (_kb_len > 0) { _kb_buf[--_kb_len] = '\0'; } break; + case 1: + if (_kb_len < _kb_maxlen) { _kb_buf[_kb_len++] = ' '; _kb_buf[_kb_len] = '\0'; } + break; + case 2: + if (_kb_len > 0) _kb_buf[--_kb_len] = '\0'; + break; case 3: - // commit + _kb_ph_mode = true; + _kb_ph_sel = 0; + break; + case 4: + // OK — commit to prefs if (_kb_field == 2) { strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1); _prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0'; @@ -2358,10 +2400,12 @@ public: } } if ((_sel == 2 || _sel == 3) && enter) { - _kb_field = _sel; - _kb_row = 0; - _kb_col = 0; - _kb_caps = false; + _kb_field = _sel; + _kb_row = 0; + _kb_col = 0; + _kb_caps = false; + _kb_ph_mode = false; + _kb_ph_sel = 0; if (_sel == 2) { strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1); _kb_maxlen = sizeof(_prefs->bot_trigger) - 1; @@ -2377,13 +2421,6 @@ public: } }; -const char BotScreen::KB_CHARS[4][10] = { - {'a','b','c','d','e','f','g','h','i','j'}, - {'k','l','m','n','o','p','q','r','s','t'}, - {'u','v','w','x','y','z','.',' ','!','?'}, - {'1','2','3','4','5','6','7','8','9','0'}, -}; - // ── ToolsScreen ─────────────────────────────────────────────────────────────── class ToolsScreen : public UIScreen { UITask* _task; @@ -2417,9 +2454,6 @@ public: display.print(ITEMS[i]); } display.setColor(DisplayDriver::LIGHT); - - display.setCursor(0, 54); - display.print("ENT:open CANCEL:back"); return 500; }