From 09a27a259177c76e62f128f10ca96108c7442a0a Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 11 May 2026 17:24:09 -0600 Subject: [PATCH 001/183] fix(mesh): widen TRACE offset to uint16 to avoid narrowing --- src/Mesh.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index 57fee140..0c96e14d 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -50,7 +50,9 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) { uint8_t path_sz = flags & 0x03; // NEW v1.11+: lower 2 bits is path hash size uint8_t len = pkt->payload_len - i; - uint8_t offset = pkt->path_len << path_sz; + // path_len*entry_size can exceed 255 (path_len up to 63, entry_size up to 8); + // a uint8_t offset would wrap and steer the isHashMatch() read to the wrong place. + uint16_t offset = (uint16_t)pkt->path_len << path_sz; if (offset >= len) { // TRACE has reached end of given path onTraceRecv(pkt, trace_tag, auth_code, flags, pkt->path, &pkt->payload[i], len); } else if (self_id.isHashMatch(&pkt->payload[i + offset], 1 << path_sz) && allowPacketForward(pkt) && !_tables->hasSeen(pkt)) { From c3846ba9f52e92633221f8eb670829a92b9c66c9 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 15:18:35 +0200 Subject: [PATCH 002/183] feat(wio-tracker-l1): add settings screen and companion radio improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings screen (UI_HAS_JOYSTICK_UPDOWN build variant): - Display brightness (5 levels, contrast curve tuned for SH1106 non-linearity) - Buzzer on/off (respected at startup, no more forced unmute) - TX power (2–22 dBm) - Auto-off delay (5s / 15s / 30s / 60s / never) - GPS update interval (off / 30s / 1min / 5min / 15min / 30min) - Timezone offset (UTC-12..+14) for clock screen - Low battery auto-shutdown threshold (off / 3.0–3.5V, default 3.4V) - Battery display mode (icon / % / voltage) Battery: - EMA filter (alpha=0.2) on ADC readings to smooth voltage spikes from uneven load - Battery % calculated using low_batt_mv as 0% anchor - Mute icon repositions dynamically based on battery display width Clock screen on HomeScreen showing time/date with timezone support. Build: - _settings variants added for USB and BLE companion radio builds - Automatic UF2 generation on every build via create-uf2.py post-script - UI_HAS_JOYSTICK_UPDOWN flag guards joystick_up/down to avoid breaking other boards Co-Authored-By: Claude Sonnet 4.6 --- create-uf2.py | 2 + examples/companion_radio/DataStore.cpp | 10 + examples/companion_radio/MyMesh.cpp | 5 + examples/companion_radio/NodePrefs.h | 5 + examples/companion_radio/ui-new/UITask.cpp | 446 ++++++++++++++++++--- examples/companion_radio/ui-new/UITask.h | 15 + src/helpers/ui/DisplayDriver.h | 1 + src/helpers/ui/SH1106Display.cpp | 9 + src/helpers/ui/SH1106Display.h | 1 + src/helpers/ui/buzzer.cpp | 1 - variants/wio-tracker-l1/platformio.ini | 22 +- variants/wio-tracker-l1/target.cpp | 4 + variants/wio-tracker-l1/target.h | 4 + 13 files changed, 474 insertions(+), 51 deletions(-) diff --git a/create-uf2.py b/create-uf2.py index 10ec0ed6..226f6f7e 100644 --- a/create-uf2.py +++ b/create-uf2.py @@ -21,6 +21,8 @@ def create_uf2_action(source, target, env): ) env.Execute(uf2_cmd) +env.AddPostAction(firmware_hex, create_uf2_action) + env.AddCustomTarget( name="create_uf2", dependencies=firmware_hex, diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c7988bb3..4b8b8e02 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -233,6 +233,11 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.read((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); // 137 + file.read((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); // 138 + file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); // 140 + file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); // 141 + file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); // 143 file.close(); } @@ -273,6 +278,11 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 + file.write((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); // 137 + file.write((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); // 138 + file.write((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); // 140 + file.write((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); // 141 + file.write((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); // 143 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e8c1914b..b8f5b9d9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -868,6 +868,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.tx_power_dbm = LORA_TX_POWER; _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default + _prefs.display_brightness = 2; // medium brightness by default + _prefs.auto_off_secs = 15; // 15 seconds auto-off by default + _prefs.tz_offset_hours = 0; // UTC by default + _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default + _prefs.batt_display_mode = 0; // icon by default //_prefs.rx_delay_base = 10.0f; enable once new algo fixed #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 48c381ce..b64102ff 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -34,4 +34,9 @@ struct NodePrefs { // persisted to file uint8_t autoadd_max_hops; // 0 = no limit, 1 = direct (0 hops), N = up to N-1 hops (max 64) char default_scope_name[31]; uint8_t default_scope_key[16]; + uint8_t display_brightness; // 0=min..4=max, default 2 (medium) + uint16_t auto_off_secs; // display auto-off: 0=never, else seconds (default 15) + int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0) + uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV + uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 94a8ee3e..9fd0c7d7 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1,5 +1,6 @@ #include "UITask.h" #include +#include #include "../MyMesh.h" #include "target.h" #ifdef WIFI_SSID @@ -75,9 +76,264 @@ public: } }; +class SettingsScreen : public UIScreen { + UITask* _task; + + enum SettingItem { + BRIGHTNESS, + BUZZER, + TX_POWER, + AUTO_OFF, +#if ENV_INCLUDE_GPS == 1 + GPS_INTERVAL, +#endif + TIMEZONE, + LOW_BAT, + BATT_DISPLAY, + Count + }; + + static const int VISIBLE_ITEMS = 4; + static const int ITEM_H = 11; + static const int START_Y = 12; + static const int VAL_X = 80; + + int _selected; + int _scroll; + + static const uint16_t AUTO_OFF_OPTS[5]; + static const char* AUTO_OFF_LABELS[5]; + static const int AUTO_OFF_COUNT = 5; +#if ENV_INCLUDE_GPS == 1 + static const uint32_t GPS_INTERVAL_OPTS[6]; + static const char* GPS_INTERVAL_LABELS[6]; + static const int GPS_INTERVAL_COUNT = 6; +#endif + static const uint16_t LOW_BAT_OPTS[7]; + static const char* LOW_BAT_LABELS[7]; + static const int LOW_BAT_COUNT = 7; + static const char* BATT_DISPLAY_LABELS[3]; + static const int BATT_DISPLAY_COUNT = 3; + + int lowBatIndex() { + uint16_t v = _task->getNodePrefs()->low_batt_mv; + for (int i = 0; i < LOW_BAT_COUNT; i++) + if (LOW_BAT_OPTS[i] == v) return i; + return 0; // default: off + } + + void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { + const int box_w = 7, box_h = 7, gap = 2; + for (int i = 0; i < max_val; i++) { + int bx = x + i * (box_w + gap); + if (i < value) display.fillRect(bx, y, box_w, box_h); + else display.drawRect(bx, y, box_w, box_h); + } + } + + int autoOffIndex() { + uint16_t v = _task->getNodePrefs()->auto_off_secs; + for (int i = 0; i < AUTO_OFF_COUNT; i++) + if (AUTO_OFF_OPTS[i] == v) return i; + return 1; // default: 15s + } + +#if ENV_INCLUDE_GPS == 1 + int gpsIntervalIndex() { + uint32_t v = _task->getNodePrefs()->gps_interval; + for (int i = 0; i < GPS_INTERVAL_COUNT; i++) + if (GPS_INTERVAL_OPTS[i] == v) return i; + return 0; + } +#endif + + void renderItem(DisplayDriver& display, int item, int y) { + NodePrefs* p = _task->getNodePrefs(); + bool sel = (item == _selected); + + display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + + if (item == BRIGHTNESS) { + display.print("Bright"); + display.setColor(DisplayDriver::GREEN); + renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); + } else if (item == BUZZER) { + display.print("Buzzer"); +#ifdef PIN_BUZZER + bool quiet = _task->isBuzzerQuiet(); + display.setColor(quiet ? DisplayDriver::RED : DisplayDriver::GREEN); + display.setCursor(VAL_X, y); + display.print(quiet ? "OFF" : "ON"); +#else + display.setColor(DisplayDriver::LIGHT); + display.setCursor(VAL_X, y); display.print("N/A"); +#endif + } else if (item == TX_POWER) { + display.print("TX Pwr"); + display.setColor(DisplayDriver::GREEN); + char buf[8]; + sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); + display.setCursor(VAL_X, y); + display.print(buf); + } else if (item == AUTO_OFF) { + display.print("AutoOff"); + display.setColor(DisplayDriver::GREEN); + display.setCursor(VAL_X, y); + display.print(AUTO_OFF_LABELS[autoOffIndex()]); +#if ENV_INCLUDE_GPS == 1 + } else if (item == GPS_INTERVAL) { + display.print("GPS upd"); + display.setColor(DisplayDriver::GREEN); + display.setCursor(VAL_X, y); + display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); +#endif + } else if (item == TIMEZONE) { + display.print("TimeZone"); + display.setColor(DisplayDriver::GREEN); + char buf[8]; + int8_t tz = p ? p->tz_offset_hours : 0; + if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); + else sprintf(buf, "UTC%d", (int)tz); + display.setCursor(VAL_X, y); + display.print(buf); + } else if (item == LOW_BAT) { + display.print("LowBat"); + display.setColor(DisplayDriver::GREEN); + display.setCursor(VAL_X, y); + display.print(LOW_BAT_LABELS[lowBatIndex()]); + } else if (item == BATT_DISPLAY) { + display.print("BattDisp"); + display.setColor(DisplayDriver::GREEN); + display.setCursor(VAL_X, y); + uint8_t mode = p ? p->batt_display_mode : 0; + display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); + } + } + +public: + SettingsScreen(UITask* task) : _task(task), _selected(0), _scroll(0) { } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { + renderItem(display, _scroll + i, START_Y + i * ITEM_H); + } + + // scroll indicators + if (_scroll > 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - 6, START_Y); + display.print("^"); + } + if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); + display.print("v"); + } + + return 300; + } + + bool handleInput(char c) override { + NodePrefs* p = _task->getNodePrefs(); + + if (c == KEY_UP) { + if (_selected > 0) { + _selected--; + if (_selected < _scroll) _scroll = _selected; + } + return true; + } + if (c == KEY_DOWN) { + if (_selected < SettingItem::Count - 1) { + _selected++; + if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; + } + return true; + } + if (c == KEY_CANCEL) { + the_mesh.savePrefs(); + _task->gotoHomeScreen(); + return true; + } + + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool enter = (c == KEY_ENTER); + + if (_selected == BRIGHTNESS) { + uint8_t lvl = _task->getBrightnessLevel(); + if (right && lvl < 4) _task->setBrightnessLevel(lvl + 1); + if (left && lvl > 0) _task->setBrightnessLevel(lvl - 1); + return right || left; + } + if (_selected == BUZZER && (left || right || enter)) { + _task->toggleBuzzer(); + return true; + } + if (_selected == TX_POWER && p) { + if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); return true; } + if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); return true; } + } + if (_selected == AUTO_OFF && p) { + int idx = autoOffIndex(); + if (right) idx = (idx + 1) % AUTO_OFF_COUNT; + if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; + if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; return true; } + } +#if ENV_INCLUDE_GPS == 1 + if (_selected == GPS_INTERVAL && p) { + int idx = gpsIntervalIndex(); + if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; + if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; + if (left || right) { + p->gps_interval = GPS_INTERVAL_OPTS[idx]; + _task->applyGPSInterval(); + return true; + } + } +#endif + if (_selected == TIMEZONE && p) { + if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; return true; } + if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; return true; } + } + if (_selected == LOW_BAT && p) { + int idx = lowBatIndex(); + if (right) idx = (idx + 1) % LOW_BAT_COUNT; + if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; + if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; return true; } + } + if (_selected == BATT_DISPLAY && p) { + int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; + if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; + if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; + if (left || right) { p->batt_display_mode = idx; return true; } + } + return false; + } +}; + +const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; +const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; +#if ENV_INCLUDE_GPS == 1 +const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; +const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; +#endif +const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 }; +const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; +const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; + class HomeScreen : public UIScreen { enum HomePage { FIRST, + CLOCK, RECENT, RADIO, BLUETOOTH, @@ -88,6 +344,7 @@ class HomeScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 SENSORS, #endif + SETTINGS, SHUTDOWN, Count // keep as last }; @@ -102,41 +359,51 @@ class HomeScreen : public UIScreen { void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { - // Convert millivolts to percentage #ifndef BATT_MIN_MILLIVOLTS - #define BATT_MIN_MILLIVOLTS 3000 + #define BATT_MIN_MILLIVOLTS 3200 #endif #ifndef BATT_MAX_MILLIVOLTS #define BATT_MAX_MILLIVOLTS 4200 #endif - const int minMilliVolts = BATT_MIN_MILLIVOLTS; - const int maxMilliVolts = BATT_MAX_MILLIVOLTS; - int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts); - if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0% - if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100% + // 0% anchor: low_batt_mv if set, otherwise BATT_MIN_MILLIVOLTS + int minMv = (_node_prefs && _node_prefs->low_batt_mv > 0) + ? (int)_node_prefs->low_batt_mv : BATT_MIN_MILLIVOLTS; + int pct = ((int)batteryMilliVolts - minMv) * 100 / (BATT_MAX_MILLIVOLTS - minMv); + if (pct < 0) pct = 0; + if (pct > 100) pct = 100; - // battery icon - int iconWidth = 24; - int iconHeight = 10; - int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner - int iconY = 0; + uint8_t mode = (_node_prefs && _node_prefs->batt_display_mode < 3) + ? _node_prefs->batt_display_mode : 0; + + display.setTextSize(1); display.setColor(DisplayDriver::GREEN); - // battery outline - display.drawRect(iconX, iconY, iconWidth, iconHeight); + int battLeftX; + if (mode == 1) { // percent + char buf[6]; + sprintf(buf, "%d%%", pct); + battLeftX = display.width() - display.getTextWidth(buf) - 1; + display.setCursor(battLeftX, 0); + display.print(buf); + } else if (mode == 2) { // voltage + char buf[8]; + sprintf(buf, "%u.%02uV", batteryMilliVolts / 1000, (batteryMilliVolts % 1000) / 10); + battLeftX = display.width() - display.getTextWidth(buf) - 1; + display.setCursor(battLeftX, 0); + display.print(buf); + } else { // icon + const int iconWidth = 24, iconHeight = 10; + battLeftX = display.width() - iconWidth - 5; + display.drawRect(battLeftX, 0, iconWidth, iconHeight); + display.fillRect(battLeftX + iconWidth, iconHeight / 4, 3, iconHeight / 2); + int fillWidth = (pct * (iconWidth - 4)) / 100; + display.fillRect(battLeftX + 2, 2, fillWidth, iconHeight - 4); + } - // battery "cap" - display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2); - - // fill the battery based on the percentage - int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100; - display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4); - - // show muted icon if buzzer is muted #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { display.setColor(DisplayDriver::RED); - display.drawXbm(iconX - 9, iconY + 1, muted_icon, 8, 8); + display.drawXbm(battLeftX - 9, 1, muted_icon, 8, 8); } #endif } @@ -203,7 +470,40 @@ public: } } - if (_page == HomePage::FIRST) { + if (_page == HomePage::CLOCK) { + uint32_t unix_ts = _rtc->getCurrentTime(); + if (unix_ts < 1000000000UL) { + display.setColor(DisplayDriver::YELLOW); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 25, "No time sync"); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 40, "Enable GPS or"); + display.drawTextCentered(display.width() / 2, 51, "connect app"); + } else { + int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + unix_ts += (int32_t)tz * 3600; + time_t t = (time_t)unix_ts; + struct tm* ti = gmtime(&t); + + char buf[24]; + display.setColor(DisplayDriver::YELLOW); + display.setTextSize(2); + sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); + display.drawTextCentered(display.width() / 2, 18, buf); + + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); + display.drawTextCentered(display.width() / 2, 40, buf); + + display.setColor(DisplayDriver::LIGHT); + if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); + else sprintf(buf, "UTC%d", (int)tz); + display.drawTextCentered(display.width() / 2, 54, buf); + } + } else if (_page == HomePage::FIRST) { display.setColor(DisplayDriver::YELLOW); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getMsgCount()); @@ -392,6 +692,13 @@ public: if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; else sensors_scroll_offset = 0; #endif + } else if (_page == HomePage::SETTINGS) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 22, "Settings"); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 38, "brightness, buzzer"); + display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::GREEN); display.setTextSize(1); @@ -402,7 +709,7 @@ public: display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); } } - return 5000; // next render after 5000 ms + return (_page == HomePage::CLOCK) ? 1000 : 5000; } bool handleInput(char c) override { @@ -447,6 +754,10 @@ public: return true; } #endif + if (c == KEY_ENTER && _page == HomePage::SETTINGS) { + _task->gotoSettingsScreen(); + return true; + } if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; @@ -549,7 +860,9 @@ public: void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; _sensors = sensors; - _auto_off = millis() + AUTO_OFF_MILLIS; + _node_prefs = node_prefs; + uint32_t aoff = autoOffMillis(); + _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS); #if defined(PIN_USER_BTN) user_btn.begin(); @@ -558,15 +871,13 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no analog_btn.begin(); #endif - _node_prefs = node_prefs; - if (_display != NULL) { _display->turnOn(); } #ifdef PIN_BUZZER - buzzer.begin(); buzzer.quiet(_node_prefs->buzzer_quiet); + buzzer.begin(); #endif #ifdef PIN_VIBRATION @@ -575,11 +886,15 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no ui_started_at = millis(); _alert_expiry = 0; + _batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); + settings = new SettingsScreen(this); setCurrScreen(splash); + + applyBrightness(); } void UITask::showAlert(const char* text, int duration_millis) { @@ -635,7 +950,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i _display->turnOn(); } if (_display->isOn()) { - _auto_off = millis() + AUTO_OFF_MILLIS; // extend the auto-off timer + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend the auto-off timer _next_refresh = 100; // trigger refresh } } @@ -711,6 +1026,16 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code } +#if UI_HAS_JOYSTICK_UPDOWN + ev = joystick_up.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_UP); + } + ev = joystick_down.check(); + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_DOWN); + } +#endif ev = joystick_left.check(); if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_LEFT); @@ -724,7 +1049,9 @@ void UITask::loop() { c = handleLongPress(KEY_RIGHT); } ev = back_btn.check(); - if (ev == BUTTON_EVENT_TRIPLE_CLICK) { + if (ev == BUTTON_EVENT_CLICK) { + c = checkDisplayOn(KEY_CANCEL); + } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { c = handleTripleClick(KEY_SELECT); } #elif defined(PIN_USER_BTN) @@ -768,7 +1095,7 @@ void UITask::loop() { if (c != 0 && curr) { curr->handleInput(c); - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 100; // trigger refresh } @@ -800,7 +1127,7 @@ void UITask::loop() { _display->endFrame(); } #if AUTO_OFF_MILLIS > 0 - if (millis() > _auto_off) { + if (autoOffMillis() > 0 && millis() > _auto_off) { _display->turnOff(); } #endif @@ -810,30 +1137,27 @@ void UITask::loop() { vibration.loop(); #endif -#ifdef AUTO_SHUTDOWN_MILLIVOLTS if (millis() > next_batt_chck) { - uint16_t milliVolts = getBattMilliVolts(); - if (milliVolts > 0 && milliVolts < AUTO_SHUTDOWN_MILLIVOLTS) { - - // show low battery shutdown alert - // we should only do this for eink displays, which will persist after power loss - #if defined(THINKNODE_M1) || defined(LILYGO_TECHO) + uint16_t raw = AbstractUITask::getBattMilliVolts(); + if (raw > 0) { + // EMA filter: alpha=0.2 (80% old, 20% new) — smooths ADC noise from uneven load + _batt_mv = (_batt_mv == 0) ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u); + } + uint16_t low_mv = _node_prefs ? _node_prefs->low_batt_mv : 0; + if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv) { if (_display != NULL) { _display->startFrame(); _display->setTextSize(2); - _display->setColor(DisplayDriver::RED); - _display->drawTextCentered(_display->width() / 2, 20, "Low Battery."); - _display->drawTextCentered(_display->width() / 2, 40, "Shutting Down!"); + _display->setColor(DisplayDriver::LIGHT); + _display->drawTextCentered(_display->width() / 2, 16, "Low Battery"); + _display->drawTextCentered(_display->width() / 2, 36, "Shutting down"); _display->endFrame(); + delay(2000); } - #endif - shutdown(); - } next_batt_chck = millis() + 8000; } -#endif } char UITask::checkDisplayOn(char c) { @@ -842,7 +1166,7 @@ char UITask::checkDisplayOn(char c) { _display->turnOn(); // turn display on and consume event c = 0; } - _auto_off = millis() + AUTO_OFF_MILLIS; // extend auto-off timer + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 0; // trigger refresh } return c; @@ -906,6 +1230,32 @@ void UITask::toggleGPS() { } } +void UITask::applyTxPower() { + if (_node_prefs == NULL) return; + radio_set_tx_power(_node_prefs->tx_power_dbm); +} + +void UITask::applyGPSInterval() { + if (_node_prefs == NULL) return; + char buf[12]; + sprintf(buf, "%u", _node_prefs->gps_interval); + sensors.setSettingValue("gps_interval", buf); +} + +void UITask::applyBrightness() { + if (_display != NULL && _node_prefs != NULL) { + _display->setBrightness(_node_prefs->display_brightness); + } +} + +void UITask::setBrightnessLevel(uint8_t level) { + if (_node_prefs == NULL) return; + if (level > 4) level = 4; + _node_prefs->display_brightness = level; + applyBrightness(); + _next_refresh = 0; +} + void UITask::toggleBuzzer() { // Toggle buzzer quiet mode #ifdef PIN_BUZZER diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index a77ad6e7..2c9ab345 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -37,6 +37,7 @@ class UITask : public AbstractUITask { unsigned long _alert_expiry; int _msgcount; unsigned long ui_started_at, next_batt_chck; + uint16_t _batt_mv; // EMA-filtered battery voltage int next_backlight_btn_check = 0; #ifdef PIN_STATUS_LED int led_state = 0; @@ -51,6 +52,7 @@ class UITask : public AbstractUITask { UIScreen* splash; UIScreen* home; UIScreen* msg_preview; + UIScreen* settings; UIScreen* curr; void userLedHandler(); @@ -68,11 +70,15 @@ public: UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { next_batt_chck = _next_refresh = 0; ui_started_at = 0; + _batt_mv = 0; curr = NULL; } void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); + NodePrefs* getNodePrefs() const { return _node_prefs; } + uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : AbstractUITask::getBattMilliVolts(); } void gotoHomeScreen() { setCurrScreen(home); } + void gotoSettingsScreen() { setCurrScreen(settings); } void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } bool hasDisplay() const { return _display != NULL; } @@ -89,6 +95,15 @@ public: void toggleBuzzer(); bool getGPSState(); void toggleGPS(); + void applyBrightness(); + void setBrightnessLevel(uint8_t level); + uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; } + void applyTxPower(); + void applyGPSInterval(); + uint32_t autoOffMillis() const { + if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0; + return (uint32_t)_node_prefs->auto_off_secs * 1000UL; + } // from AbstractUITask diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index ec63c191..7242091a 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -96,5 +96,6 @@ public: print(temp_str); } + virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default virtual void endFrame() = 0; }; diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index f383bb00..48dfd331 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -85,6 +85,15 @@ uint16_t SH1106Display::getTextWidth(const char *str) return w; } +void SH1106Display::setBrightness(uint8_t level) +{ + // OLED contrast is highly non-linear: most perceptible change is in the low range. + // Values are tuned so each step looks visually distinct. + static const uint8_t contrast_values[] = { 8, 30, 80, 160, 255 }; + uint8_t contrast = contrast_values[level < 5 ? level : 4]; + display.setContrast(contrast); +} + void SH1106Display::endFrame() { display.display(); diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index b52a6adf..45f1e2b3 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -39,5 +39,6 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t *bits, int w, int h) override; uint16_t getTextWidth(const char *str) override; + void setBrightness(uint8_t level) override; void endFrame() override; }; diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index ca469d17..29fb3e26 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -9,7 +9,6 @@ void genericBuzzer::begin() { digitalWrite(PIN_BUZZER_EN, HIGH); #endif - quiet(false); pinMode(PIN_BUZZER, OUTPUT); digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw startup(); diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 6c1e8f63..115d686f 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -55,7 +55,7 @@ build_flags = ${WioTrackerL1.build_flags} lib_deps = ${WioTrackerL1.lib_deps} adafruit/RTClib @ ^2.1.3 -[env:WioTrackerL1_companion_radio_usb] +[WioTrackerL1CompanionUSB] extends = WioTrackerL1 board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld board_upload.maximum_size = 708608 @@ -81,7 +81,16 @@ lib_deps = ${WioTrackerL1.lib_deps} densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 -[env:WioTrackerL1_companion_radio_ble] +[env:WioTrackerL1_companion_radio_usb] +extends = WioTrackerL1CompanionUSB + +[env:WioTrackerL1_companion_radio_usb_settings] +extends = WioTrackerL1CompanionUSB +build_flags = ${WioTrackerL1CompanionUSB.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 +extra_scripts = post:create-uf2.py + +[WioTrackerL1CompanionBLE] extends = WioTrackerL1 board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld board_upload.maximum_size = 708608 @@ -111,3 +120,12 @@ lib_deps = ${WioTrackerL1.lib_deps} adafruit/RTClib @ ^2.1.3 densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 + +[env:WioTrackerL1_companion_radio_ble] +extends = WioTrackerL1CompanionBLE + +[env:WioTrackerL1_companion_radio_ble_settings] +extends = WioTrackerL1CompanionBLE +build_flags = ${WioTrackerL1CompanionBLE.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 +extra_scripts = post:create-uf2.py diff --git a/variants/wio-tracker-l1/target.cpp b/variants/wio-tracker-l1/target.cpp index 896ead26..00af685a 100644 --- a/variants/wio-tracker-l1/target.cpp +++ b/variants/wio-tracker-l1/target.cpp @@ -25,6 +25,10 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager(); MomentaryButton joystick_left(JOYSTICK_LEFT, 1000, true, false, false); MomentaryButton joystick_right(JOYSTICK_RIGHT, 1000, true, false, false); MomentaryButton back_btn(PIN_BACK_BTN, 1000, true, false, true); + #ifdef UI_HAS_JOYSTICK_UPDOWN + MomentaryButton joystick_up(JOYSTICK_UP, 1000, true, false, false); + MomentaryButton joystick_down(JOYSTICK_DOWN, 1000, true, false, false); + #endif #endif bool radio_init() { diff --git a/variants/wio-tracker-l1/target.h b/variants/wio-tracker-l1/target.h index 72766e77..3722182c 100644 --- a/variants/wio-tracker-l1/target.h +++ b/variants/wio-tracker-l1/target.h @@ -28,6 +28,10 @@ extern EnvironmentSensorManager sensors; extern MomentaryButton joystick_left; extern MomentaryButton joystick_right; extern MomentaryButton back_btn; + #ifdef UI_HAS_JOYSTICK_UPDOWN + extern MomentaryButton joystick_up; + extern MomentaryButton joystick_down; + #endif #endif bool radio_init(); From 6c8a2cbdeadc24a9238d3b3102baf421ebdc09cc Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 15:34:07 +0200 Subject: [PATCH 003/183] build: include _settings companion firmware variants in release builds Adds suffix matching for WioTrackerL1 _settings builds (with settings screen) so they are picked up automatically by the companion firmware build workflow. Co-Authored-By: Claude Sonnet 4.6 --- build.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sh b/build.sh index 313c4c47..ffa72897 100755 --- a/build.sh +++ b/build.sh @@ -225,6 +225,8 @@ build_companion_firmwares() { # build all companion firmwares build_all_firmwares_by_suffix "_companion_radio_usb" build_all_firmwares_by_suffix "_companion_radio_ble" + build_all_firmwares_by_suffix "_companion_radio_usb_settings" + build_all_firmwares_by_suffix "_companion_radio_ble_settings" } From 639183d101c6ce12c1e4c04330bb1315364174a0 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 15:37:14 +0200 Subject: [PATCH 004/183] ci: add Wio Tracker L1 firmware build and release workflow Builds and releases only the _settings companion firmware variants (USB and BLE) which contain the settings screen with display brightness, buzzer, TX power, auto-off, GPS interval, timezone, low battery threshold, and battery display mode. Triggers on tags matching 'wio-tracker-*' (e.g. wio-tracker-v1.0.0) or manually via workflow_dispatch. Creates a draft GitHub release. Co-Authored-By: Claude Sonnet 4.6 --- .../build-wio-tracker-l1-firmwares.yml | 42 +++++++++++++++++++ build.sh | 7 ++++ 2 files changed, 49 insertions(+) create mode 100644 .github/workflows/build-wio-tracker-l1-firmwares.yml diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml new file mode 100644 index 00000000..28430fa6 --- /dev/null +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -0,0 +1,42 @@ +name: Build Wio Tracker L1 Firmwares + +permissions: + contents: write + +on: + workflow_dispatch: + push: + tags: + - 'wio-tracker-*' + +jobs: + + build: + runs-on: ubuntu-latest + steps: + + - name: Clone Repo + uses: actions/checkout@v4 + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Firmwares + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-wio-tracker-l1-firmwares + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v4 + with: + name: wio-tracker-l1-firmwares + path: out + + - name: Create Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + name: Wio Tracker L1 Firmware ${{ env.GIT_TAG_VERSION }} + body: "" + draft: true + files: out/* diff --git a/build.sh b/build.sh index ffa72897..6e2e8cfb 100755 --- a/build.sh +++ b/build.sh @@ -230,6 +230,11 @@ build_companion_firmwares() { } +build_wio_tracker_l1_firmwares() { + build_firmware "WioTrackerL1_companion_radio_usb_settings" + build_firmware "WioTrackerL1_companion_radio_ble_settings" +} + build_room_server_firmwares() { # # build specific room server firmwares @@ -275,6 +280,8 @@ elif [[ $1 == "build-companion-firmwares" ]]; then build_companion_firmwares elif [[ $1 == "build-repeater-firmwares" ]]; then build_repeater_firmwares +elif [[ $1 == "build-wio-tracker-l1-firmwares" ]]; then + build_wio_tracker_l1_firmwares elif [[ $1 == "build-room-server-firmwares" ]]; then build_room_server_firmwares fi From e445c0a3253e8e9817b74ecb4d94d2e12bc7684a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 17:55:59 +0200 Subject: [PATCH 005/183] docs: add Wio Tracker L1 feature documentation --- WIOTRACKER_L1.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 WIOTRACKER_L1.md diff --git a/WIOTRACKER_L1.md b/WIOTRACKER_L1.md new file mode 100644 index 00000000..87d47ab0 --- /dev/null +++ b/WIOTRACKER_L1.md @@ -0,0 +1,71 @@ +# Wio Tracker L1 — Extended Companion Radio Firmware + +This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1** (NRF52840 + SX1262 + SH1106 OLED 128×64 + GPS + buzzer + 5-way joystick). + +## New Build Variants + +In addition to the standard `_companion_radio_usb` and `_companion_radio_ble` builds, two new variants are available with a full **settings screen**: + +| Environment | Interface | +|---|---| +| `WioTrackerL1_companion_radio_usb_settings` | USB serial | +| `WioTrackerL1_companion_radio_ble_settings` | Bluetooth LE | + +The settings screen is accessed by navigating to the last page of the home screen and pressing Enter. + +## New Features + +### Settings Screen + +All settings are saved to flash and restored on next boot. + +| Setting | Options | Default | +|---|---|---| +| Display brightness | 5 levels (very dim → max) | Medium | +| Buzzer | ON / OFF | ON | +| TX Power | 2 – 22 dBm | Board default | +| Auto-off delay | 5 s / 15 s / 30 s / 60 s / never | 15 s | +| GPS update interval | off / 30 s / 1 min / 5 min / 15 min / 30 min | off | +| Timezone | UTC−12 .. UTC+14 | UTC | +| Low battery shutdown | off / 3.0 V – 3.5 V | 3.4 V | +| Battery display | icon / % / voltage | icon | + +### Clock Screen + +A dedicated clock page on the home screen shows the current time and date, synchronized from GPS. Timezone is applied from the settings above. + +### Battery Display Modes + +The top-right battery indicator can be switched between: +- **Icon** — classic fill bar +- **%** — percentage, where 0% = low battery shutdown threshold (if set), otherwise 3.2 V +- **V** — live voltage (e.g. `3.84V`) + +Battery voltage is filtered with an exponential moving average (α = 0.2) to smooth out ADC noise from uneven load. + +### Low Battery Auto-Shutdown + +When the filtered battery voltage drops below the configured threshold, the device displays a warning on screen for 2 seconds and then shuts down gracefully. The threshold is configurable from the settings screen (3.0 V – 3.5 V) or can be disabled. + +## Bug Fixes + +- **Buzzer mute at startup** — the saved mute preference is now respected during the startup sound; previously the buzzer was always unmuted on boot regardless of the saved setting. +- **Brightness change delay** — brightness changes are now applied instantly without triggering a flash write on every keypress; flash is written only when exiting the settings screen. + +## Build & Release + +Firmware is built automatically via GitHub Actions on every `wio-tracker-*` tag push. +Pre-built `.uf2` files are published in the [Releases](../../releases) section. + +To flash: copy the `.uf2` file to the device while it is in bootloader mode (double-press reset). + +## Syncing with Upstream + +This branch tracks [meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore). +To pull upstream updates: + +```bash +git fetch upstream +git rebase upstream/main +git push origin wio-tracker-l1-improvements --force-with-lease +``` From 4d46abb2c13473cbe76bce8dd70af981498a07ce Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 18:01:59 +0200 Subject: [PATCH 006/183] feat(wio-tracker-l1): energy optimizations - CPU sleep: board.sleep(0) in main loop puts NRF52 into WFE/sd_app_evt_wait between iterations; wakes on any interrupt (radio, BLE, timer). Largest power saving of the three changes. - VBAT_ENABLE gating: voltage divider now powered only during ADC read (HIGH for 10ms, then LOW). Previously stayed HIGH continuously. Fixed in both variant.cpp (init) and WioTrackerL1Board::getBattMilliVolts(). - LED off with display: PIN_LED is cleared when auto-off triggers and restored (handed back to userLedHandler) when display wakes. Avoids idle LED current draw during screen-off periods. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/main.cpp | 1 + examples/companion_radio/ui-new/UITask.cpp | 8 +++++++- variants/wio-tracker-l1/WioTrackerL1Board.cpp | 2 ++ variants/wio-tracker-l1/WioTrackerL1Board.h | 7 ++++--- variants/wio-tracker-l1/variant.cpp | 4 ++-- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 876dc9c3..25c6c489 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -229,4 +229,5 @@ void loop() { ui_task.loop(); #endif rtc_clock.tick(); + board.sleep(0); // CPU sleeps until next interrupt (radio, timer, BLE); nRF52 ignores the seconds param } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9fd0c7d7..c5681079 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1129,6 +1129,9 @@ void UITask::loop() { #if AUTO_OFF_MILLIS > 0 if (autoOffMillis() > 0 && millis() > _auto_off) { _display->turnOff(); +#ifdef PIN_LED + digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power +#endif } #endif } @@ -1163,7 +1166,10 @@ void UITask::loop() { char UITask::checkDisplayOn(char c) { if (_display != NULL) { if (!_display->isOn()) { - _display->turnOn(); // turn display on and consume event + _display->turnOn(); +#ifdef PIN_LED + digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over) +#endif c = 0; } { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.cpp b/variants/wio-tracker-l1/WioTrackerL1Board.cpp index 4153714e..61be5369 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.cpp +++ b/variants/wio-tracker-l1/WioTrackerL1Board.cpp @@ -8,6 +8,8 @@ void WioTrackerL1Board::begin() { btn_prev_state = HIGH; pinMode(PIN_VBAT_READ, INPUT); // VBAT ADC input + pinMode(VBAT_ENABLE, OUTPUT); + digitalWrite(VBAT_ENABLE, LOW); // keep voltage divider off; enabled only during ADC read // Set all button pins to INPUT_PULLUP pinMode(PIN_BUTTON1, INPUT_PULLUP); pinMode(PIN_BUTTON2, INPUT_PULLUP); diff --git a/variants/wio-tracker-l1/WioTrackerL1Board.h b/variants/wio-tracker-l1/WioTrackerL1Board.h index 052238e6..14a9746b 100644 --- a/variants/wio-tracker-l1/WioTrackerL1Board.h +++ b/variants/wio-tracker-l1/WioTrackerL1Board.h @@ -22,11 +22,12 @@ public: #endif uint16_t getBattMilliVolts() override { - int adcvalue = 0; + digitalWrite(VBAT_ENABLE, HIGH); analogReadResolution(12); analogReference(AR_INTERNAL); - delay(10); - adcvalue = analogRead(PIN_VBAT_READ); + delay(10); // allow voltage divider to stabilize + int adcvalue = analogRead(PIN_VBAT_READ); + digitalWrite(VBAT_ENABLE, LOW); return (adcvalue * ADC_MULTIPLIER * AREF_VOLTAGE) / 4.096; } diff --git a/variants/wio-tracker-l1/variant.cpp b/variants/wio-tracker-l1/variant.cpp index 4fc2606a..d5a77736 100644 --- a/variants/wio-tracker-l1/variant.cpp +++ b/variants/wio-tracker-l1/variant.cpp @@ -67,9 +67,9 @@ void initVariant() { pinMode(PIN_QSPI_CS, OUTPUT); digitalWrite(PIN_QSPI_CS, HIGH); - // VBAT_ENABLE + // VBAT_ENABLE: keep LOW by default, WioTrackerL1Board::getBattMilliVolts() toggles it pinMode(VBAT_ENABLE, OUTPUT); - digitalWrite(VBAT_ENABLE, HIGH); + digitalWrite(VBAT_ENABLE, LOW); // set LED pin as output and set it low pinMode(PIN_LED, OUTPUT); From 1fe5922c79aaba7b3bbc90fca0a861862dbb3921 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 18:23:25 +0200 Subject: [PATCH 007/183] Add RTC time persistence to flash Save last known unix timestamp to /rtc_save on shutdown and restore it on startup, so the clock shows correct time immediately after restart without waiting for GPS fix. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 20 ++++++++++++++++++++ examples/companion_radio/DataStore.h | 2 ++ examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/main.cpp | 1 + examples/companion_radio/ui-new/UITask.cpp | 1 + 5 files changed, 25 insertions(+) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4b8b8e02..3ea90016 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -288,6 +288,26 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ } } +void DataStore::saveRTCTime() { + uint32_t t = _clock->getCurrentTime(); + if (t < 1000000000UL) return; // don't save if time not yet synced + File file = openWrite(_fs, "/rtc_save"); + if (file) { + file.write((uint8_t *)&t, sizeof(t)); + file.close(); + } +} + +void DataStore::restoreRTCTime() { + File file = openRead(_fs, "/rtc_save"); + if (file) { + uint32_t t = 0; + file.read((uint8_t *)&t, sizeof(t)); + file.close(); + if (t > 1000000000UL) _clock->setCurrentTime(t); + } +} + void DataStore::loadContacts(DataStoreHost* host) { File file = openRead(_getContactsChannelsFS(), "/contacts3"); if (file) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index 58b4d5d2..1b69e190 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -49,6 +49,8 @@ public: bool removeFile(FILESYSTEM* fs, const char* filename); uint32_t getStorageUsedKb() const; uint32_t getStorageTotalKb() const; + void saveRTCTime(); + void restoreRTCTime(); private: FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;}; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index aeff591c..8b51b6a9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -165,6 +165,7 @@ protected: public: void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } + void saveRTCTime() { _store->saveRTCTime(); } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 25c6c489..671d592a 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -211,6 +211,7 @@ void setup() { #error "need to define filesystem" #endif + store.restoreRTCTime(); sensors.begin(); #if ENV_INCLUDE_GPS == 1 diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c5681079..7a95d900 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -986,6 +986,7 @@ void UITask::setCurrScreen(UIScreen* c) { hardware-agnostic pre-shutdown activity should be done here */ void UITask::shutdown(bool restart){ + the_mesh.saveRTCTime(); #ifdef PIN_BUZZER /* note: we have a choice here - From af2aca151f6f0cb022bf77aee572bbd126865bbf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 18:45:35 +0200 Subject: [PATCH 008/183] Add hardware watchdog and QuickMsgScreen Watchdog: nRF52840 hardware WDT with 30s timeout, runs during sleep. Resets device if main loop stalls for >30s. Pets in every loop() iteration so normal operation never triggers it. QuickMsgScreen: new UI screen accessible from home screen pages. Two-phase UI: contact picker (shows all contacts with hop count), then predefined message picker (location, I'm OK, need help, on my way, ETA 10min, ETA 30min). Location message uses live GPS fix if available. Sends via the_mesh.sendMessage() and shows sent/error alert. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/main.cpp | 10 ++ examples/companion_radio/ui-new/UITask.cpp | 196 +++++++++++++++++++++ examples/companion_radio/ui-new/UITask.h | 2 + 3 files changed, 208 insertions(+) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 671d592a..751de330 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -221,9 +221,19 @@ void setup() { #ifdef DISPLAY_CLASS ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved #endif + +#ifdef NRF52_PLATFORM + NRF_WDT->CONFIG = 0x01; // run during sleep; pause during debug halt + NRF_WDT->CRV = 32768*30-1; // 30 second timeout + NRF_WDT->RREN = 0x01; // enable reload register 0 + NRF_WDT->TASKS_START = 1; +#endif } void loop() { +#ifdef NRF52_PLATFORM + NRF_WDT->RR[0] = 0x6E524635UL; // pet watchdog +#endif the_mesh.loop(); sensors.loop(); #ifdef DISPLAY_CLASS diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7a95d900..84622b3c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -330,6 +330,184 @@ const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; +static const char* const QUICK_MSG_LABELS[] = { + "My location", + "I'm OK", + "Need help!", + "On my way", + "ETA 10min", + "ETA 30min", +}; +static const int QUICK_MSG_COUNT = 6; + +class QuickMsgScreen : public UIScreen { + UITask* _task; + + enum Phase { CONTACT_PICK, MSG_PICK }; + Phase _phase; + int _contact_sel, _contact_scroll; + int _msg_sel, _msg_scroll; + int _num_contacts; + ContactInfo _sel_contact; + + static const int VISIBLE = 4; + static const int ITEM_H = 12; + static const int START_Y = 12; + + void buildLocMsg(char* buf, int len) { +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + snprintf(buf, len, "Loc: %.5f,%.5f", + loc->getLatitude() / 1000000.0, + loc->getLongitude() / 1000000.0); + return; + } +#endif + snprintf(buf, len, "Loc: no GPS fix"); + } + + void renderScrollHints(DisplayDriver& display, int scroll, int count) { + display.setColor(DisplayDriver::LIGHT); + if (scroll > 0) { + display.setCursor(display.width() - 6, START_Y); + display.print("^"); + } + if (scroll + VISIBLE < count) { + display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); + display.print("v"); + } + } + +public: + QuickMsgScreen(UITask* task) + : _task(task), _phase(CONTACT_PICK), + _contact_sel(0), _contact_scroll(0), + _msg_sel(0), _msg_scroll(0), _num_contacts(0) {} + + void reset() { + _phase = CONTACT_PICK; + _contact_sel = _contact_scroll = 0; + _msg_sel = _msg_scroll = 0; + _num_contacts = the_mesh.getNumContacts(); + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::GREEN); + + if (_phase == CONTACT_PICK) { + display.drawTextCentered(display.width()/2, 0, "SELECT CONTACT"); + display.fillRect(0, 10, display.width(), 1); + + if (_num_contacts == 0) { + display.setColor(DisplayDriver::YELLOW); + display.drawTextCentered(display.width()/2, 32, "No contacts"); + return 5000; + } + + for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { + int idx = _contact_scroll + i; + bool sel = (idx == _contact_sel); + int y = START_Y + i * ITEM_H; + display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + display.setCursor(0, y); + display.print(sel ? ">" : " "); + + ContactInfo c; + if (the_mesh.getContactByIdx(idx, c)) { + char filtered[sizeof(c.name)]; + display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); + display.drawTextEllipsized(8, y, display.width() - 24, filtered); + display.setColor(DisplayDriver::GREEN); + char hop[5]; + snprintf(hop, sizeof(hop), c.path_len == 0xFF ? "D" : "%dh", (int)c.path_len); + display.setCursor(display.width() - display.getTextWidth(hop) - 1, y); + display.print(hop); + } + } + renderScrollHints(display, _contact_scroll, _num_contacts); + + } else { + char title[24]; + snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < VISIBLE && (_msg_scroll+i) < QUICK_MSG_COUNT; i++) { + int idx = _msg_scroll + i; + bool sel = (idx == _msg_sel); + int y = START_Y + i * ITEM_H; + display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + display.setCursor(0, y); + display.print(sel ? ">" : " "); + + if (idx == 0) { + char loc[36]; + buildLocMsg(loc, sizeof(loc)); + display.drawTextEllipsized(8, y, display.width() - 10, loc); + } else { + display.setCursor(8, y); + display.print(QUICK_MSG_LABELS[idx]); + } + } + renderScrollHints(display, _msg_scroll, QUICK_MSG_COUNT); + } + return 300; + } + + bool handleInput(char c) override { + if (_phase == CONTACT_PICK) { + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } + if ((c == KEY_UP) && _contact_sel > 0) { + _contact_sel--; + if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; + return true; + } + if ((c == KEY_DOWN) && _contact_sel < _num_contacts - 1) { + _contact_sel++; + if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _num_contacts > 0) { + if (the_mesh.getContactByIdx(_contact_sel, _sel_contact)) { + _phase = MSG_PICK; + _msg_sel = _msg_scroll = 0; + } + return true; + } + } else { + if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } + if ((c == KEY_UP) && _msg_sel > 0) { + _msg_sel--; + if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; + return true; + } + if ((c == KEY_DOWN) && _msg_sel < QUICK_MSG_COUNT - 1) { + _msg_sel++; + if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER) { + char msg[80]; + if (_msg_sel == 0) { + buildLocMsg(msg, sizeof(msg)); + } else { + strncpy(msg, QUICK_MSG_LABELS[_msg_sel], sizeof(msg)-1); + msg[sizeof(msg)-1] = '\0'; + } + uint32_t expected_ack = 0, est_timeout = 0; + int result = the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, + msg, expected_ack, est_timeout); + _task->showAlert(result > 0 ? "Sent!" : "Send failed", 1500); + _task->gotoHomeScreen(); + return true; + } + } + return false; + } +}; + class HomeScreen : public UIScreen { enum HomePage { FIRST, @@ -345,6 +523,7 @@ class HomeScreen : public UIScreen { SENSORS, #endif SETTINGS, + QUICK_MSG, SHUTDOWN, Count // keep as last }; @@ -699,6 +878,13 @@ public: display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 38, "brightness, buzzer"); display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); + } else if (_page == HomePage::QUICK_MSG) { + display.setColor(DisplayDriver::GREEN); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 22, "Quick Message"); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 38, "send to contact"); + display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::GREEN); display.setTextSize(1); @@ -758,6 +944,10 @@ public: _task->gotoSettingsScreen(); return true; } + if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) { + _task->gotoQuickMsgScreen(); + return true; + } if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) { _shutdown_init = true; // need to wait for button to be released return true; @@ -892,11 +1082,17 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); msg_preview = new MsgPreviewScreen(this, &rtc_clock); settings = new SettingsScreen(this); + quick_msg = new QuickMsgScreen(this); setCurrScreen(splash); applyBrightness(); } +void UITask::gotoQuickMsgScreen() { + ((QuickMsgScreen*)quick_msg)->reset(); + setCurrScreen(quick_msg); +} + void UITask::showAlert(const char* text, int duration_millis) { strcpy(_alert, text); _alert_expiry = millis() + duration_millis; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 2c9ab345..05158773 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -53,6 +53,7 @@ class UITask : public AbstractUITask { UIScreen* home; UIScreen* msg_preview; UIScreen* settings; + UIScreen* quick_msg; UIScreen* curr; void userLedHandler(); @@ -79,6 +80,7 @@ public: uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : AbstractUITask::getBattMilliVolts(); } void gotoHomeScreen() { setCurrScreen(home); } void gotoSettingsScreen() { setCurrScreen(settings); } + void gotoQuickMsgScreen(); void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } bool hasDisplay() const { return _display != NULL; } From 18c13002db86d896a3ac456504b0406b6b7345ee Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 18:52:31 +0200 Subject: [PATCH 009/183] QuickMsgScreen: sort favourites first, mark with asterisk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favourite contacts (flags & 0x01 set by companion app) appear at the top of the contact picker, marked with '*'. Non-favourites appear below. The favourite bit is already synced by the existing protocol via CMD_ADD_UPDATE_CONTACT — no protocol changes needed. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 27 +++++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 84622b3c..79ff490a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -348,6 +348,7 @@ class QuickMsgScreen : public UIScreen { int _contact_sel, _contact_scroll; int _msg_sel, _msg_scroll; int _num_contacts; + uint8_t _sorted[MAX_CONTACTS]; // contact indices, favourites sorted first ContactInfo _sel_contact; static const int VISIBLE = 4; @@ -390,6 +391,18 @@ public: _contact_sel = _contact_scroll = 0; _msg_sel = _msg_scroll = 0; _num_contacts = the_mesh.getNumContacts(); + // sort: favourites (flags & 0x01) first, rest after + ContactInfo c; + int nfavs = 0; + for (int i = 0; i < _num_contacts; i++) + if (the_mesh.getContactByIdx(i, c) && (c.flags & 0x01)) nfavs++; + int fpos = 0, npos = nfavs; + for (int i = 0; i < _num_contacts; i++) { + if (the_mesh.getContactByIdx(i, c) && (c.flags & 0x01)) + _sorted[fpos++] = i; + else + _sorted[npos++] = i; + } } int render(DisplayDriver& display) override { @@ -407,15 +420,17 @@ public: } for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { - int idx = _contact_scroll + i; - bool sel = (idx == _contact_sel); + int list_idx = _contact_scroll + i; + int mesh_idx = _sorted[list_idx]; + bool sel = (list_idx == _contact_sel); int y = START_Y + i * ITEM_H; display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); - display.setCursor(0, y); - display.print(sel ? ">" : " "); ContactInfo c; - if (the_mesh.getContactByIdx(idx, c)) { + if (the_mesh.getContactByIdx(mesh_idx, c)) { + bool fav = (c.flags & 0x01); + display.setCursor(0, y); + display.print(sel ? ">" : (fav ? "*" : " ")); char filtered[sizeof(c.name)]; display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); display.drawTextEllipsized(8, y, display.width() - 24, filtered); @@ -470,7 +485,7 @@ public: return true; } if (c == KEY_ENTER && _num_contacts > 0) { - if (the_mesh.getContactByIdx(_contact_sel, _sel_contact)) { + if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { _phase = MSG_PICK; _msg_sel = _msg_scroll = 0; } From 20824c2db4e9be35e3bc37ec908e7a9a69c8fc8c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 18:53:52 +0200 Subject: [PATCH 010/183] Fix: use out_path_len instead of path_len in QuickMsgScreen ContactInfo has out_path_len, not path_len. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 79ff490a..1d15b554 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -436,7 +436,7 @@ public: display.drawTextEllipsized(8, y, display.width() - 24, filtered); display.setColor(DisplayDriver::GREEN); char hop[5]; - snprintf(hop, sizeof(hop), c.path_len == 0xFF ? "D" : "%dh", (int)c.path_len); + snprintf(hop, sizeof(hop), c.out_path_len == 0xFF ? "D" : "%dh", (int)c.out_path_len); display.setCursor(display.width() - display.getTextWidth(hop) - 1, y); display.print(hop); } From 03085f0715cd8e88156b5f603980db92de8cfae7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 18:58:13 +0200 Subject: [PATCH 011/183] QuickMsgScreen: show only companion (chat) contacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter contact list to ADV_TYPE_CHAT only — excludes repeaters, rooms, and sensors which cannot receive direct chat messages. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 1d15b554..86ed9982 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -391,17 +391,19 @@ public: _contact_sel = _contact_scroll = 0; _msg_sel = _msg_scroll = 0; _num_contacts = the_mesh.getNumContacts(); - // sort: favourites (flags & 0x01) first, rest after + // only show chat companions (not repeaters/rooms/sensors), favourites first ContactInfo c; int nfavs = 0; for (int i = 0; i < _num_contacts; i++) - if (the_mesh.getContactByIdx(i, c) && (c.flags & 0x01)) nfavs++; + if (the_mesh.getContactByIdx(i, c) && c.type == ADV_TYPE_CHAT && (c.flags & 0x01)) nfavs++; int fpos = 0, npos = nfavs; - for (int i = 0; i < _num_contacts; i++) { - if (the_mesh.getContactByIdx(i, c) && (c.flags & 0x01)) - _sorted[fpos++] = i; - else - _sorted[npos++] = i; + _num_contacts = 0; + int total = the_mesh.getNumContacts(); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; + if (c.flags & 0x01) _sorted[fpos++] = i; + else _sorted[npos++] = i; + _num_contacts++; } } From 35b2b008c8cdd0344dec84fb628dd54dd94d1e86 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 19:47:12 +0200 Subject: [PATCH 012/183] Battery %: use LiPo discharge curve with variable shutdown threshold Replace linear voltage-to-percent formula with a LiPo discharge curve lookup table and linear interpolation. The curve is then rescaled so that low_batt_mv (configurable shutdown threshold) maps to 0% and 4200mV maps to 100%, preserving the non-linear shape regardless of the chosen threshold. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 35 +++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 86ed9982..6381c7fe 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -558,13 +558,34 @@ class HomeScreen : public UIScreen { #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3200 #endif -#ifndef BATT_MAX_MILLIVOLTS - #define BATT_MAX_MILLIVOLTS 4200 -#endif - // 0% anchor: low_batt_mv if set, otherwise BATT_MIN_MILLIVOLTS - int minMv = (_node_prefs && _node_prefs->low_batt_mv > 0) - ? (int)_node_prefs->low_batt_mv : BATT_MIN_MILLIVOLTS; - int pct = ((int)batteryMilliVolts - minMv) * 100 / (BATT_MAX_MILLIVOLTS - minMv); + // LiPo discharge curve: voltage (mV) → raw capacity (%) + static const struct { uint16_t mv; uint8_t pct; } CURVE[] = { + {3200, 0}, {3300, 3}, {3400, 8}, {3500, 15}, + {3600, 25}, {3650, 33}, {3700, 45}, {3750, 58}, + {3800, 68}, {3900, 77}, {4000, 86}, {4100, 93}, {4200, 100} + }; + static const int CURVE_LEN = sizeof(CURVE) / sizeof(CURVE[0]); + + auto curveAt = [&](int mv) -> int { + if (mv <= (int)CURVE[0].mv) return CURVE[0].pct; + if (mv >= (int)CURVE[CURVE_LEN-1].mv) return CURVE[CURVE_LEN-1].pct; + for (int i = 1; i < CURVE_LEN; i++) { + if (mv <= (int)CURVE[i].mv) { + int span_mv = CURVE[i].mv - CURVE[i-1].mv; + int span_pct = CURVE[i].pct - CURVE[i-1].pct; + return CURVE[i-1].pct + (mv - (int)CURVE[i-1].mv) * span_pct / span_mv; + } + } + return 100; + }; + + int low_mv = (_node_prefs && _node_prefs->low_batt_mv > 0) + ? (int)_node_prefs->low_batt_mv : BATT_MIN_MILLIVOLTS; + int raw_pct = curveAt((int)batteryMilliVolts); + int low_pct = curveAt(low_mv); + // rescale so low_mv = 0% and 4200mV = 100% + int pct = (low_pct >= 100) ? 0 + : (raw_pct - low_pct) * 100 / (100 - low_pct); if (pct < 0) pct = 0; if (pct > 100) pct = 100; From 4e83e993161d43f4f6ba8a4ab10fc950f58d5f64 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 20:26:05 +0200 Subject: [PATCH 013/183] Fix null pointer dereferences in SettingsScreen index helpers lowBatIndex(), autoOffIndex(), gpsIntervalIndex() each called getNodePrefs() independently without null check, while renderItem() had its own guarded copy. Added null checks consistent with the rest of the class. Also initialize _node_prefs to NULL in UITask constructor. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 19 +++++++++++-------- examples/companion_radio/ui-new/UITask.h | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6381c7fe..44676a3b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -116,10 +116,11 @@ class SettingsScreen : public UIScreen { static const int BATT_DISPLAY_COUNT = 3; int lowBatIndex() { - uint16_t v = _task->getNodePrefs()->low_batt_mv; + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; for (int i = 0; i < LOW_BAT_COUNT; i++) - if (LOW_BAT_OPTS[i] == v) return i; - return 0; // default: off + if (LOW_BAT_OPTS[i] == p->low_batt_mv) return i; + return 0; } void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { @@ -132,17 +133,19 @@ class SettingsScreen : public UIScreen { } int autoOffIndex() { - uint16_t v = _task->getNodePrefs()->auto_off_secs; + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 1; for (int i = 0; i < AUTO_OFF_COUNT; i++) - if (AUTO_OFF_OPTS[i] == v) return i; - return 1; // default: 15s + if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i; + return 1; } #if ENV_INCLUDE_GPS == 1 int gpsIntervalIndex() { - uint32_t v = _task->getNodePrefs()->gps_interval; + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; for (int i = 0; i < GPS_INTERVAL_COUNT; i++) - if (GPS_INTERVAL_OPTS[i] == v) return i; + if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; return 0; } #endif diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 05158773..5447270e 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -68,7 +68,7 @@ class UITask : public AbstractUITask { public: - UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL) { + UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL) { next_batt_chck = _next_refresh = 0; ui_started_at = 0; _batt_mv = 0; From 60de6630fa92c9607ad7c33992c6e765e83d487b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 22:12:29 +0200 Subject: [PATCH 014/183] SettingsScreen: skip flash write if no settings changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track dirty state per-field in handleInput. savePrefs() on exit only if at least one value was actually modified. Buzzer is excluded — it already saves immediately via toggleBuzzer(). markClean() resets the flag each time the settings screen is opened. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 33 ++++++++++++++-------- examples/companion_radio/ui-new/UITask.h | 2 +- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 44676a3b..87c37bb1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -100,6 +100,7 @@ class SettingsScreen : public UIScreen { int _selected; int _scroll; + bool _dirty; static const uint16_t AUTO_OFF_OPTS[5]; static const char* AUTO_OFF_LABELS[5]; @@ -217,7 +218,9 @@ class SettingsScreen : public UIScreen { } public: - SettingsScreen(UITask* task) : _task(task), _selected(0), _scroll(0) { } + SettingsScreen(UITask* task) : _task(task), _selected(0), _scroll(0), _dirty(false) { } + + void markClean() { _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); @@ -262,7 +265,7 @@ public: return true; } if (c == KEY_CANCEL) { - the_mesh.savePrefs(); + if (_dirty) the_mesh.savePrefs(); _task->gotoHomeScreen(); return true; } @@ -273,23 +276,23 @@ public: if (_selected == BRIGHTNESS) { uint8_t lvl = _task->getBrightnessLevel(); - if (right && lvl < 4) _task->setBrightnessLevel(lvl + 1); - if (left && lvl > 0) _task->setBrightnessLevel(lvl - 1); + if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; } + if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; } return right || left; } if (_selected == BUZZER && (left || right || enter)) { - _task->toggleBuzzer(); + _task->toggleBuzzer(); // saves immediately internally return true; } if (_selected == TX_POWER && p) { - if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); return true; } - if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); return true; } + if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } + if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } } if (_selected == AUTO_OFF && p) { int idx = autoOffIndex(); if (right) idx = (idx + 1) % AUTO_OFF_COUNT; if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; - if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; return true; } + if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } } #if ENV_INCLUDE_GPS == 1 if (_selected == GPS_INTERVAL && p) { @@ -299,25 +302,26 @@ public: if (left || right) { p->gps_interval = GPS_INTERVAL_OPTS[idx]; _task->applyGPSInterval(); + _dirty = true; return true; } } #endif if (_selected == TIMEZONE && p) { - if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; return true; } - if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; return true; } + if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; _dirty = true; return true; } + if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; _dirty = true; return true; } } if (_selected == LOW_BAT && p) { int idx = lowBatIndex(); if (right) idx = (idx + 1) % LOW_BAT_COUNT; if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; - if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; return true; } + if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } } if (_selected == BATT_DISPLAY && p) { int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; - if (left || right) { p->batt_display_mode = idx; return true; } + if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } } return false; } @@ -1129,6 +1133,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no applyBrightness(); } +void UITask::gotoSettingsScreen() { + ((SettingsScreen*)settings)->markClean(); + setCurrScreen(settings); +} + void UITask::gotoQuickMsgScreen() { ((QuickMsgScreen*)quick_msg)->reset(); setCurrScreen(quick_msg); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5447270e..0950b202 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -79,7 +79,7 @@ public: NodePrefs* getNodePrefs() const { return _node_prefs; } uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : AbstractUITask::getBattMilliVolts(); } void gotoHomeScreen() { setCurrScreen(home); } - void gotoSettingsScreen() { setCurrScreen(settings); } + void gotoSettingsScreen(); void gotoQuickMsgScreen(); void showAlert(const char* text, int duration_millis); int getMsgCount() const { return _msgcount; } From 87aafce88fc9e1ebbf471aa7aad8e1dede67326a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 22:51:46 +0200 Subject: [PATCH 015/183] Set custom FIRMWARE_VERSION for Wio Tracker companion builds Override the upstream FIRMWARE_VERSION define via build_flags so all companion radio builds (USB and BLE) show "v1.15.0-Plus" on the splash screen. The #ifndef guard in MyMesh.h makes this override automatic. Co-Authored-By: Claude Sonnet 4.6 --- variants/wio-tracker-l1/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 115d686f..26ed87b5 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -68,6 +68,7 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 + -D FIRMWARE_VERSION='"v1.15.0-Plus"' ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} @@ -106,6 +107,7 @@ build_flags = ${WioTrackerL1.build_flags} -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' + -D FIRMWARE_VERSION='"v1.15.0-Plus"' -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 From 3f7293c65748fca2af62a83e6a9302461baee112 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 22:53:36 +0200 Subject: [PATCH 016/183] Fix FIRMWARE_VERSION: append -Plus in build.sh, not platformio.ini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.sh sets FIRMWARE_VERSION via PLATFORMIO_BUILD_FLAGS — hardcoding it in platformio.ini caused a macro redefinition conflict. Instead, build_wio_tracker_l1_firmwares() now appends "-Plus" to the env var before calling build_firmware(), so the final version string becomes e.g. v1.15.0-Plus-abcdef. Co-Authored-By: Claude Sonnet 4.6 --- build.sh | 3 +++ variants/wio-tracker-l1/platformio.ini | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 6e2e8cfb..8fc513d2 100755 --- a/build.sh +++ b/build.sh @@ -231,8 +231,11 @@ build_companion_firmwares() { } build_wio_tracker_l1_firmwares() { + local _saved_version="$FIRMWARE_VERSION" + export FIRMWARE_VERSION="${FIRMWARE_VERSION}-Plus" build_firmware "WioTrackerL1_companion_radio_usb_settings" build_firmware "WioTrackerL1_companion_radio_ble_settings" + export FIRMWARE_VERSION="$_saved_version" } build_room_server_firmwares() { diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 26ed87b5..115d686f 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -68,7 +68,6 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 - -D FIRMWARE_VERSION='"v1.15.0-Plus"' ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} @@ -107,7 +106,6 @@ build_flags = ${WioTrackerL1.build_flags} -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' - -D FIRMWARE_VERSION='"v1.15.0-Plus"' -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 From 81c69f89c6bdea71f882dfe0120f9377c3d9b291 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 10 May 2026 22:58:46 +0200 Subject: [PATCH 017/183] Splash screen: show unofficial build marker for Plus builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FIRMWARE_PLUS_BUILD compile flag to both companion sections in platformio.ini. When set, SplashScreen shows "+ unofficial build" in yellow below the build date. Avoids runtime string parsing — the existing version stripping logic (cuts at first dash) is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 5 +++++ variants/wio-tracker-l1/platformio.ini | 2 ++ 2 files changed, 7 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 87c37bb1..0fb86dec 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -66,6 +66,11 @@ public: display.setTextSize(1); display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); +#ifdef FIRMWARE_PLUS_BUILD + display.setColor(DisplayDriver::YELLOW); + display.drawTextCentered(display.width()/2, 54, "+ unofficial build"); +#endif + return 1000; } diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 115d686f..34d21989 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -68,6 +68,7 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 + -D FIRMWARE_PLUS_BUILD=1 ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} @@ -106,6 +107,7 @@ build_flags = ${WioTrackerL1.build_flags} -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' + -D FIRMWARE_PLUS_BUILD=1 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 From d5a2e1170abfccbd3063d962ab2e483fe2703b71 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 08:36:39 +0200 Subject: [PATCH 018/183] Enable sensors page for Wio Tracker companion builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI_SENSORS_PAGE=1 activates the existing sensor display screen in HomeScreen — shows temperature, pressure, humidity, voltage and other LPP-formatted sensor data from connected I2C sensors (BME280, etc.). BME280 support is already included via sensor_base build flags. Co-Authored-By: Claude Sonnet 4.6 --- variants/wio-tracker-l1/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 34d21989..8b5d94dd 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -69,6 +69,7 @@ build_flags = ${WioTrackerL1.build_flags} -D PIN_BUZZER=12 -D QSPIFLASH=1 -D FIRMWARE_PLUS_BUILD=1 + -D UI_SENSORS_PAGE=1 ; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1 ; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1 build_src_filter = ${WioTrackerL1.build_src_filter} @@ -108,6 +109,7 @@ build_flags = ${WioTrackerL1.build_flags} -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' -D FIRMWARE_PLUS_BUILD=1 + -D UI_SENSORS_PAGE=1 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL ; -D MESH_PACKET_LOGGING=1 From 6779b3489b6c70d58bd34c5e206c53d2f80b5bc1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 15:56:30 +0200 Subject: [PATCH 019/183] WioTrackerL1: redesign companion UI (channels, keyboard, msg preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuickMsg → Message screen: channel history, on-screen keyboard, custom message first - Channel history: RAM ring buffer (32 entries, 80 chars), fullscreen view with word-wrap scroll - After channel send: stay in history, own message shown as "Me: ..." - MsgPreview: one message per screen with up/down nav, ENTER opens fullscreen scroll - Monochromatic display: all color usage corrected to DARK/LIGHT only - Top bar: battery icon, BT muted indicator, splash "Plus for Wio" - KB_MAX_LEN: 64 → 100 chars; channel text buffer: 52 → 80 chars Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 1 + examples/companion_radio/MyMesh.cpp | 2 +- examples/companion_radio/ui-new/UITask.cpp | 867 ++++++++++++++++++--- examples/companion_radio/ui-new/UITask.h | 1 + 4 files changed, 747 insertions(+), 124 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 0eee45ae..18e1f9cb 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -42,5 +42,6 @@ public: 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 notify(UIEventType t = UIEventType::none) = 0; + virtual void addChannelMsg(uint8_t channel_idx, const char* text) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b8f5b9d9..7adcaf65 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -567,7 +567,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe #endif } #ifdef DISPLAY_CLASS - // Get the channel name from the channel index + if (_ui) _ui->addChannelMsg(channel_idx, text); const char *channel_name = "Unknown"; ChannelDetails channel_details; if (getChannel(channel_idx, channel_details)) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0fb86dec..18e98669 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -54,12 +54,11 @@ public: int render(DisplayDriver& display) override { // meshcore logo - display.setColor(DisplayDriver::BLUE); + display.setColor(DisplayDriver::LIGHT); int logoWidth = 128; display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); // version info - display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); display.drawTextCentered(display.width()/2, 22, _version_info); @@ -67,8 +66,10 @@ public: display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); #ifdef FIRMWARE_PLUS_BUILD - display.setColor(DisplayDriver::YELLOW); - display.drawTextCentered(display.width()/2, 54, "+ unofficial build"); + display.fillRect(0, 53, display.width(), 10); + display.setColor(DisplayDriver::DARK); + display.drawTextCentered(display.width()/2, 54, "Plus for Wio"); + display.setColor(DisplayDriver::LIGHT); #endif return 1000; @@ -160,48 +161,47 @@ class SettingsScreen : public UIScreen { NodePrefs* p = _task->getNodePrefs(); bool sel = (item == _selected); - display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); display.print(sel ? ">" : " "); display.setCursor(8, y); if (item == BRIGHTNESS) { display.print("Bright"); - display.setColor(DisplayDriver::GREEN); renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); } else if (item == BUZZER) { display.print("Buzzer"); -#ifdef PIN_BUZZER - bool quiet = _task->isBuzzerQuiet(); - display.setColor(quiet ? DisplayDriver::RED : DisplayDriver::GREEN); display.setCursor(VAL_X, y); - display.print(quiet ? "OFF" : "ON"); +#ifdef PIN_BUZZER + display.print(_task->isBuzzerQuiet() ? "OFF" : "ON"); #else - display.setColor(DisplayDriver::LIGHT); - display.setCursor(VAL_X, y); display.print("N/A"); + display.print("N/A"); #endif } else if (item == TX_POWER) { display.print("TX Pwr"); - display.setColor(DisplayDriver::GREEN); char buf[8]; sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); display.setCursor(VAL_X, y); display.print(buf); } else if (item == AUTO_OFF) { display.print("AutoOff"); - display.setColor(DisplayDriver::GREEN); display.setCursor(VAL_X, y); display.print(AUTO_OFF_LABELS[autoOffIndex()]); #if ENV_INCLUDE_GPS == 1 } else if (item == GPS_INTERVAL) { display.print("GPS upd"); - display.setColor(DisplayDriver::GREEN); display.setCursor(VAL_X, y); display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); #endif } else if (item == TIMEZONE) { display.print("TimeZone"); - display.setColor(DisplayDriver::GREEN); char buf[8]; int8_t tz = p ? p->tz_offset_hours : 0; if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); @@ -210,12 +210,10 @@ class SettingsScreen : public UIScreen { display.print(buf); } else if (item == LOW_BAT) { display.print("LowBat"); - display.setColor(DisplayDriver::GREEN); display.setCursor(VAL_X, y); display.print(LOW_BAT_LABELS[lowBatIndex()]); } else if (item == BATT_DISPLAY) { display.print("BattDisp"); - display.setColor(DisplayDriver::GREEN); display.setCursor(VAL_X, y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); @@ -229,7 +227,7 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); display.fillRect(0, 10, display.width(), 1); @@ -352,20 +350,96 @@ static const char* const QUICK_MSG_LABELS[] = { }; static const int QUICK_MSG_COUNT = 6; +// On-screen keyboard layout (4 rows × 10 cols) +static const char 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'}, +}; +static const int KB_ROWS_CHAR = 4; +static const int KB_COLS_CHAR = 10; +static const int KB_SPECIAL = 3; // [Spc] [Del] [OK] +static const int KB_MAX_LEN = 100; +static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display +static const int KB_CELL_H = 9; +static const int KB_TEXT_Y = 0; +static const int KB_SEP_Y = 9; +static const int KB_CHARS_Y = 11; // first char row +static const int KB_SPECIAL_Y = 48; + class QuickMsgScreen : public UIScreen { UITask* _task; - enum Phase { CONTACT_PICK, MSG_PICK }; + enum Phase { MODE_SELECT, CONTACT_PICK, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; Phase _phase; + + // MODE_SELECT + int _mode_sel; // 0=Direct, 1=Channel + + // CONTACT_PICK int _contact_sel, _contact_scroll; - int _msg_sel, _msg_scroll; int _num_contacts; - uint8_t _sorted[MAX_CONTACTS]; // contact indices, favourites sorted first + uint8_t _sorted[MAX_CONTACTS]; ContactInfo _sel_contact; - static const int VISIBLE = 4; - static const int ITEM_H = 12; - static const int START_Y = 12; + // CHANNEL_PICK + int _channel_sel, _channel_scroll; + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + int _sel_channel_idx; + bool _sending_to_channel; + + // MSG_PICK (shared) + int _msg_sel, _msg_scroll; + + // CHANNEL_HIST + int _hist_sel, _hist_scroll; + bool _hist_fullscreen; + int _hist_fs_scroll; + static const int CH_HIST_MAX = 32; + + static const int FS_CHARS = 21; + static const int FS_LINE_H = 9; + static const int FS_START_Y = 12; + static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; + + int wrapLines(const char* text, char out[][FS_CHARS + 1], int max_lines) const { + int count = 0; + const char* p = text; + while (*p && count < max_lines) { + int len = strlen(p); + if (len <= FS_CHARS) { + strncpy(out[count++], p, FS_CHARS); + out[count-1][len] = '\0'; + break; + } + int brk = FS_CHARS; + for (int i = FS_CHARS - 1; i > 0; i--) { + if (p[i] == ' ') { brk = i; break; } + } + strncpy(out[count], p, brk); + out[count++][brk] = '\0'; + p += brk + (p[brk] == ' ' ? 1 : 0); + } + return count; + } + + // KEYBOARD + int _kb_row, _kb_col; + char _kb_text[KB_MAX_LEN + 1]; + int _kb_len; + struct ChHistEntry { uint8_t ch_idx; char text[80]; }; + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + + static const int VISIBLE = 4; + static const int ITEM_H = 12; + static const int START_Y = 12; + static const int HIST_VISIBLE = 2; // 2-line boxed history entries + static const int HIST_ITEM_H = 21; // box(19) + gap(2) + static const int HIST_BOX_H = 19; + static const int HIST_START_Y = 11; void buildLocMsg(char* buf, int len) { #if ENV_INCLUDE_GPS == 1 @@ -392,43 +466,150 @@ class QuickMsgScreen : public UIScreen { } } + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void afterSend(bool ok, const char* msg) { + if (ok && _sending_to_channel) { + char entry[sizeof(ChHistEntry::text)]; + snprintf(entry, sizeof(entry), "Me: %s", msg); + addChannelMsg(_sel_channel_idx, entry); + _hist_sel = 0; + _hist_scroll = 0; + _phase = CHANNEL_HIST; + _task->showAlert("Sent!", 600); + } else { + _task->showAlert(ok ? "Sent!" : "Send failed", 1500); + _task->gotoHomeScreen(); + } + } + + bool sendText(const char* msg) { + if (_sending_to_channel) { + ChannelDetails ch; + if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; + return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, + the_mesh.getNodeName(), msg, strlen(msg)); + } else { + uint32_t expected_ack = 0, est_timeout = 0; + return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, + msg, expected_ack, est_timeout) > 0; + } + } + + void buildChannelList() { + _num_channels = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + } + public: QuickMsgScreen(UITask* task) - : _task(task), _phase(CONTACT_PICK), - _contact_sel(0), _contact_scroll(0), - _msg_sel(0), _msg_scroll(0), _num_contacts(0) {} + : _task(task), _phase(MODE_SELECT), _mode_sel(0), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), + _channel_sel(0), _channel_scroll(0), _num_channels(0), + _sel_channel_idx(0), _sending_to_channel(false), + _msg_sel(0), _msg_scroll(0), + _hist_sel(0), _hist_scroll(0), + _hist_fullscreen(false), _hist_fs_scroll(0), + _hist_head(0), _hist_count(0), + _kb_row(0), _kb_col(0), _kb_len(0) { _kb_text[0] = '\0'; } + + void addChannelMsg(uint8_t ch_idx, const char* text) { + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + } void reset() { - _phase = CONTACT_PICK; + _phase = MODE_SELECT; + _mode_sel = 0; _contact_sel = _contact_scroll = 0; _msg_sel = _msg_scroll = 0; - _num_contacts = the_mesh.getNumContacts(); - // only show chat companions (not repeaters/rooms/sensors), favourites first + _channel_sel = _channel_scroll = 0; + _sending_to_channel = false; + _kb_row = _kb_col = _kb_len = 0; + _kb_text[0] = '\0'; + + // build contact list: chat companions only, favourites first ContactInfo c; + int total = the_mesh.getNumContacts(); int nfavs = 0; - for (int i = 0; i < _num_contacts; i++) + for (int i = 0; i < total; i++) if (the_mesh.getContactByIdx(i, c) && c.type == ADV_TYPE_CHAT && (c.flags & 0x01)) nfavs++; int fpos = 0, npos = nfavs; _num_contacts = 0; - int total = the_mesh.getNumContacts(); for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; if (c.flags & 0x01) _sorted[fpos++] = i; else _sorted[npos++] = i; _num_contacts++; } + + buildChannelList(); } int render(DisplayDriver& display) override { display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); - if (_phase == CONTACT_PICK) { + if (_phase == MODE_SELECT) { + display.drawTextCentered(display.width()/2, 0, "MESSAGE"); + display.fillRect(0, 10, display.width(), 1); + const char* opts[] = { "Direct message", "Channels" }; + for (int i = 0; i < 2; i++) { + int y = START_Y + i * ITEM_H; + bool sel = (i == _mode_sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + display.print(opts[i]); + } + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == CONTACT_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CONTACT"); display.fillRect(0, 10, display.width(), 1); if (_num_contacts == 0) { - display.setColor(DisplayDriver::YELLOW); display.drawTextCentered(display.width()/2, 32, "No contacts"); return 5000; } @@ -438,7 +619,14 @@ public: int mesh_idx = _sorted[list_idx]; bool sel = (list_idx == _contact_sel); int y = START_Y + i * ITEM_H; - display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } ContactInfo c; if (the_mesh.getContactByIdx(mesh_idx, c)) { @@ -448,88 +636,444 @@ public: char filtered[sizeof(c.name)]; display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); display.drawTextEllipsized(8, y, display.width() - 24, filtered); - display.setColor(DisplayDriver::GREEN); char hop[5]; snprintf(hop, sizeof(hop), c.out_path_len == 0xFF ? "D" : "%dh", (int)c.out_path_len); display.setCursor(display.width() - display.getTextWidth(hop) - 1, y); display.print(hop); } } + display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _contact_scroll, _num_contacts); - } else { + } else if (_phase == CHANNEL_PICK) { + display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); + display.fillRect(0, 10, display.width(), 1); + + if (_num_channels == 0) { + display.drawTextCentered(display.width()/2, 32, "No channels"); + return 5000; + } + + for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { + int list_idx = _channel_scroll + i; + bool sel = (list_idx == _channel_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + ChannelDetails ch; + if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { + display.drawTextEllipsized(8, y, display.width() - 10, ch.name); + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _channel_scroll, _num_channels); + + } else if (_phase == CHANNEL_HIST) { + if (_hist_fullscreen && _hist_sel >= 0) { + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0) { + const char* ftext = _hist[ring_pos].text; + const char* fsep = strstr(ftext, ": "); + char fsender[22], fmsg[79]; + if (fsep) { + int nl = fsep - ftext; if (nl > 21) nl = 21; + strncpy(fsender, ftext, nl); fsender[nl] = '\0'; + strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } else { + strncpy(fsender, "?", sizeof(fsender)); + strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), 10); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, fsender); + display.setColor(DisplayDriver::LIGHT); + + char lines[12][FS_CHARS + 1]; + int lcount = wrapLines(fmsg, lines, 12); + int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; + if (_hist_fs_scroll > max_scroll) _hist_fs_scroll = max_scroll; + for (int i = 0; i < FS_VISIBLE && (_hist_fs_scroll + i) < lcount; i++) { + display.setCursor(0, FS_START_Y + i * FS_LINE_H); + display.print(lines[_hist_fs_scroll + i]); + } + if (_hist_fs_scroll > 0) { + display.setCursor(display.width() - 6, FS_START_Y); + display.print("^"); + } + if (_hist_fs_scroll < max_scroll) { + display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); + display.print("v"); + } + } + return 300; + } + + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); char title[24]; - snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); + snprintf(title, sizeof(title), "#%.21s", ch.name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 9, display.width(), 1); + + int ch_hist_count = histCountForChannel(_sel_channel_idx); + + for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { + int item = _hist_scroll + i; + bool sel = (item == _hist_sel); + int y = HIST_START_Y + i * HIST_ITEM_H; + + int ring_pos = histEntryForChannel(_sel_channel_idx, item); + if (ring_pos < 0) continue; + + const char* text = _hist[ring_pos].text; + const char* sep = strstr(text, ": "); + char sender[22], msg_part[79]; + if (sep) { + int nl = sep - text; if (nl > 21) nl = 21; + strncpy(sender, text, nl); sender[nl] = '\0'; + strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } else { + strncpy(sender, "?", sizeof(sender)); + strncpy(msg_part, text, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), HIST_BOX_H); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + } + } + + if (ch_hist_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 32, "No messages yet"); + } + + // scroll hints + display.setColor(DisplayDriver::LIGHT); + if (_hist_scroll > 0) { + display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.print("^"); + } + if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { + display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + display.print("v"); + } + + // small compose button (bottom-left, always bordered, inverted when selected) + bool compose_sel = (_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1, cby = 55; + if (compose_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); + } + display.setCursor(cbx + 1, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == KEYBOARD) { + // text preview with cursor + display.setColor(DisplayDriver::LIGHT); + const char* disp_start = _kb_text; + int disp_len = _kb_len; + if (disp_len > 20) { disp_start = _kb_text + (disp_len - 20); disp_len = 20; } + char preview[24]; + snprintf(preview, sizeof(preview), "%.*s_", disp_len, disp_start); + display.setCursor(0, KB_TEXT_Y); + display.print(preview); + display.fillRect(0, KB_SEP_Y, display.width(), 1); + + // 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_buf[2] = { KB_CHARS[row][col], '\0' }; + if (ch_buf[0] == ' ') ch_buf[0] = '_'; + int cx = col * KB_CELL_W; + if (sel) { + 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(ch_buf); + } + } + + // special row: [Spc] [Del] [OK] + const char* spec[] = { "[Sp]", "[Del]", "[OK]" }; + for (int i = 0; i < KB_SPECIAL; i++) { + bool sel = (_kb_row == KB_ROWS_CHAR && _kb_col == i); + int sx = i * 43; + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(sx, KB_SPECIAL_Y - 1, 41, KB_CELL_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(sx + 1, KB_SPECIAL_Y); + display.print(spec[i]); + } + + } else { // MSG_PICK + char title[24]; + if (_sending_to_channel) { + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + snprintf(title, sizeof(title), "#%.21s", ch.name); + } else { + snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); + } display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 10, display.width(), 1); - for (int i = 0; i < VISIBLE && (_msg_scroll+i) < QUICK_MSG_COUNT; i++) { + int total_msg_items = QUICK_MSG_COUNT + 1; // +1 for "Custom message..." + for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { int idx = _msg_scroll + i; bool sel = (idx == _msg_sel); int y = START_Y + i * ITEM_H; - display.setColor(sel ? DisplayDriver::YELLOW : DisplayDriver::LIGHT); + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } display.setCursor(0, y); display.print(sel ? ">" : " "); if (idx == 0) { + display.setCursor(8, y); + display.print("Custom message..."); + } else if (idx == 1) { char loc[36]; buildLocMsg(loc, sizeof(loc)); display.drawTextEllipsized(8, y, display.width() - 10, loc); } else { display.setCursor(8, y); - display.print(QUICK_MSG_LABELS[idx]); + display.print(QUICK_MSG_LABELS[idx - 1]); } } - renderScrollHints(display, _msg_scroll, QUICK_MSG_COUNT); + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _msg_scroll, total_msg_items); } return 300; } bool handleInput(char c) override { - if (_phase == CONTACT_PICK) { + if (_phase == MODE_SELECT) { if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } - if ((c == KEY_UP) && _contact_sel > 0) { + if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } + if (c == KEY_DOWN && _mode_sel < 1) { _mode_sel++; return true; } + if (c == KEY_ENTER) { + _phase = (_mode_sel == 0) ? CONTACT_PICK : CHANNEL_PICK; + return true; + } + + } else if (_phase == CONTACT_PICK) { + if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _contact_sel > 0) { _contact_sel--; if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; return true; } - if ((c == KEY_DOWN) && _contact_sel < _num_contacts - 1) { + if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { _contact_sel++; if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; return true; } if (c == KEY_ENTER && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { + _sending_to_channel = false; _phase = MSG_PICK; _msg_sel = _msg_scroll = 0; } return true; } - } else { - if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } - if ((c == KEY_UP) && _msg_sel > 0) { + + } else if (_phase == CHANNEL_PICK) { + if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _channel_sel > 0) { + _channel_sel--; + if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; + return true; + } + if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { + _channel_sel++; + if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _num_channels > 0) { + _sel_channel_idx = _channel_indices[_channel_sel]; + _hist_scroll = 0; + _hist_sel = histCountForChannel(_sel_channel_idx) > 0 ? 0 : -1; + _phase = CHANNEL_HIST; + return true; + } + + } else if (_phase == CHANNEL_HIST) { + int ch_hist_count = histCountForChannel(_sel_channel_idx); + if (_hist_fullscreen) { + if (c == KEY_UP) { if (_hist_fs_scroll > 0) _hist_fs_scroll--; return true; } + if (c == KEY_DOWN) { _hist_fs_scroll++; return true; } + if (c == KEY_ENTER || c == KEY_CANCEL) { _hist_fullscreen = false; return true; } + return true; + } + if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } + if (c == KEY_UP) { + if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } + else if (_hist_sel == 0) _hist_sel = -1; + return true; + } + if (c == KEY_DOWN) { + if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } + else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { + _hist_sel++; + if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; + } + return true; + } + if (c == KEY_ENTER) { + if (_hist_sel >= 0) { + _hist_fullscreen = true; + _hist_fs_scroll = 0; + } else { + _sending_to_channel = true; + _msg_sel = _msg_scroll = 0; + _phase = MSG_PICK; + } + return true; + } + + } else if (_phase == KEYBOARD) { + if (c == KEY_CANCEL) { + _phase = MSG_PICK; + return true; + } + if (c == KEY_UP) { + if (_kb_row > 0) { + _kb_row--; + if (_kb_row == KB_ROWS_CHAR - 1 && _kb_col > KB_COLS_CHAR - 1) + _kb_col = KB_COLS_CHAR - 1; + } + return true; + } + if (c == KEY_DOWN) { + if (_kb_row < KB_ROWS_CHAR) { + int old_row = _kb_row; + _kb_row++; + if (_kb_row == KB_ROWS_CHAR) { + // entering special row: map col proportionally + _kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR; + } else if (old_row == KB_ROWS_CHAR) { + // shouldn't happen but guard anyway + } + } + return true; + } + if (c == KEY_LEFT) { + if (_kb_col > 0) _kb_col--; + return true; + } + if (c == KEY_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 (c == KEY_ENTER) { + if (_kb_row < KB_ROWS_CHAR) { + // regular character + if (_kb_len < KB_MAX_LEN) { + _kb_text[_kb_len++] = KB_CHARS[_kb_row][_kb_col]; + _kb_text[_kb_len] = '\0'; + } + } else { + // special row + if (_kb_col == 0) { + // Space + if (_kb_len < KB_MAX_LEN) { + _kb_text[_kb_len++] = ' '; + _kb_text[_kb_len] = '\0'; + } + } else if (_kb_col == 1) { + // Delete + if (_kb_len > 0) _kb_text[--_kb_len] = '\0'; + } else { + // OK — send + if (_kb_len > 0) { + bool ok = sendText(_kb_text); + afterSend(ok, _kb_text); + } + } + } + return true; + } + + } else { // MSG_PICK + int total_msg_items = QUICK_MSG_COUNT + 1; + if (c == KEY_CANCEL) { + _phase = _sending_to_channel ? CHANNEL_HIST : CONTACT_PICK; + return true; + } + if (c == KEY_UP && _msg_sel > 0) { _msg_sel--; if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; return true; } - if ((c == KEY_DOWN) && _msg_sel < QUICK_MSG_COUNT - 1) { + if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { _msg_sel++; if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; return true; } if (c == KEY_ENTER) { - char msg[80]; if (_msg_sel == 0) { + // Custom message → open keyboard + _kb_row = _kb_col = _kb_len = 0; + _kb_text[0] = '\0'; + _phase = KEYBOARD; + return true; + } + char msg[80]; + if (_msg_sel == 1) { buildLocMsg(msg, sizeof(msg)); } else { - strncpy(msg, QUICK_MSG_LABELS[_msg_sel], sizeof(msg)-1); + strncpy(msg, QUICK_MSG_LABELS[_msg_sel - 1], sizeof(msg)-1); msg[sizeof(msg)-1] = '\0'; } - uint32_t expected_ack = 0, est_timeout = 0; - int result = the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, - msg, expected_ack, est_timeout); - _task->showAlert(result > 0 ? "Sent!" : "Send failed", 1500); - _task->gotoHomeScreen(); + bool ok = sendText(msg); + afterSend(ok, msg); return true; } } @@ -605,7 +1149,7 @@ class HomeScreen : public UIScreen { ? _node_prefs->batt_display_mode : 0; display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); int battLeftX; if (mode == 1) { // percent @@ -631,7 +1175,7 @@ class HomeScreen : public UIScreen { #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { - display.setColor(DisplayDriver::RED); + display.setColor(DisplayDriver::LIGHT); display.drawXbm(battLeftX - 9, 1, muted_icon, 8, 8); } #endif @@ -679,7 +1223,7 @@ public: char tmp[80]; // node name display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); display.setCursor(0, 0); @@ -702,10 +1246,9 @@ public: if (_page == HomePage::CLOCK) { uint32_t unix_ts = _rtc->getCurrentTime(); if (unix_ts < 1000000000UL) { - display.setColor(DisplayDriver::YELLOW); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 25, "No time sync"); display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 25, "! No time sync"); display.drawTextCentered(display.width() / 2, 40, "Enable GPS or"); display.drawTextCentered(display.width() / 2, 51, "connect app"); } else { @@ -715,25 +1258,23 @@ public: struct tm* ti = gmtime(&t); char buf[24]; - display.setColor(DisplayDriver::YELLOW); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); display.drawTextCentered(display.width() / 2, 18, buf); display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); display.drawTextCentered(display.width() / 2, 40, buf); - display.setColor(DisplayDriver::LIGHT); if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); else sprintf(buf, "UTC%d", (int)tz); display.drawTextCentered(display.width() / 2, 54, buf); } } else if (_page == HomePage::FIRST) { - display.setColor(DisplayDriver::YELLOW); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); sprintf(tmp, "MSG: %d", _task->getMsgCount()); display.drawTextCentered(display.width() / 2, 20, tmp); @@ -742,22 +1283,25 @@ public: IPAddress ip = WiFi.localIP(); snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); + display.drawTextCentered(display.width() / 2, 54, tmp); #endif if (_task->hasConnection()) { - display.setColor(DisplayDriver::GREEN); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 43, "< Connected >"); - - } else if (the_mesh.getBLEPin() != 0) { // BT pin - display.setColor(DisplayDriver::RED); + const char* conn_label = "< Connected >"; + int lw = display.getTextWidth(conn_label); + int lx = (display.width() - lw) / 2; + display.fillRect(lx - 2, 41, lw + 4, 10); + display.setColor(DisplayDriver::DARK); + display.drawTextCentered(display.width() / 2, 43, conn_label); + display.setColor(DisplayDriver::LIGHT); + } else if (the_mesh.getBLEPin() != 0) { display.setTextSize(2); sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); display.drawTextCentered(display.width() / 2, 43, tmp); } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); int y = 20; for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { auto a = &recent[i]; @@ -781,7 +1325,7 @@ public: display.print(tmp); } } else if (_page == HomePage::RADIO) { - display.setColor(DisplayDriver::YELLOW); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); // freq / sf display.setCursor(0, 20); @@ -800,14 +1344,14 @@ public: sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.drawXbm((display.width() - 32) / 2, 18, _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 @@ -922,21 +1466,19 @@ public: else sensors_scroll_offset = 0; #endif } else if (_page == HomePage::SETTINGS) { - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Settings"); - display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 38, "brightness, buzzer"); display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); } else if (_page == HomePage::QUICK_MSG) { - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Quick Message"); - display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 38, "send to contact"); display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { - display.setColor(DisplayDriver::GREEN); + display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); if (_shutdown_init) { display.drawTextCentered(display.width() / 2, 34, "hibernating..."); @@ -1015,81 +1557,156 @@ class MsgPreviewScreen : public UIScreen { char origin[62]; char msg[78]; }; - #define MAX_UNREAD_MSGS 32 + #define MAX_UNREAD_MSGS 32 int num_unread; - int head = MAX_UNREAD_MSGS - 1; // index of latest unread message + int head; + int _view_offset; // 0=newest, num_unread-1=oldest + bool _fullscreen; + int _fs_scroll; // line scroll offset in fullscreen MsgEntry unread[MAX_UNREAD_MSGS]; + static const int FS_CHARS = 21; // chars per line at text size 1, 128px wide + static const int FS_LINE_H = 9; + static const int FS_START_Y = 12; + static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; // 5 lines + + // word-wrap msg into lines; returns line count + int wrapLines(const char* text, char out[][FS_CHARS + 1], int max_lines) const { + int count = 0; + const char* p = text; + while (*p && count < max_lines) { + int len = strlen(p); + if (len <= FS_CHARS) { + strncpy(out[count++], p, FS_CHARS); + out[count-1][len] = '\0'; + break; + } + int brk = FS_CHARS; + for (int i = FS_CHARS - 1; i > 0; i--) { + if (p[i] == ' ') { brk = i; break; } + } + strncpy(out[count], p, brk); + out[count++][brk] = '\0'; + p += brk + (p[brk] == ' ' ? 1 : 0); + } + return count; + } + public: - MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; } + MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) + : _task(task), _rtc(rtc), num_unread(0), head(MAX_UNREAD_MSGS - 1), + _view_offset(0), _fullscreen(false), _fs_scroll(0) {} void addPreview(uint8_t path_len, const char* from_name, const char* msg) { head = (head + 1) % MAX_UNREAD_MSGS; if (num_unread < MAX_UNREAD_MSGS) num_unread++; + _view_offset = 0; + _fullscreen = false; auto p = &unread[head]; p->timestamp = _rtc->getCurrentTime(); if (path_len == 0xFF) { sprintf(p->origin, "(D) %s:", from_name); } else { - sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name); + sprintf(p->origin, "(%d) %s:", (uint32_t)path_len, from_name); } StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); } int render(DisplayDriver& display) override { - char tmp[16]; - display.setCursor(0, 0); display.setTextSize(1); - display.setColor(DisplayDriver::GREEN); - sprintf(tmp, "Unread: %d", num_unread); - display.print(tmp); + display.setColor(DisplayDriver::LIGHT); - auto p = &unread[head]; + int msg_idx = (head - _view_offset + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; + auto p = &unread[msg_idx]; - int secs = _rtc->getCurrentTime() - p->timestamp; - if (secs < 60) { - sprintf(tmp, "%ds", secs); - } else if (secs < 60*60) { - sprintf(tmp, "%dm", secs / 60); - } else { - sprintf(tmp, "%dh", secs / (60*60)); - } - display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0); - display.print(tmp); - - display.drawRect(0, 11, display.width(), 1); // horiz line - - display.setCursor(0, 14); - display.setColor(DisplayDriver::YELLOW); char filtered_origin[sizeof(p->origin)]; display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); - display.print(filtered_origin); - - display.setCursor(0, 25); - display.setColor(DisplayDriver::LIGHT); char filtered_msg[sizeof(p->msg)]; display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); - display.printWordWrap(filtered_msg, display.width()); -#if AUTO_OFF_MILLIS==0 // probably e-ink - return 10000; // 10 s + // inverted header: origin + counter + char counter[8]; + snprintf(counter, sizeof(counter), "%d/%d", _view_offset + 1, num_unread); + int counter_w = display.getTextWidth(counter); + display.fillRect(0, 0, display.width(), 10); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - counter_w - 6, filtered_origin); + display.setCursor(display.width() - counter_w - 2, 1); + display.print(counter); + display.setColor(DisplayDriver::LIGHT); + + if (_fullscreen) { + char lines[10][FS_CHARS + 1]; + int line_count = wrapLines(filtered_msg, lines, 10); + int max_scroll = (line_count > FS_VISIBLE) ? line_count - FS_VISIBLE : 0; + if (_fs_scroll > max_scroll) _fs_scroll = max_scroll; + + for (int i = 0; i < FS_VISIBLE && (_fs_scroll + i) < line_count; i++) { + display.setCursor(0, FS_START_Y + i * FS_LINE_H); + display.print(lines[_fs_scroll + i]); + } + if (_fs_scroll > 0) { + display.setCursor(display.width() - 6, FS_START_Y); + display.print("^"); + } + if (_fs_scroll < max_scroll) { + display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); + display.print("v"); + } + } else { + display.setCursor(0, 13); + display.printWordWrap(filtered_msg, display.width()); + + // nav hints + if (_view_offset < num_unread - 1) { + display.setCursor(display.width() - 6, 13); + display.print("^"); + } + if (_view_offset > 0) { + display.setCursor(display.width() - 6, 55); + display.print("v"); + } + + // timestamp + char tmp[10]; + int secs = _rtc->getCurrentTime() - p->timestamp; + if (secs < 60) snprintf(tmp, sizeof(tmp), "%ds", secs); + else if (secs < 3600) snprintf(tmp, sizeof(tmp), "%dm", secs / 60); + else snprintf(tmp, sizeof(tmp), "%dh", secs / 3600); + display.setCursor(0, 56); + display.print(tmp); + } + +#if AUTO_OFF_MILLIS==0 + return 10000; #else - return 1000; // next render after 1000 ms + return 1000; #endif } bool handleInput(char c) override { - if (c == KEY_NEXT || c == KEY_RIGHT) { - head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS; - num_unread--; - if (num_unread == 0) { - _task->gotoHomeScreen(); - } + if (_fullscreen) { + if (c == KEY_UP) { if (_fs_scroll > 0) _fs_scroll--; return true; } + if (c == KEY_DOWN) { _fs_scroll++; return true; } + if (c == KEY_ENTER || c == KEY_CANCEL) { _fullscreen = false; return true; } + return true; + } + if (c == KEY_UP || c == KEY_LEFT) { + if (_view_offset < num_unread - 1) _view_offset++; + return true; + } + if (c == KEY_DOWN || c == KEY_RIGHT) { + if (_view_offset > 0) _view_offset--; return true; } if (c == KEY_ENTER) { - num_unread = 0; // clear unread queue + _fullscreen = true; + _fs_scroll = 0; + return true; + } + if (c == KEY_CANCEL) { + num_unread = 0; _task->gotoHomeScreen(); return true; } @@ -1148,6 +1765,10 @@ void UITask::gotoQuickMsgScreen() { setCurrScreen(quick_msg); } +void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { + ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); +} + void UITask::showAlert(const char* text, int duration_millis) { strcpy(_alert, text); _alert_expiry = millis() + duration_millis; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 0950b202..173a17a5 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -82,6 +82,7 @@ public: void gotoSettingsScreen(); void gotoQuickMsgScreen(); void showAlert(const char* text, int duration_millis); + void addChannelMsg(uint8_t channel_idx, const char* text) override; int getMsgCount() const { return _msgcount; } bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; From 56788e4b584f308d48792357f23cd50021c891d4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 16:52:24 +0200 Subject: [PATCH 020/183] WioTrackerL1 UI: predefined messages, caps lock, placeholder picker, 139-char limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings → Messages section: 10 user-editable message slots (M1–M10) with on-screen keyboard editor; slot M1 defaults to "OK" on first boot - Keyboard: add Caps Lock [^] button — stays highlighted when active, all letters display and insert as uppercase until toggled off - Keyboard: add [{}] placeholder picker overlay — inserts {loc} or {time} into the message at cursor position; available in both compose and settings slot editor - Max message length raised to 139 chars to match the companion app; ChHistEntry text buffer extended to 140 chars - NodePrefs: custom_msgs slot size extended to 140 chars per slot - KB constants (KB_MAX_LEN, KB_CHARS, etc.) moved before SettingsScreen so both SettingsScreen and QuickMsgScreen can reference them Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/NodePrefs.h | 1 + examples/companion_radio/ui-new/UITask.cpp | 627 +++++++++++++++++---- 2 files changed, 508 insertions(+), 120 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index b64102ff..90109739 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -39,4 +39,5 @@ struct NodePrefs { // persisted to file int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0) uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage + char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time}) }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 18e98669..1feba9ae 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -82,20 +82,56 @@ public: } }; +static const int QUICK_MSGS_MAX = 10; + +// On-screen keyboard layout (4 rows × 10 cols) +static const char 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'}, +}; +static const int KB_ROWS_CHAR = 4; +static const int KB_COLS_CHAR = 10; +static const int KB_SPECIAL = 5; // [^] [Sp] [Del] [{}] [OK] +static const char* KB_PH_LIST[] = { "{loc}", "{time}" }; +static const int KB_PH_COUNT = 2; +static const int KB_MAX_LEN = 139; +static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display +static const int KB_CELL_H = 9; +static const int KB_TEXT_Y = 0; +static const int KB_SEP_Y = 9; +static const int KB_CHARS_Y = 11; // first char row +static const int KB_SPECIAL_Y = 48; + class SettingsScreen : public UIScreen { UITask* _task; enum SettingItem { + // Display section + SECTION_DISPLAY, BRIGHTNESS, - BUZZER, - TX_POWER, AUTO_OFF, -#if ENV_INCLUDE_GPS == 1 - GPS_INTERVAL, -#endif + BATT_DISPLAY, + // Sound section + SECTION_SOUND, + BUZZER, + // Radio section + SECTION_RADIO, + TX_POWER, + // System section + SECTION_SYSTEM, TIMEZONE, LOW_BAT, - BATT_DISPLAY, +#if ENV_INCLUDE_GPS == 1 + // GPS section + SECTION_GPS, + GPS_INTERVAL, +#endif + // Messages section + SECTION_MESSAGES, + MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, + MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, Count }; @@ -157,8 +193,59 @@ class SettingsScreen : public UIScreen { } #endif + bool isSection(int item) const { + return item == SECTION_DISPLAY || item == SECTION_SOUND || + item == SECTION_RADIO || item == SECTION_SYSTEM || + item == SECTION_MESSAGES +#if ENV_INCLUDE_GPS == 1 + || item == SECTION_GPS +#endif + ; + } + + const char* sectionName(int item) const { + if (item == SECTION_DISPLAY) return "Display"; + if (item == SECTION_SOUND) return "Sound"; + if (item == SECTION_RADIO) return "Radio"; + if (item == SECTION_SYSTEM) return "System"; + if (item == SECTION_MESSAGES) return "Messages"; +#if ENV_INCLUDE_GPS == 1 + if (item == SECTION_GPS) return "GPS"; +#endif + return ""; + } + + bool isMsgSlot(int item) const { + return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; + } + + int msgSlotIndex(int item) const { + return item - MSG_SLOT_0; + } + + int nextSelectable(int from) const { + for (int i = from + 1; i < (int)Count; i++) + if (!isSection(i)) return i; + return from; + } + + int prevSelectable(int from) const { + for (int i = from - 1; i >= 0; i--) + if (!isSection(i)) return i; + return from; + } + void renderItem(DisplayDriver& display, int item, int y) { NodePrefs* p = _task->getNodePrefs(); + + if (isSection(item)) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), 1); + display.setCursor(2, y + 2); + display.print(sectionName(item)); + return; + } + bool sel = (item == _selected); if (sel) { @@ -217,16 +304,117 @@ class SettingsScreen : public UIScreen { display.setCursor(VAL_X, y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); + } else if (isMsgSlot(item)) { + int slot = msgSlotIndex(item); + char label[5]; + snprintf(label, sizeof(label), "M%d:", slot + 1); + display.print(label); + const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; + display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); } } + // Keyboard state for editing message slots + int _edit_slot; // -1 = not editing, 0..9 = slot being edited + char _edit_buf[KB_MAX_LEN + 1]; + int _edit_len; + int _edit_kb_row, _edit_kb_col; + bool _edit_caps; + bool _edit_ph_mode; + int _edit_ph_sel; + public: - SettingsScreen(UITask* task) : _task(task), _selected(0), _scroll(0), _dirty(false) { } + SettingsScreen(UITask* task) + : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), + _edit_slot(-1), _edit_len(0), _edit_kb_row(0), _edit_kb_col(0), + _edit_caps(false), _edit_ph_mode(false), _edit_ph_sel(0) { + _edit_buf[0] = '\0'; + } void markClean() { _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); + + if (_edit_slot >= 0) { + // Keyboard mode for editing a message slot + display.setColor(DisplayDriver::LIGHT); + // preview line with cursor + const char* disp_start = _edit_buf; + int disp_len = _edit_len; + if (disp_len > 20) { disp_start = _edit_buf + (disp_len - 20); disp_len = 20; } + char preview[26]; + snprintf(preview, sizeof(preview), "M%d:%.*s_", _edit_slot + 1, disp_len, disp_start); + display.setCursor(0, KB_TEXT_Y); + display.print(preview); + display.fillRect(0, KB_SEP_Y, display.width(), 1); + + // 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 = (_edit_kb_row == row && _edit_kb_col == col); + char ch = KB_CHARS[row][col]; + if (_edit_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + char ch_buf[2] = { ch, '\0' }; + if (ch_buf[0] == ' ') ch_buf[0] = '_'; + int cx = col * KB_CELL_W; + if (sel) { + 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(ch_buf); + } + } + + // 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 = (_edit_kb_row == KB_ROWS_CHAR && _edit_kb_col == i); + bool active = (i == 0 && _edit_caps); + int sx = i * 25; + if (sel || active) { + 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 + 1, KB_SPECIAL_Y); + display.print(spec[i]); + display.setColor(DisplayDriver::LIGHT); + } + + // placeholder picker overlay + if (_edit_ph_mode) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + display.setColor(DisplayDriver::DARK); + display.fillRect(20, 20, 88, 10); + display.setCursor(24, 21); + display.print("Placeholder:"); + display.setColor(DisplayDriver::LIGHT); + for (int i = 0; i < KB_PH_COUNT; i++) { + int py = 32 + i * 10; + if (i == _edit_ph_sel) { + display.fillRect(20, py - 1, 88, 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; + } + display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); display.fillRect(0, 10, display.width(), 1); @@ -253,16 +441,109 @@ public: bool handleInput(char c) override { NodePrefs* p = _task->getNodePrefs(); + // Keyboard editing mode for message slots + if (_edit_slot >= 0) { + if (c == KEY_UP) { + if (_edit_kb_row > 0) { + _edit_kb_row--; + if (_edit_kb_row == KB_ROWS_CHAR - 1 && _edit_kb_col > KB_COLS_CHAR - 1) + _edit_kb_col = KB_COLS_CHAR - 1; + } + return true; + } + if (c == KEY_DOWN) { + if (_edit_kb_row < KB_ROWS_CHAR) { + int old_row = _edit_kb_row; + _edit_kb_row++; + if (_edit_kb_row == KB_ROWS_CHAR) { + _edit_kb_col = _edit_kb_col * KB_SPECIAL / KB_COLS_CHAR; + } + (void)old_row; + } + return true; + } + if (c == KEY_LEFT) { + if (_edit_kb_col > 0) _edit_kb_col--; + return true; + } + if (c == KEY_RIGHT) { + int max_col = (_edit_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; + if (_edit_kb_col < max_col) _edit_kb_col++; + return true; + } + if (_edit_ph_mode) { + if (c == KEY_UP && _edit_ph_sel > 0) { _edit_ph_sel--; return true; } + if (c == KEY_DOWN && _edit_ph_sel < KB_PH_COUNT - 1) { _edit_ph_sel++; return true; } + if (c == KEY_ENTER) { + const char* ph = KB_PH_LIST[_edit_ph_sel]; + int ph_len = strlen(ph); + if (_edit_len + ph_len <= KB_MAX_LEN) { + memcpy(_edit_buf + _edit_len, ph, ph_len); + _edit_len += ph_len; + _edit_buf[_edit_len] = '\0'; + } + _edit_ph_mode = false; + return true; + } + if (c == KEY_CANCEL) { _edit_ph_mode = false; return true; } + return true; + } + if (c == KEY_ENTER) { + if (_edit_kb_row < KB_ROWS_CHAR) { + if (_edit_len < KB_MAX_LEN) { + char ch = KB_CHARS[_edit_kb_row][_edit_kb_col]; + if (_edit_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + _edit_buf[_edit_len++] = ch; + _edit_buf[_edit_len] = '\0'; + } + } else { + if (_edit_kb_col == 0) { + // Caps Lock toggle + _edit_caps = !_edit_caps; + } else if (_edit_kb_col == 1) { + // Space + if (_edit_len < KB_MAX_LEN) { + _edit_buf[_edit_len++] = ' '; + _edit_buf[_edit_len] = '\0'; + } + } else if (_edit_kb_col == 2) { + // Del + if (_edit_len > 0) _edit_buf[--_edit_len] = '\0'; + } else if (_edit_kb_col == 3) { + // Placeholder picker + _edit_ph_mode = true; + _edit_ph_sel = 0; + } else { + // OK — save + if (p) { + strncpy(p->custom_msgs[_edit_slot], _edit_buf, sizeof(p->custom_msgs[0]) - 1); + p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; + the_mesh.savePrefs(); + } + _edit_slot = -1; + } + } + return true; + } + if (c == KEY_CANCEL) { + _edit_slot = -1; + return true; + } + return true; + } + if (c == KEY_UP) { - if (_selected > 0) { - _selected--; + int prev = prevSelectable(_selected); + if (prev != _selected) { + _selected = prev; if (_selected < _scroll) _scroll = _selected; } return true; } if (c == KEY_DOWN) { - if (_selected < SettingItem::Count - 1) { - _selected++; + int next = nextSelectable(_selected); + if (next != _selected) { + _selected = next; if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; } return true; @@ -326,6 +607,25 @@ public: if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } } + if (isMsgSlot(_selected) && enter) { + int slot = msgSlotIndex(_selected); + _edit_slot = slot; + _edit_len = 0; + _edit_buf[0] = '\0'; + // Pre-fill with existing text + if (p && p->custom_msgs[slot][0]) { + int existing = strlen(p->custom_msgs[slot]); + if (existing > KB_MAX_LEN) existing = KB_MAX_LEN; + memcpy(_edit_buf, p->custom_msgs[slot], existing); + _edit_buf[existing] = '\0'; + _edit_len = existing; + } + _edit_kb_row = 0; + _edit_kb_col = 0; + _edit_caps = false; + _edit_ph_mode = false; + return true; + } return false; } }; @@ -340,34 +640,6 @@ const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; -static const char* const QUICK_MSG_LABELS[] = { - "My location", - "I'm OK", - "Need help!", - "On my way", - "ETA 10min", - "ETA 30min", -}; -static const int QUICK_MSG_COUNT = 6; - -// On-screen keyboard layout (4 rows × 10 cols) -static const char 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'}, -}; -static const int KB_ROWS_CHAR = 4; -static const int KB_COLS_CHAR = 10; -static const int KB_SPECIAL = 3; // [Spc] [Del] [OK] -static const int KB_MAX_LEN = 100; -static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display -static const int KB_CELL_H = 9; -static const int KB_TEXT_Y = 0; -static const int KB_SEP_Y = 9; -static const int KB_CHARS_Y = 11; // first char row -static const int KB_SPECIAL_Y = 48; - class QuickMsgScreen : public UIScreen { UITask* _task; @@ -387,11 +659,14 @@ class QuickMsgScreen : public UIScreen { int _channel_sel, _channel_scroll; int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; int _sel_channel_idx; bool _sending_to_channel; // MSG_PICK (shared) int _msg_sel, _msg_scroll; + int _active_msgs[QUICK_MSGS_MAX]; + int _active_msg_count; // CHANNEL_HIST int _hist_sel, _hist_scroll; @@ -429,7 +704,10 @@ class QuickMsgScreen : public UIScreen { int _kb_row, _kb_col; char _kb_text[KB_MAX_LEN + 1]; int _kb_len; - struct ChHistEntry { uint8_t ch_idx; char text[80]; }; + bool _kb_caps; + bool _kb_ph_mode; + int _kb_ph_sel; + struct ChHistEntry { uint8_t ch_idx; char text[140]; }; ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; @@ -441,17 +719,58 @@ class QuickMsgScreen : public UIScreen { static const int HIST_BOX_H = 19; static const int HIST_START_Y = 11; - void buildLocMsg(char* buf, int len) { + 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) { #if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - if (loc && loc->isValid()) { - snprintf(buf, len, "Loc: %.5f,%.5f", - loc->getLatitude() / 1000000.0, - loc->getLongitude() / 1000000.0); - return; - } + 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 - snprintf(buf, len, "Loc: no GPS fix"); + 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++; + } + } + out[oi] = '\0'; + } + + void setupMsgPick() { + _msg_sel = _msg_scroll = 0; + _active_msg_count = 0; + NodePrefs* p = _task->getNodePrefs(); + if (p) { + for (int i = 0; i < QUICK_MSGS_MAX; i++) { + if (p->custom_msgs[i][0] != '\0') + _active_msgs[_active_msg_count++] = i; + } + } } void renderScrollHints(DisplayDriver& display, int scroll, int count) { @@ -532,11 +851,15 @@ public: _contact_sel(0), _contact_scroll(0), _num_contacts(0), _channel_sel(0), _channel_scroll(0), _num_channels(0), _sel_channel_idx(0), _sending_to_channel(false), - _msg_sel(0), _msg_scroll(0), + _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _hist_fullscreen(false), _hist_fs_scroll(0), _hist_head(0), _hist_count(0), - _kb_row(0), _kb_col(0), _kb_len(0) { _kb_text[0] = '\0'; } + _kb_row(0), _kb_col(0), _kb_len(0), + _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0) { + _kb_text[0] = '\0'; + memset(_ch_unread, 0, sizeof(_ch_unread)); + } void addChannelMsg(uint8_t ch_idx, const char* text) { int pos; @@ -550,6 +873,11 @@ public: _hist[pos].ch_idx = ch_idx; strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + + if (ch_idx < MAX_GROUP_CHANNELS) { + bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + } } void reset() { @@ -560,6 +888,7 @@ public: _channel_sel = _channel_scroll = 0; _sending_to_channel = false; _kb_row = _kb_col = _kb_len = 0; + _kb_caps = false; _kb_ph_mode = false; _kb_ph_sel = 0; _kb_text[0] = '\0'; // build contact list: chat companions only, favourites first @@ -670,7 +999,18 @@ public: display.print(sel ? ">" : " "); ChannelDetails ch; if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { - display.drawTextEllipsized(8, y, display.width() - 10, ch.name); + uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + char badge[5] = ""; + int bw = 0; + if (unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); + if (unread > 0) { + display.setCursor(display.width() - bw, y); + display.print(badge); + } } } display.setColor(DisplayDriver::LIGHT); @@ -815,7 +1155,9 @@ public: 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_buf[2] = { KB_CHARS[row][col], '\0' }; + char ch = KB_CHARS[row][col]; + if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + char ch_buf[2] = { ch, '\0' }; if (ch_buf[0] == ' ') ch_buf[0] = '_'; int cx = col * KB_CELL_W; if (sel) { @@ -830,20 +1172,45 @@ public: } } - // special row: [Spc] [Del] [OK] - const char* spec[] = { "[Sp]", "[Del]", "[OK]" }; + // 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); - int sx = i * 43; - if (sel) { + bool active = (i == 0 && _kb_caps); // caps indicator + int sx = i * 25; + if (sel || active) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(sx, KB_SPECIAL_Y - 1, 41, KB_CELL_H); + display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } 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::LIGHT); + display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + display.setColor(DisplayDriver::DARK); + display.fillRect(20, 20, 88, 10); + display.setCursor(24, 21); + display.print("Placeholder:"); + display.setColor(DisplayDriver::LIGHT); + for (int i = 0; i < KB_PH_COUNT; i++) { + int py = 32 + i * 10; + if (i == _kb_ph_sel) { + display.fillRect(20, py - 1, 88, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(24, py); + display.print(KB_PH_LIST[i]); + display.setColor(DisplayDriver::LIGHT); + } } } else { // MSG_PICK @@ -858,7 +1225,7 @@ public: display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 10, display.width(), 1); - int total_msg_items = QUICK_MSG_COUNT + 1; // +1 for "Custom message..." + int total_msg_items = 1 + _active_msg_count; for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { int idx = _msg_scroll + i; bool sel = (idx == _msg_sel); @@ -877,13 +1244,11 @@ public: if (idx == 0) { display.setCursor(8, y); display.print("Custom message..."); - } else if (idx == 1) { - char loc[36]; - buildLocMsg(loc, sizeof(loc)); - display.drawTextEllipsized(8, y, display.width() - 10, loc); } else { - display.setCursor(8, y); - display.print(QUICK_MSG_LABELS[idx - 1]); + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[idx - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : ""; + display.drawTextEllipsized(8, y, display.width() - 10, tmpl); } } display.setColor(DisplayDriver::LIGHT); @@ -917,8 +1282,8 @@ public: if (c == KEY_ENTER && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { _sending_to_channel = false; + setupMsgPick(); _phase = MSG_PICK; - _msg_sel = _msg_scroll = 0; } return true; } @@ -937,6 +1302,7 @@ public: } if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; + _ch_unread[_sel_channel_idx] = 0; _hist_scroll = 0; _hist_sel = histCountForChannel(_sel_channel_idx) > 0 ? 0 : -1; _phase = CHANNEL_HIST; @@ -971,13 +1337,30 @@ public: _hist_fs_scroll = 0; } else { _sending_to_channel = true; - _msg_sel = _msg_scroll = 0; + setupMsgPick(); _phase = MSG_PICK; } return true; } } else if (_phase == KEYBOARD) { + 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_MAX_LEN) { + memcpy(_kb_text + _kb_len, ph, ph_len); + _kb_len += ph_len; + _kb_text[_kb_len] = '\0'; + } + _kb_ph_mode = false; + return true; + } + if (c == KEY_CANCEL) { _kb_ph_mode = false; return true; } + return true; + } if (c == KEY_CANCEL) { _phase = MSG_PICK; return true; @@ -995,10 +1378,8 @@ public: int old_row = _kb_row; _kb_row++; if (_kb_row == KB_ROWS_CHAR) { - // entering special row: map col proportionally _kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR; } else if (old_row == KB_ROWS_CHAR) { - // shouldn't happen but guard anyway } } return true; @@ -1014,22 +1395,29 @@ public: } if (c == KEY_ENTER) { if (_kb_row < KB_ROWS_CHAR) { - // regular character if (_kb_len < KB_MAX_LEN) { - _kb_text[_kb_len++] = KB_CHARS[_kb_row][_kb_col]; + char ch = KB_CHARS[_kb_row][_kb_col]; + if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + _kb_text[_kb_len++] = ch; _kb_text[_kb_len] = '\0'; } } else { - // special row if (_kb_col == 0) { + // Caps Lock toggle + _kb_caps = !_kb_caps; + } else if (_kb_col == 1) { // Space if (_kb_len < KB_MAX_LEN) { _kb_text[_kb_len++] = ' '; _kb_text[_kb_len] = '\0'; } - } else if (_kb_col == 1) { + } else if (_kb_col == 2) { // Delete if (_kb_len > 0) _kb_text[--_kb_len] = '\0'; + } else if (_kb_col == 3) { + // Placeholder picker + _kb_ph_mode = true; + _kb_ph_sel = 0; } else { // OK — send if (_kb_len > 0) { @@ -1042,7 +1430,7 @@ public: } } else { // MSG_PICK - int total_msg_items = QUICK_MSG_COUNT + 1; + int total_msg_items = 1 + _active_msg_count; if (c == KEY_CANCEL) { _phase = _sending_to_channel ? CHANNEL_HIST : CONTACT_PICK; return true; @@ -1059,19 +1447,17 @@ public: } if (c == KEY_ENTER) { if (_msg_sel == 0) { - // Custom message → open keyboard _kb_row = _kb_col = _kb_len = 0; + _kb_caps = false; _kb_ph_mode = false; _kb_ph_sel = 0; _kb_text[0] = '\0'; _phase = KEYBOARD; return true; } + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[_msg_sel - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : "OK"; char msg[80]; - if (_msg_sel == 1) { - buildLocMsg(msg, sizeof(msg)); - } else { - strncpy(msg, QUICK_MSG_LABELS[_msg_sel - 1], sizeof(msg)-1); - msg[sizeof(msg)-1] = '\0'; - } + expandMsg(tmpl, msg, sizeof(msg)); bool ok = sendText(msg); afterSend(ok, msg); return true; @@ -1083,7 +1469,6 @@ public: class HomeScreen : public UIScreen { enum HomePage { - FIRST, CLOCK, RECENT, RADIO, @@ -1179,6 +1564,27 @@ class HomeScreen : public UIScreen { display.drawXbm(battLeftX - 9, 1, muted_icon, 8, 8); } #endif + + // BT connection indicator (left of muted/battery icons) + if (_task->isSerialEnabled()) { +#ifdef PIN_BUZZER + int btX = battLeftX - 18; +#else + int btX = battLeftX - 9; +#endif + if (_task->hasConnection()) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(btX - 1, 0, 8, 9); + display.setColor(DisplayDriver::DARK); + display.setCursor(btX, 1); + display.print("B"); + display.setColor(DisplayDriver::LIGHT); + } else { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(btX, 1); + display.print("b"); + } + } } CayenneLPP sensors_lpp; @@ -1273,32 +1679,6 @@ public: else sprintf(buf, "UTC%d", (int)tz); display.drawTextCentered(display.width() / 2, 54, buf); } - } else if (_page == HomePage::FIRST) { - display.setColor(DisplayDriver::LIGHT); - display.setTextSize(2); - sprintf(tmp, "MSG: %d", _task->getMsgCount()); - display.drawTextCentered(display.width() / 2, 20, tmp); - - #ifdef WIFI_SSID - IPAddress ip = WiFi.localIP(); - snprintf(tmp, sizeof(tmp), "IP: %d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); - display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 54, tmp); - #endif - if (_task->hasConnection()) { - display.setTextSize(1); - const char* conn_label = "< Connected >"; - int lw = display.getTextWidth(conn_label); - int lx = (display.width() - lw) / 2; - display.fillRect(lx - 2, 41, lw + 4, 10); - display.setColor(DisplayDriver::DARK); - display.drawTextCentered(display.width() / 2, 43, conn_label); - display.setColor(DisplayDriver::LIGHT); - } else if (the_mesh.getBLEPin() != 0) { - display.setTextSize(2); - sprintf(tmp, "Pin:%d", the_mesh.getBLEPin()); - display.drawTextCentered(display.width() / 2, 43, tmp); - } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(DisplayDriver::LIGHT); @@ -1345,11 +1725,16 @@ public: display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(DisplayDriver::LIGHT); - display.drawXbm((display.width() - 32) / 2, 18, + display.drawXbm((display.width() - 32) / 2, 14, _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 64 - 11, "toggle: " PRESS_LABEL); + if (_task->isSerialEnabled() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) { + char pin_buf[16]; + snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin()); + display.drawTextCentered(display.width() / 2, 49, pin_buf); + } + display.drawTextCentered(display.width() / 2, 57, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { display.setColor(DisplayDriver::LIGHT); display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); @@ -1468,15 +1853,13 @@ public: } else if (_page == HomePage::SETTINGS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 22, "Settings"); - display.drawTextCentered(display.width() / 2, 38, "brightness, buzzer"); - display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, 30, "Settings"); + display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 22, "Quick Message"); - display.drawTextCentered(display.width() / 2, 38, "send to contact"); - display.drawTextCentered(display.width() / 2, 52, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, 30, "Messages"); + display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -1625,16 +2008,15 @@ public: char filtered_msg[sizeof(p->msg)]; display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); - // inverted header: origin + counter + // header: origin + counter, tekst + separator char counter[8]; snprintf(counter, sizeof(counter), "%d/%d", _view_offset + 1, num_unread); int counter_w = display.getTextWidth(counter); - display.fillRect(0, 0, display.width(), 10); - display.setColor(DisplayDriver::DARK); + display.setColor(DisplayDriver::LIGHT); display.drawTextEllipsized(2, 1, display.width() - counter_w - 6, filtered_origin); display.setCursor(display.width() - counter_w - 2, 1); display.print(counter); - display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 10, display.width(), 1); if (_fullscreen) { char lines[10][FS_CHARS + 1]; @@ -1741,6 +2123,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no vibration.begin(); #endif + // Set default quick message if slot 0 is empty (first boot) + if (_node_prefs && _node_prefs->custom_msgs[0][0] == '\0') { + strncpy(_node_prefs->custom_msgs[0], "OK", sizeof(_node_prefs->custom_msgs[0]) - 1); + } + ui_started_at = millis(); _alert_expiry = 0; _batt_mv = AbstractUITask::getBattMilliVolts(); // seed EMA with first reading From aa99b77de4728d4d51151204efa21ec33e801963 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 17:19:03 +0200 Subject: [PATCH 021/183] WioTrackerL1 UI v1.15.1: bugfixes and UX polish - Fix placeholder picker colors: dark box + white border, light fill for selected item (consistent with rest of UI) - Fix placeholders not expanded when sending from keyboard (expandMsg was only called in MSG_PICK path, not KEYBOARD path) - Fix sent messages incrementing unread counter: set _phase=CHANNEL_HIST before addChannelMsg so viewing=true - Fix keyboard column inverse mapping on UP from special row - Remove old_row dead code from QuickMsgScreen keyboard handler - Alert banner shown only on home screen, suppressed in settings/messages - Bump FIRMWARE_VERSION to v1.15.1 Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 96 +++++++++++++--------- 2 files changed, 59 insertions(+), 39 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8b51b6a9..90bc928f 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.0" +#define FIRMWARE_VERSION "v1.15.1" #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 1feba9ae..08002ee4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -391,25 +391,26 @@ public: // placeholder picker overlay if (_edit_ph_mode) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10); display.setColor(DisplayDriver::DARK); - display.fillRect(20, 20, 88, 10); + 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.setColor(DisplayDriver::LIGHT); + display.fillRect(20, 30, 88, 1); for (int i = 0; i < KB_PH_COUNT; i++) { - int py = 32 + i * 10; + int py = 33 + i * 10; if (i == _edit_ph_sel) { - display.fillRect(20, py - 1, 88, 10); + 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); } + display.setColor(DisplayDriver::LIGHT); } return 50; @@ -446,19 +447,16 @@ public: if (c == KEY_UP) { if (_edit_kb_row > 0) { _edit_kb_row--; - if (_edit_kb_row == KB_ROWS_CHAR - 1 && _edit_kb_col > KB_COLS_CHAR - 1) - _edit_kb_col = KB_COLS_CHAR - 1; + if (_edit_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward + _edit_kb_col = _edit_kb_col * KB_COLS_CHAR / KB_SPECIAL; } return true; } if (c == KEY_DOWN) { if (_edit_kb_row < KB_ROWS_CHAR) { - int old_row = _edit_kb_row; _edit_kb_row++; - if (_edit_kb_row == KB_ROWS_CHAR) { + if (_edit_kb_row == KB_ROWS_CHAR) // entering special row _edit_kb_col = _edit_kb_col * KB_SPECIAL / KB_COLS_CHAR; - } - (void)old_row; } return true; } @@ -809,12 +807,12 @@ class QuickMsgScreen : public UIScreen { void afterSend(bool ok, const char* msg) { if (ok && _sending_to_channel) { + _hist_sel = 0; + _hist_scroll = 0; + _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump char entry[sizeof(ChHistEntry::text)]; snprintf(entry, sizeof(entry), "Me: %s", msg); addChannelMsg(_sel_channel_idx, entry); - _hist_sel = 0; - _hist_scroll = 0; - _phase = CHANNEL_HIST; _task->showAlert("Sent!", 600); } else { _task->showAlert(ok ? "Sent!" : "Send failed", 1500); @@ -917,6 +915,7 @@ public: display.drawTextCentered(display.width()/2, 0, "MESSAGE"); display.fillRect(0, 10, display.width(), 1); const char* opts[] = { "Direct message", "Channels" }; + int dm_unread = _task->getMsgCount(); for (int i = 0; i < 2; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _mode_sel); @@ -931,6 +930,14 @@ public: display.print(sel ? ">" : " "); display.setCursor(8, y); display.print(opts[i]); + // DM unread badge on "Direct message" row + if (i == 0 && dm_unread > 0) { + char badge[5]; + snprintf(badge, sizeof(badge), "%d", dm_unread); + int bw = display.getTextWidth(badge) + 2; + display.setCursor(display.width() - bw, y); + display.print(badge); + } } display.setColor(DisplayDriver::LIGHT); @@ -1192,25 +1199,29 @@ public: // placeholder picker overlay if (_kb_ph_mode) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + // Black box with white border display.setColor(DisplayDriver::DARK); - display.fillRect(20, 20, 88, 10); + 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.setColor(DisplayDriver::LIGHT); + display.fillRect(20, 30, 88, 1); // separator for (int i = 0; i < KB_PH_COUNT; i++) { - int py = 32 + i * 10; + int py = 33 + i * 10; if (i == _kb_ph_sel) { - display.fillRect(20, py - 1, 88, 10); + // Selected: white fill + black text + display.setColor(DisplayDriver::LIGHT); + display.fillRect(21, py - 1, 86, 10); display.setColor(DisplayDriver::DARK); } else { + // Unselected: white text on black display.setColor(DisplayDriver::LIGHT); } display.setCursor(24, py); display.print(KB_PH_LIST[i]); - display.setColor(DisplayDriver::LIGHT); } + display.setColor(DisplayDriver::LIGHT); } } else { // MSG_PICK @@ -1368,19 +1379,16 @@ public: if (c == KEY_UP) { if (_kb_row > 0) { _kb_row--; - if (_kb_row == KB_ROWS_CHAR - 1 && _kb_col > KB_COLS_CHAR - 1) - _kb_col = KB_COLS_CHAR - 1; + if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward + _kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL; } return true; } if (c == KEY_DOWN) { if (_kb_row < KB_ROWS_CHAR) { - int old_row = _kb_row; _kb_row++; - if (_kb_row == KB_ROWS_CHAR) { + if (_kb_row == KB_ROWS_CHAR) // entering special row _kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR; - } else if (old_row == KB_ROWS_CHAR) { - } } return true; } @@ -1419,10 +1427,12 @@ public: _kb_ph_mode = true; _kb_ph_sel = 0; } else { - // OK — send + // OK — send (expand placeholders first) if (_kb_len > 0) { - bool ok = sendText(_kb_text); - afterSend(ok, _kb_text); + char expanded[KB_MAX_LEN + 1]; + expandMsg(_kb_text, expanded, sizeof(expanded)); + bool ok = sendText(expanded); + afterSend(ok, expanded); } } } @@ -1859,7 +1869,15 @@ public: display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 30, "Messages"); - display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); + int dm_unread = _task->getMsgCount(); + if (dm_unread > 0) { + char badge[16]; + snprintf(badge, sizeof(badge), "%d unread DM", dm_unread); + display.drawTextCentered(display.width() / 2, 41, badge); + display.drawTextCentered(display.width() / 2, 54, PRESS_LABEL " to open"); + } else { + display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); + } } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -2201,16 +2219,18 @@ void UITask::msgRead(int msgcount) { void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { _msgcount = msgcount; - ((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text); - setCurrScreen(msg_preview); + char alert_buf[80]; + snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); + showAlert(alert_buf, 3000); if (_display != NULL) { if (!_display->isOn() && !hasConnection()) { _display->turnOn(); } if (_display->isOn()) { - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend the auto-off timer - _next_refresh = 100; // trigger refresh + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + _next_refresh = 100; } } } @@ -2371,7 +2391,7 @@ void UITask::loop() { if (millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); - if (millis() < _alert_expiry) { // render alert popup + if (millis() < _alert_expiry && curr == home) { // render alert only on home screen _display->setTextSize(1); int y = _display->height() / 3; int p = _display->height() / 32; From a26bde6ce5a427dbd5a25b9d0ef0180b0f3159c5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 18:56:26 +0200 Subject: [PATCH 022/183] Per-channel notification settings via long-press context menu Long-pressing ENTER on a channel in the channel picker opens a 2-item context menu: "Mark all read" and "Notif: default/OFF/ON". Muted channels still increment the unread counter silently; force-ON overrides global buzzer mute. Settings persist to flash via NodePrefs (ch_notif_override / ch_notif_muted bitmasks). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/NodePrefs.h | 2 + examples/companion_radio/ui-new/UITask.cpp | 112 ++++++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 2 + 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 90109739..d8e4b32e 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -40,4 +40,6 @@ struct NodePrefs { // persisted to file uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time}) + uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting + uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 08002ee4..fac66124 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -705,6 +705,12 @@ class QuickMsgScreen : public UIScreen { bool _kb_caps; bool _kb_ph_mode; int _kb_ph_sel; + + // Context menu (opened by long-press ENTER in CHANNEL_PICK) + bool _ctx_open; + int _ctx_ch_idx; + int _ctx_sel; + struct ChHistEntry { uint8_t ch_idx; char text[140]; }; ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; @@ -843,6 +849,32 @@ class QuickMsgScreen : public UIScreen { } } + // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on + uint8_t chNotifState(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_override & mask)) return 0; + return (p->ch_notif_muted & mask) ? 1 : 2; + } + + void setChNotifState(uint8_t ch_idx, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (state == 0) { + p->ch_notif_override &= ~mask; + p->ch_notif_muted &= ~mask; + } else if (state == 1) { + p->ch_notif_override |= mask; + p->ch_notif_muted |= mask; + } else { + p->ch_notif_override |= mask; + p->ch_notif_muted &= ~mask; + } + the_mesh.savePrefs(); + } + public: QuickMsgScreen(UITask* task) : _task(task), _phase(MODE_SELECT), _mode_sel(0), @@ -854,7 +886,8 @@ public: _hist_fullscreen(false), _hist_fs_scroll(0), _hist_head(0), _hist_count(0), _kb_row(0), _kb_col(0), _kb_len(0), - _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0) { + _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), + _ctx_open(false), _ctx_ch_idx(-1), _ctx_sel(0) { _kb_text[0] = '\0'; memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -1023,6 +1056,38 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _channel_scroll, _num_channels); + // Context menu overlay (long-press ENTER) + if (_ctx_open && _num_channels > 0) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + uint8_t ch_idx = _channel_indices[_channel_sel]; + uint8_t nstate = chNotifState(ch_idx); + char notif_item[22]; + snprintf(notif_item, sizeof(notif_item), "Notif: %s", NOTIF_LABELS[nstate]); + const char* items[] = { "Mark all read", notif_item }; + const int CTX_COUNT = 2; + + display.setColor(DisplayDriver::DARK); + display.fillRect(15, 14, 98, 34); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(15, 14, 98, 34); + display.setCursor(19, 15); + display.print("Channel options"); + display.fillRect(15, 24, 98, 1); + for (int i = 0; i < CTX_COUNT; i++) { + int py = 27 + i * 10; + if (i == _ctx_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(16, py - 1, 96, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(19, py); + display.print(items[i]); + } + display.setColor(DisplayDriver::LIGHT); + } + } else if (_phase == CHANNEL_HIST) { if (_hist_fullscreen && _hist_sel >= 0) { int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); @@ -1300,6 +1365,24 @@ public: } } else if (_phase == CHANNEL_PICK) { + // Context menu consumes all input while open + if (_ctx_open) { + if (c == KEY_CANCEL) { _ctx_open = false; return true; } + if (c == KEY_UP && _ctx_sel > 0) { _ctx_sel--; return true; } + if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } + if (c == KEY_ENTER && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + if (_ctx_sel == 0) { + _ch_unread[ch_idx] = 0; // mark all read + } else { + uint8_t nstate = chNotifState(ch_idx); + setChNotifState(ch_idx, (nstate + 1) % 3); // cycle: default→OFF→ON→default + } + _ctx_open = false; + return true; + } + return true; + } if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } if (c == KEY_UP && _channel_sel > 0) { _channel_sel--; @@ -1319,6 +1402,12 @@ public: _phase = CHANNEL_HIST; return true; } + if (c == KEY_CONTEXT_MENU && _num_channels > 0) { + _ctx_ch_idx = _channel_indices[_channel_sel]; + _ctx_sel = 0; + _ctx_open = true; + return true; + } } else if (_phase == CHANNEL_HIST) { int ch_hist_count = histCountForChannel(_sel_channel_idx); @@ -2171,6 +2260,7 @@ void UITask::gotoQuickMsgScreen() { } void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { + _last_notif_ch_idx = (int)channel_idx; ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); } @@ -2186,9 +2276,22 @@ switch(t){ // gemini's pick buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); break; - case UIEventType::channelMessage: - buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + case UIEventType::channelMessage: { + bool play = true; + if (_last_notif_ch_idx >= 0 && _node_prefs) { + uint64_t mask = 1ULL << _last_notif_ch_idx; + if (_node_prefs->ch_notif_override & mask) { + play = !(_node_prefs->ch_notif_muted & mask); // explicit override + } else { + play = !buzzer.isQuiet(); // follow global + } + } else { + play = !buzzer.isQuiet(); + } + _last_notif_ch_idx = -1; + if (play) buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); break; + } case UIEventType::ack: buzzer.play("ack:d=32,o=8,b=120:c"); break; @@ -2461,8 +2564,9 @@ char UITask::checkDisplayOn(char c) { char UITask::handleLongPress(char c) { if (millis() - ui_started_at < 8000) { // long press in first 8 seconds since startup -> CLI/rescue the_mesh.enterCLIRescue(); - c = 0; // consume event + return 0; } + if (c == KEY_ENTER) return KEY_CONTEXT_MENU; return c; } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 173a17a5..282959d8 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -36,6 +36,7 @@ class UITask : public AbstractUITask { char _alert[80]; unsigned long _alert_expiry; int _msgcount; + int _last_notif_ch_idx; unsigned long ui_started_at, next_batt_chck; uint16_t _batt_mv; // EMA-filtered battery voltage int next_backlight_btn_check = 0; @@ -72,6 +73,7 @@ public: next_batt_chck = _next_refresh = 0; ui_started_at = 0; _batt_mv = 0; + _last_notif_ch_idx = -1; curr = NULL; } void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); From bd07ff60998d8df98948c48ec5f512a0c567d1ff Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 18:58:39 +0200 Subject: [PATCH 023/183] Direct message: show only favourited companions The contact picker now lists only contacts of type ADV_TYPE_CHAT that have the favourite flag set. Shows "No favourites" when none are starred. Asterisk prefix removed since all entries are favourites. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index fac66124..8c9b3167 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -922,19 +922,13 @@ public: _kb_caps = false; _kb_ph_mode = false; _kb_ph_sel = 0; _kb_text[0] = '\0'; - // build contact list: chat companions only, favourites first + // build contact list: favourited chat companions only ContactInfo c; int total = the_mesh.getNumContacts(); - int nfavs = 0; - for (int i = 0; i < total; i++) - if (the_mesh.getContactByIdx(i, c) && c.type == ADV_TYPE_CHAT && (c.flags & 0x01)) nfavs++; - int fpos = 0, npos = nfavs; _num_contacts = 0; for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; - if (c.flags & 0x01) _sorted[fpos++] = i; - else _sorted[npos++] = i; - _num_contacts++; + if (c.flags & 0x01) _sorted[_num_contacts++] = i; } buildChannelList(); @@ -979,7 +973,7 @@ public: display.fillRect(0, 10, display.width(), 1); if (_num_contacts == 0) { - display.drawTextCentered(display.width()/2, 32, "No contacts"); + display.drawTextCentered(display.width()/2, 32, "No favourites"); return 5000; } @@ -999,9 +993,8 @@ public: ContactInfo c; if (the_mesh.getContactByIdx(mesh_idx, c)) { - bool fav = (c.flags & 0x01); display.setCursor(0, y); - display.print(sel ? ">" : (fav ? "*" : " ")); + display.print(sel ? ">" : " "); char filtered[sizeof(c.name)]; display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); display.drawTextEllipsized(8, y, display.width() - 24, filtered); From ef24bb855460bcae909f22f855a0f8c818c44c39 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:05:41 +0200 Subject: [PATCH 024/183] Enable joystick up/down in all WioTrackerL1 companion builds Moved UI_HAS_JOYSTICK_UPDOWN=1 from _settings-only variants to the WioTrackerL1CompanionBLE and WioTrackerL1CompanionUSB base configs. The hardware always had the up/down buttons; the flag was only in _settings as a temporary testing boundary. Co-Authored-By: Claude Sonnet 4.6 --- variants/wio-tracker-l1/platformio.ini | 2 ++ 1 file changed, 2 insertions(+) diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 8b5d94dd..755bddc0 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -65,6 +65,7 @@ build_flags = ${WioTrackerL1.build_flags} -D MAX_GROUP_CHANNELS=40 -D DISPLAY_CLASS=SH1106Display -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 -D OFFLINE_QUEUE_SIZE=256 -D PIN_BUZZER=12 -D QSPIFLASH=1 @@ -105,6 +106,7 @@ build_flags = ${WioTrackerL1.build_flags} -D OFFLINE_QUEUE_SIZE=256 -D DISPLAY_CLASS=SH1106Display -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 -D PIN_BUZZER=12 -D QSPIFLASH=1 -D ADVERT_NAME='"@@MAC"' From 1ba59d2236198e7370ec5f8271ea5da46218c544 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:11:19 +0200 Subject: [PATCH 025/183] Home Messages page: show total unread count (DM + all channels) Sums getMsgCount() (DM unreads) and getTotalChannelUnread() (sum of _ch_unread[] across all channels) and displays "N unread" under the "Messages" heading when non-zero. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 26 ++++++++++++++-------- examples/companion_radio/ui-new/UITask.h | 1 + 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8c9b3167..030852e5 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -911,6 +911,12 @@ public: } } + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + void reset() { _phase = MODE_SELECT; _mode_sel = 0; @@ -1950,16 +1956,14 @@ public: } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 30, "Messages"); - int dm_unread = _task->getMsgCount(); - if (dm_unread > 0) { - char badge[16]; - snprintf(badge, sizeof(badge), "%d unread DM", dm_unread); - display.drawTextCentered(display.width() / 2, 41, badge); - display.drawTextCentered(display.width() / 2, 54, PRESS_LABEL " to open"); - } else { - display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, 22, "Messages"); + int total_unread = _task->getMsgCount() + _task->getChannelUnreadCount(); + if (total_unread > 0) { + char badge[20]; + snprintf(badge, sizeof(badge), "%d unread", total_unread); + display.drawTextCentered(display.width() / 2, 35, badge); } + display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -2257,6 +2261,10 @@ void UITask::addChannelMsg(uint8_t channel_idx, const char* text) { ((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text); } +int UITask::getChannelUnreadCount() const { + return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread(); +} + void UITask::showAlert(const char* text, int duration_millis) { strcpy(_alert, text); _alert_expiry = millis() + duration_millis; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 282959d8..147f44a3 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -86,6 +86,7 @@ public: void showAlert(const char* text, int duration_millis); void addChannelMsg(uint8_t channel_idx, const char* text) override; int getMsgCount() const { return _msgcount; } + int getChannelUnreadCount() const; bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; From 9cbbaa88522780fb69247dd8f6a0f879a43b4b3e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:16:26 +0200 Subject: [PATCH 026/183] Messages: add Room Servers as third option alongside DM and Channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ADV_TYPE_ROOM contacts as a selectable category in the MODE_SELECT screen. Reuses the existing CONTACT_PICK → MSG_PICK → send flow via a _room_mode flag; sendMessage() works identically for room servers. All room server contacts are listed (no favourites filter). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 54 ++++++++++++++-------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 030852e5..97dc3375 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -645,13 +645,14 @@ class QuickMsgScreen : public UIScreen { Phase _phase; // MODE_SELECT - int _mode_sel; // 0=Direct, 1=Channel + int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers // CONTACT_PICK int _contact_sel, _contact_scroll; int _num_contacts; uint8_t _sorted[MAX_CONTACTS]; ContactInfo _sel_contact; + bool _room_mode; // true = picking a room server, false = picking a DM contact // CHANNEL_PICK int _channel_sel, _channel_scroll; @@ -839,6 +840,23 @@ class QuickMsgScreen : public UIScreen { } } + void buildContactList() { + ContactInfo c; + int total = the_mesh.getNumContacts(); + _num_contacts = 0; + if (_room_mode) { + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; + _sorted[_num_contacts++] = i; + } + } else { + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; + if (c.flags & 0x01) _sorted[_num_contacts++] = i; + } + } + } + void buildChannelList() { _num_channels = 0; for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { @@ -878,7 +896,7 @@ class QuickMsgScreen : public UIScreen { public: QuickMsgScreen(UITask* task) : _task(task), _phase(MODE_SELECT), _mode_sel(0), - _contact_sel(0), _contact_scroll(0), _num_contacts(0), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _channel_sel(0), _channel_scroll(0), _num_channels(0), _sel_channel_idx(0), _sending_to_channel(false), _msg_sel(0), _msg_scroll(0), _active_msg_count(0), @@ -928,14 +946,8 @@ public: _kb_caps = false; _kb_ph_mode = false; _kb_ph_sel = 0; _kb_text[0] = '\0'; - // build contact list: favourited chat companions only - ContactInfo c; - int total = the_mesh.getNumContacts(); - _num_contacts = 0; - for (int i = 0; i < total; i++) { - if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; - if (c.flags & 0x01) _sorted[_num_contacts++] = i; - } + _room_mode = false; + buildContactList(); buildChannelList(); } @@ -947,9 +959,9 @@ public: if (_phase == MODE_SELECT) { display.drawTextCentered(display.width()/2, 0, "MESSAGE"); display.fillRect(0, 10, display.width(), 1); - const char* opts[] = { "Direct message", "Channels" }; + const char* opts[] = { "Direct message", "Channels", "Room Servers" }; int dm_unread = _task->getMsgCount(); - for (int i = 0; i < 2; i++) { + for (int i = 0; i < 3; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _mode_sel); if (sel) { @@ -963,7 +975,6 @@ public: display.print(sel ? ">" : " "); display.setCursor(8, y); display.print(opts[i]); - // DM unread badge on "Direct message" row if (i == 0 && dm_unread > 0) { char badge[5]; snprintf(badge, sizeof(badge), "%d", dm_unread); @@ -975,11 +986,11 @@ public: display.setColor(DisplayDriver::LIGHT); } else if (_phase == CONTACT_PICK) { - display.drawTextCentered(display.width()/2, 0, "SELECT CONTACT"); + display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); display.fillRect(0, 10, display.width(), 1); if (_num_contacts == 0) { - display.drawTextCentered(display.width()/2, 32, "No favourites"); + display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); return 5000; } @@ -1336,14 +1347,21 @@ public: if (_phase == MODE_SELECT) { if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } - if (c == KEY_DOWN && _mode_sel < 1) { _mode_sel++; return true; } + if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } if (c == KEY_ENTER) { - _phase = (_mode_sel == 0) ? CONTACT_PICK : CHANNEL_PICK; + if (_mode_sel == 1) { + _phase = CHANNEL_PICK; + } else { + _room_mode = (_mode_sel == 2); + buildContactList(); + _contact_sel = _contact_scroll = 0; + _phase = CONTACT_PICK; + } return true; } } else if (_phase == CONTACT_PICK) { - if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } + if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } if (c == KEY_UP && _contact_sel > 0) { _contact_sel--; if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; From 1a1cda4a1a8f9a30679f727e82b375889f86b011 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:19:44 +0200 Subject: [PATCH 027/183] Settings: contact filter toggles for DM and Room Servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SECTION_CONTACTS with two items under Settings: - DM: "fav" (default) / "all" — controlled by dm_show_all in NodePrefs - Rooms: "all" (default) / "fav" — controlled by room_fav_only in NodePrefs Both fields default to 0, preserving current behaviour on firmware upgrade. buildContactList() reads the prefs to filter accordingly. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/NodePrefs.h | 2 ++ examples/companion_radio/ui-new/UITask.cpp | 30 ++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index d8e4b32e..667eb36d 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -42,4 +42,6 @@ struct NodePrefs { // persisted to file char custom_msgs[10][140]; // user-defined quick messages (supports {loc}, {time}) uint64_t ch_notif_override; // bitmask: bit i = channel i has explicit notification setting uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) + uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts + uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 97dc3375..6fae0418 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -128,6 +128,8 @@ class SettingsScreen : public UIScreen { SECTION_GPS, GPS_INTERVAL, #endif + // Contacts section + SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, // Messages section SECTION_MESSAGES, MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, @@ -196,7 +198,7 @@ class SettingsScreen : public UIScreen { bool isSection(int item) const { return item == SECTION_DISPLAY || item == SECTION_SOUND || item == SECTION_RADIO || item == SECTION_SYSTEM || - item == SECTION_MESSAGES + item == SECTION_CONTACTS || item == SECTION_MESSAGES #if ENV_INCLUDE_GPS == 1 || item == SECTION_GPS #endif @@ -208,6 +210,7 @@ class SettingsScreen : public UIScreen { if (item == SECTION_SOUND) return "Sound"; if (item == SECTION_RADIO) return "Radio"; if (item == SECTION_SYSTEM) return "System"; + if (item == SECTION_CONTACTS) return "Contacts"; if (item == SECTION_MESSAGES) return "Messages"; #if ENV_INCLUDE_GPS == 1 if (item == SECTION_GPS) return "GPS"; @@ -304,6 +307,14 @@ class SettingsScreen : public UIScreen { display.setCursor(VAL_X, y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); + } else if (item == DM_FILTER) { + display.print("DM"); + display.setCursor(VAL_X, y); + display.print((p && p->dm_show_all) ? "all" : "fav"); + } else if (item == ROOM_FILTER) { + display.print("Rooms"); + display.setCursor(VAL_X, y); + display.print((p && p->room_fav_only) ? "fav" : "all"); } else if (isMsgSlot(item)) { int slot = msgSlotIndex(item); char label[5]; @@ -605,6 +616,16 @@ public: if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } } + if (_selected == DM_FILTER && p && (left || right || enter)) { + p->dm_show_all = p->dm_show_all ? 0 : 1; + _dirty = true; + return true; + } + if (_selected == ROOM_FILTER && p && (left || right || enter)) { + p->room_fav_only = p->room_fav_only ? 0 : 1; + _dirty = true; + return true; + } if (isMsgSlot(_selected) && enter) { int slot = msgSlotIndex(_selected); _edit_slot = slot; @@ -841,18 +862,23 @@ class QuickMsgScreen : public UIScreen { } void buildContactList() { + NodePrefs* p = _task->getNodePrefs(); ContactInfo c; int total = the_mesh.getNumContacts(); _num_contacts = 0; if (_room_mode) { + bool fav_only = (p && p->room_fav_only); for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; + if (fav_only && !(c.flags & 0x01)) continue; _sorted[_num_contacts++] = i; } } else { + bool show_all = (p && p->dm_show_all); for (int i = 0; i < total; i++) { if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; - if (c.flags & 0x01) _sorted[_num_contacts++] = i; + if (!show_all && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; } } } From 158bbf74c8080a7352d2a724945465eff24a2c43 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:27:16 +0200 Subject: [PATCH 028/183] Unread badges per category + context menu stays open on action - newMsg() now takes contact_type; room server messages (ADV_TYPE_ROOM) tracked separately in _room_unread; DM badge = msgCount - roomUnread - MODE_SELECT shows unread badges for all 3 rows: DM, Channels, Rooms - Room unread cleared when user opens Room Servers list or msgRead(0) - Channel context menu: ENTER performs action but stays open; only CANCEL (Back button) closes it so user can see the updated state Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 2 +- examples/companion_radio/MyMesh.cpp | 4 ++-- examples/companion_radio/ui-new/UITask.cpp | 19 ++++++++++++++----- examples/companion_radio/ui-new/UITask.h | 6 +++++- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index 18e1f9cb..ecac65d5 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -40,7 +40,7 @@ public: void enableSerial() { _serial->enable(); } void disableSerial() { _serial->disable(); } 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 newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void addChannelMsg(uint8_t channel_idx, const char* text) {} virtual void loop() = 0; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 7adcaf65..d4157789 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -464,7 +464,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; if (should_display && _ui) { - _ui->newMsg(path_len, from.name, text, offline_queue_len); + _ui->newMsg(path_len, from.name, text, offline_queue_len, from.type); if (!_serial->isConnected()) { _ui->notify(UIEventType::contactMessage); } @@ -573,7 +573,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe if (getChannel(channel_idx, channel_details)) { channel_name = channel_details.name; } - if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len); + if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0); #endif } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6fae0418..5d183087 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -986,7 +986,12 @@ public: display.drawTextCentered(display.width()/2, 0, "MESSAGE"); display.fillRect(0, 10, display.width(), 1); const char* opts[] = { "Direct message", "Channels", "Room Servers" }; - int dm_unread = _task->getMsgCount(); + int badges[3] = { + _task->getMsgCount() - _task->getRoomUnreadCount(), // DM only + _task->getChannelUnreadCount(), + _task->getRoomUnreadCount() + }; + if (badges[0] < 0) badges[0] = 0; for (int i = 0; i < 3; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _mode_sel); @@ -1001,9 +1006,9 @@ public: display.print(sel ? ">" : " "); display.setCursor(8, y); display.print(opts[i]); - if (i == 0 && dm_unread > 0) { + if (badges[i] > 0) { char badge[5]; - snprintf(badge, sizeof(badge), "%d", dm_unread); + snprintf(badge, sizeof(badge), "%d", badges[i]); int bw = display.getTextWidth(badge) + 2; display.setCursor(display.width() - bw, y); display.print(badge); @@ -1379,6 +1384,7 @@ public: _phase = CHANNEL_PICK; } else { _room_mode = (_mode_sel == 2); + if (_room_mode) _task->clearRoomUnread(); buildContactList(); _contact_sel = _contact_scroll = 0; _phase = CONTACT_PICK; @@ -1421,7 +1427,7 @@ public: uint8_t nstate = chNotifState(ch_idx); setChNotifState(ch_idx, (nstate + 1) % 3); // cycle: default→OFF→ON→default } - _ctx_open = false; + // stay open so user can see result; CANCEL closes return true; } return true; @@ -2002,6 +2008,7 @@ public: display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Messages"); int total_unread = _task->getMsgCount() + _task->getChannelUnreadCount(); + // getMsgCount() already includes room msgs via offline_queue_len; avoid double-count if (total_unread > 0) { char badge[20]; snprintf(badge, sizeof(badge), "%d unread", total_unread); @@ -2360,12 +2367,14 @@ switch(t){ void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { + _room_unread = 0; gotoHomeScreen(); } } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type) { _msgcount = msgcount; + if (contact_type == ADV_TYPE_ROOM) _room_unread++; char alert_buf[80]; snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 147f44a3..90ab7431 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -36,6 +36,7 @@ class UITask : public AbstractUITask { char _alert[80]; unsigned long _alert_expiry; int _msgcount; + int _room_unread; int _last_notif_ch_idx; unsigned long ui_started_at, next_batt_chck; uint16_t _batt_mv; // EMA-filtered battery voltage @@ -73,6 +74,7 @@ public: next_batt_chck = _next_refresh = 0; ui_started_at = 0; _batt_mv = 0; + _msgcount = _room_unread = 0; _last_notif_ch_idx = -1; curr = NULL; } @@ -87,6 +89,8 @@ public: void addChannelMsg(uint8_t channel_idx, const char* text) override; int getMsgCount() const { return _msgcount; } int getChannelUnreadCount() const; + int getRoomUnreadCount() const { return _room_unread; } + void clearRoomUnread() { _room_unread = 0; } bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; @@ -114,7 +118,7 @@ public: // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; From 4b06dff4e7dd3790f3c3f6f6f9760102ac4b4cf4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:28:37 +0200 Subject: [PATCH 029/183] Channel context menu: defer flash write until CANCEL closes menu savePrefs() is now called once when the user closes the menu with Back/Cancel, only if the notification state was actually changed (_ctx_dirty flag). Toggling multiple times before closing still results in a single write. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5d183087..a22e375b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -730,6 +730,7 @@ class QuickMsgScreen : public UIScreen { // Context menu (opened by long-press ENTER in CHANNEL_PICK) bool _ctx_open; + bool _ctx_dirty; int _ctx_ch_idx; int _ctx_sel; @@ -916,7 +917,6 @@ class QuickMsgScreen : public UIScreen { p->ch_notif_override |= mask; p->ch_notif_muted &= ~mask; } - the_mesh.savePrefs(); } public: @@ -931,7 +931,7 @@ public: _hist_head(0), _hist_count(0), _kb_row(0), _kb_col(0), _kb_len(0), _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), - _ctx_open(false), _ctx_ch_idx(-1), _ctx_sel(0) { + _ctx_open(false), _ctx_dirty(false), _ctx_ch_idx(-1), _ctx_sel(0) { _kb_text[0] = '\0'; memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -1416,18 +1416,22 @@ public: } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open if (_ctx_open) { - if (c == KEY_CANCEL) { _ctx_open = false; return true; } + if (c == KEY_CANCEL) { + if (_ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } + _ctx_open = false; + return true; + } if (c == KEY_UP && _ctx_sel > 0) { _ctx_sel--; return true; } if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } if (c == KEY_ENTER && _num_channels > 0) { uint8_t ch_idx = _channel_indices[_channel_sel]; if (_ctx_sel == 0) { - _ch_unread[ch_idx] = 0; // mark all read + _ch_unread[ch_idx] = 0; } else { uint8_t nstate = chNotifState(ch_idx); - setChNotifState(ch_idx, (nstate + 1) % 3); // cycle: default→OFF→ON→default + setChNotifState(ch_idx, (nstate + 1) % 3); + _ctx_dirty = true; } - // stay open so user can see result; CANCEL closes return true; } return true; @@ -1454,6 +1458,7 @@ public: if (c == KEY_CONTEXT_MENU && _num_channels > 0) { _ctx_ch_idx = _channel_indices[_channel_sel]; _ctx_sel = 0; + _ctx_dirty = false; _ctx_open = true; return true; } From eea92542125694da3777904dba3172c974d2dc30 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 19:34:07 +0200 Subject: [PATCH 030/183] Fix several bugs found in code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DataStore: persist custom_msgs, ch_notif_override/muted, dm_show_all, room_fav_only — none were being saved/loaded, all settings lost on reboot. Load uses file.available() guard for backward compat with old saves. - QuickMsgScreen reset(): clear _ctx_open/_ctx_dirty/_ctx_sel so context menu state is clean when re-entering the messaging screen. - Remove unused _ctx_ch_idx field (code always uses _channel_indices[_channel_sel]). - _room_unread: cap at _msgcount before incrementing to prevent badge going negative (getMsgCount() - getRoomUnreadCount()). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 12 ++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 11 ++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 3ea90016..464516ed 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -238,6 +238,13 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); // 140 file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); // 141 file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); // 143 + if (file.available()) { + file.read((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); // 144 + file.read((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); // 1544 + file.read((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 + file.read((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 + file.read((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 + } file.close(); } @@ -283,6 +290,11 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); // 140 file.write((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); // 141 file.write((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); // 143 + file.write((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); // 144 + file.write((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); // 1544 + file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 + file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 + file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 file.close(); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a22e375b..78fdb64c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -731,7 +731,6 @@ class QuickMsgScreen : public UIScreen { // Context menu (opened by long-press ENTER in CHANNEL_PICK) bool _ctx_open; bool _ctx_dirty; - int _ctx_ch_idx; int _ctx_sel; struct ChHistEntry { uint8_t ch_idx; char text[140]; }; @@ -931,7 +930,7 @@ public: _hist_head(0), _hist_count(0), _kb_row(0), _kb_col(0), _kb_len(0), _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), - _ctx_open(false), _ctx_dirty(false), _ctx_ch_idx(-1), _ctx_sel(0) { + _ctx_open(false), _ctx_dirty(false), _ctx_sel(0) { _kb_text[0] = '\0'; memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -974,8 +973,11 @@ public: _room_mode = false; buildContactList(); - buildChannelList(); + + _ctx_open = false; + _ctx_dirty = false; + _ctx_sel = 0; } int render(DisplayDriver& display) override { @@ -1456,7 +1458,6 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _num_channels > 0) { - _ctx_ch_idx = _channel_indices[_channel_sel]; _ctx_sel = 0; _ctx_dirty = false; _ctx_open = true; @@ -2379,7 +2380,7 @@ void UITask::msgRead(int msgcount) { void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type) { _msgcount = msgcount; - if (contact_type == ADV_TYPE_ROOM) _room_unread++; + if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; char alert_buf[80]; snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); From f603d82b0c2e6c4a0a8c03cf0696981539cc503d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 20:05:44 +0200 Subject: [PATCH 031/183] MsgPreview: fix nav-to-home from chat screen; add fullscreen left/right message switching - msgRead(0) now only navigates to home when curr == msg_preview, preventing unexpected redirects while the user is in the chat selection screen - Fullscreen message view: LEFT/RIGHT navigate between unread messages (resets scroll to top); < > hints shown at bottom corners when more messages available Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 78fdb64c..3e832f18 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2197,6 +2197,14 @@ public: display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); display.print("v"); } + if (_view_offset < num_unread - 1) { + display.setCursor(0, 56); + display.print("<"); + } + if (_view_offset > 0) { + display.setCursor(display.width() - 6, 56); + display.print(">"); + } } else { display.setCursor(0, 13); display.printWordWrap(filtered_msg, display.width()); @@ -2232,6 +2240,14 @@ public: if (_fullscreen) { if (c == KEY_UP) { if (_fs_scroll > 0) _fs_scroll--; return true; } if (c == KEY_DOWN) { _fs_scroll++; return true; } + if (c == KEY_LEFT) { + if (_view_offset < num_unread - 1) { _view_offset++; _fs_scroll = 0; } + return true; + } + if (c == KEY_RIGHT) { + if (_view_offset > 0) { _view_offset--; _fs_scroll = 0; } + return true; + } if (c == KEY_ENTER || c == KEY_CANCEL) { _fullscreen = false; return true; } return true; } @@ -2374,7 +2390,7 @@ void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { _room_unread = 0; - gotoHomeScreen(); + if (curr == msg_preview) gotoHomeScreen(); } } From c90d6a9d3ff14d7fb53f222d3a8b04870793349a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 20:14:52 +0200 Subject: [PATCH 032/183] CI: upload only .uf2 to WioTracker L1 release (drop redundant DFU .zip) Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-wio-tracker-l1-firmwares.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index 28430fa6..195c8dc9 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -39,4 +39,4 @@ jobs: name: Wio Tracker L1 Firmware ${{ env.GIT_TAG_VERSION }} body: "" draft: true - files: out/* + files: out/*.uf2 From 1281ec5c3539e7d1ba4b18adf718b84572722292 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 21:10:36 +0200 Subject: [PATCH 033/183] ChannelHist: lazy unread counter + fullscreen left/right message navigation - Unread badge now clears gradually as the user navigates through messages instead of instantly on channel entry; counter tracks the highest-seen message watermark so navigating back doesn't re-add unread count - Fullscreen view: LEFT/RIGHT switch between messages in the thread (resets scroll); < > arrows shown at bottom corners when adjacent messages exist - Navigating in list (UP/DOWN) or entering fullscreen also marks messages read Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 44 +++++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3e832f18..aeb0ae8c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -692,6 +692,8 @@ class QuickMsgScreen : public UIScreen { int _hist_sel, _hist_scroll; bool _hist_fullscreen; int _hist_fs_scroll; + int _viewing_unread_start; // index of first "unread" message when entering CHANNEL_HIST + int _viewing_max_seen; // highest _hist_sel reached in current session static const int CH_HIST_MAX = 32; static const int FS_CHARS = 21; @@ -927,6 +929,7 @@ public: _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _hist_fullscreen(false), _hist_fs_scroll(0), + _viewing_unread_start(0), _viewing_max_seen(0), _hist_head(0), _hist_count(0), _kb_row(0), _kb_col(0), _kb_len(0), _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), @@ -960,6 +963,16 @@ public: return total; } + void updateChannelUnread() { + if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; + if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; + int hist_count = histCountForChannel(_sel_channel_idx); + int watermark = _viewing_max_seen > (_viewing_unread_start - 1) + ? _viewing_max_seen : (_viewing_unread_start - 1); + int remaining = (hist_count - 1) - watermark; + _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + } + void reset() { _phase = MODE_SELECT; _mode_sel = 0; @@ -978,6 +991,8 @@ public: _ctx_open = false; _ctx_dirty = false; _ctx_sel = 0; + _viewing_unread_start = 0; + _viewing_max_seen = 0; } int render(DisplayDriver& display) override { @@ -1133,6 +1148,7 @@ public: } else if (_phase == CHANNEL_HIST) { if (_hist_fullscreen && _hist_sel >= 0) { + int fs_hist_count = histCountForChannel(_sel_channel_idx); int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) { const char* ftext = _hist[ring_pos].text; @@ -1168,6 +1184,14 @@ public: display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); display.print("v"); } + if (_hist_sel > 0) { + display.setCursor(0, 56); + display.print("<"); + } + if (_hist_sel < fs_hist_count - 1) { + display.setCursor(display.width() - 6, 56); + display.print(">"); + } } return 300; } @@ -1451,10 +1475,14 @@ public: } if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; - _ch_unread[_sel_channel_idx] = 0; + int hc = histCountForChannel(_sel_channel_idx); + _viewing_unread_start = hc - (int)_ch_unread[_sel_channel_idx]; + if (_viewing_unread_start < 0) _viewing_unread_start = 0; _hist_scroll = 0; - _hist_sel = histCountForChannel(_sel_channel_idx) > 0 ? 0 : -1; + _hist_sel = hc > 0 ? 0 : -1; + _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; _phase = CHANNEL_HIST; + updateChannelUnread(); return true; } if (c == KEY_CONTEXT_MENU && _num_channels > 0) { @@ -1469,6 +1497,15 @@ public: if (_hist_fullscreen) { if (c == KEY_UP) { if (_hist_fs_scroll > 0) _hist_fs_scroll--; return true; } if (c == KEY_DOWN) { _hist_fs_scroll++; return true; } + if (c == KEY_LEFT) { + if (_hist_sel > 0) { _hist_sel--; _hist_fs_scroll = 0; updateChannelUnread(); } + return true; + } + if (c == KEY_RIGHT) { + int count = histCountForChannel(_sel_channel_idx); + if (_hist_sel < count - 1) { _hist_sel++; _hist_fs_scroll = 0; updateChannelUnread(); } + return true; + } if (c == KEY_ENTER || c == KEY_CANCEL) { _hist_fullscreen = false; return true; } return true; } @@ -1476,6 +1513,7 @@ public: if (c == KEY_UP) { if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } else if (_hist_sel == 0) _hist_sel = -1; + updateChannelUnread(); return true; } if (c == KEY_DOWN) { @@ -1484,12 +1522,14 @@ public: _hist_sel++; if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; } + updateChannelUnread(); return true; } if (c == KEY_ENTER) { if (_hist_sel >= 0) { _hist_fullscreen = true; _hist_fs_scroll = 0; + updateChannelUnread(); } else { _sending_to_channel = true; setupMsgPick(); From 9d92fb62df2c2c458f08a48e5233cd9aa330ebd7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 21:17:31 +0200 Subject: [PATCH 034/183] Buzzer: add playForced(); fix per-channel force-on notification ignoring global mute buzzer.play() was returning early when _is_quiet=true, so channels with explicit notification=ON were silent even though the logic correctly set play=true. playForced() bypasses the quiet check for these overrides. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 13 +++++++++---- src/helpers/ui/buzzer.cpp | 14 +++++++------- src/helpers/ui/buzzer.h | 3 ++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index aeb0ae8c..e4ee3b02 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2391,19 +2391,24 @@ switch(t){ buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); break; case UIEventType::channelMessage: { - bool play = true; + bool play = false; + bool force = false; if (_last_notif_ch_idx >= 0 && _node_prefs) { uint64_t mask = 1ULL << _last_notif_ch_idx; if (_node_prefs->ch_notif_override & mask) { - play = !(_node_prefs->ch_notif_muted & mask); // explicit override + if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } // state 2: force-on + // state 1: muted — play stays false } else { - play = !buzzer.isQuiet(); // follow global + play = !buzzer.isQuiet(); // state 0: follow global } } else { play = !buzzer.isQuiet(); } _last_notif_ch_idx = -1; - if (play) buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + if (play) { + if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#"); + else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + } break; } case UIEventType::ack: diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 29fb3e26..983e5c93 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -15,18 +15,18 @@ void genericBuzzer::begin() { } void genericBuzzer::play(const char *melody) { - if (isPlaying()) // interrupt existing - { - rtttl::stop(); - } - + if (isPlaying()) rtttl::stop(); if (_is_quiet) return; - - rtttl::begin(PIN_BUZZER,melody); + rtttl::begin(PIN_BUZZER, melody); // Serial.print("DBG: Playing melody - isQuiet: "); // Serial.println(isQuiet()); } +void genericBuzzer::playForced(const char *melody) { + if (isPlaying()) rtttl::stop(); + rtttl::begin(PIN_BUZZER, melody); +} + bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); } diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 0a500552..ae040814 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -20,7 +20,8 @@ class genericBuzzer { public: void begin(); // set up buzzer port - void play(const char *melody); // Generic play function + void play(const char *melody); + void playForced(const char *melody); // play regardless of quiet state void loop(); // loop driven-nonblocking void startup(); // play startup sound void shutdown(); // play shutdown sound From 40f470ebf2e2147e9cd484e1aff2eb42e4c05aeb Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 11 May 2026 21:33:49 +0200 Subject: [PATCH 035/183] Sensors page: show placeholders for all connected sensors; support all LPP types - SensorManager::getAvailableLPPTypes() reports which LPP types will be present based on initialized sensor flags (temp, humidity, pressure, etc.) - Sensors page renders one row per available type; shows "--" for any sensor that hasn't produced a reading yet instead of hiding the row entirely - LPPReader: add readLuminosity, readPercentage, readConcentration, readDistance - All known LPP types now have proper names and units in the sensor display Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 117 +++++++++--------- src/helpers/SensorManager.h | 1 + .../sensors/EnvironmentSensorManager.cpp | 40 ++++++ .../sensors/EnvironmentSensorManager.h | 1 + src/helpers/sensors/LPPDataHelpers.h | 16 +++ 5 files changed, 117 insertions(+), 58 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index e4ee3b02..ac6dc39a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1976,72 +1976,73 @@ public: } else if (_page == HomePage::SENSORS) { int y = 18; refresh_sensors(); - char buf[30]; - char name[30]; - LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); - for (int i = 0; i < sensors_scroll_offset; i++) { - uint8_t channel, type; - r.readHeader(channel, type); - r.skipData(type); - } + uint8_t avail_types[16]; + int avail_count = _sensors ? _sensors->getAvailableLPPTypes(avail_types, 16) : 0; + bool need_scroll = avail_count > UI_RECENT_LIST_SIZE; + int offset = need_scroll ? (sensors_scroll_offset % avail_count) : 0; + int show_n = need_scroll ? UI_RECENT_LIST_SIZE : avail_count; - for (int i = 0; i < (sensors_scroll?UI_RECENT_LIST_SIZE:sensors_nb); i++) { - uint8_t channel, type; - if (!r.readHeader(channel, type)) { // reached end, reset - r.reset(); - r.readHeader(channel, type); + for (int i = 0; i < show_n; i++) { + uint8_t target = avail_types[(offset + i) % avail_count]; + + // scan LPP buffer for this type + LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); + uint8_t ch, type; + bool found = false; + char buf[22] = "--"; + while (r.readHeader(ch, type)) { + if (type == target) { + float v, v2, v3; + switch (type) { + case LPP_GPS: + r.readGPS(v, v2, v3); + if (v != 0 || v2 != 0) snprintf(buf, sizeof(buf), "%.4f %.4f", v, v2); + break; + case LPP_VOLTAGE: r.readVoltage(v); snprintf(buf, sizeof(buf), "%.2fV", v); break; + case LPP_CURRENT: r.readCurrent(v); snprintf(buf, sizeof(buf), "%.3fA", v); break; + case LPP_POWER: r.readPower(v); snprintf(buf, sizeof(buf), "%.1fW", v); break; + case LPP_TEMPERATURE:r.readTemperature(v); snprintf(buf, sizeof(buf), "%.1f\xf8""C", v); break; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(buf, sizeof(buf), "%.1fhPa", v); break; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(buf, sizeof(buf), "%.0fm", v); break; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(buf, sizeof(buf), "%.0flux", v); break; + case LPP_PERCENTAGE: r.readPercentage(v); snprintf(buf, sizeof(buf), "%.0f%%", v); break; + case LPP_DISTANCE: r.readDistance(v); snprintf(buf, sizeof(buf), "%.2fm", v); break; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(buf, sizeof(buf), "%.0fppm", v); break; + default: r.skipData(type); continue; + } + found = true; + break; + } + r.skipData(type); } + (void)found; + + static const struct { uint8_t type; const char* name; } TYPE_NAMES[] = { + { LPP_VOLTAGE, "voltage" }, + { LPP_GPS, "gps" }, + { LPP_TEMPERATURE, "temp" }, + { LPP_RELATIVE_HUMIDITY, "humidity" }, + { LPP_BAROMETRIC_PRESSURE,"pressure" }, + { LPP_ALTITUDE, "altitude" }, + { LPP_CURRENT, "current" }, + { LPP_POWER, "power" }, + { LPP_LUMINOSITY, "light" }, + { LPP_PERCENTAGE, "moisture" }, + { LPP_DISTANCE, "distance" }, + { LPP_CONCENTRATION, "CO2" }, + }; + const char* name = "sensor"; + for (auto& tn : TYPE_NAMES) { if (tn.type == target) { name = tn.name; break; } } - display.setCursor(0, y); - float v; - switch (type) { - case LPP_GPS: // GPS - float lat, lon, alt; - r.readGPS(lat, lon, alt); - strcpy(name, "gps"); sprintf(buf, "%.4f %.4f", lat, lon); - break; - case LPP_VOLTAGE: - r.readVoltage(v); - strcpy(name, "voltage"); sprintf(buf, "%6.2f", v); - break; - case LPP_CURRENT: - r.readCurrent(v); - strcpy(name, "current"); sprintf(buf, "%.3f", v); - break; - case LPP_TEMPERATURE: - r.readTemperature(v); - strcpy(name, "temperature"); sprintf(buf, "%.2f", v); - break; - case LPP_RELATIVE_HUMIDITY: - r.readRelativeHumidity(v); - strcpy(name, "humidity"); sprintf(buf, "%.2f", v); - break; - case LPP_BAROMETRIC_PRESSURE: - r.readPressure(v); - strcpy(name, "pressure"); sprintf(buf, "%.2f", v); - break; - case LPP_ALTITUDE: - r.readAltitude(v); - strcpy(name, "altitude"); sprintf(buf, "%.0f", v); - break; - case LPP_POWER: - r.readPower(v); - strcpy(name, "power"); sprintf(buf, "%6.2f", v); - break; - default: - r.skipData(type); - strcpy(name, "unk"); sprintf(buf, ""); - } display.setCursor(0, y); display.print(name); - display.setCursor( - display.width()-display.getTextWidth(buf)-1, y - ); + display.setCursor(display.width() - display.getTextWidth(buf) - 1, y); display.print(buf); - y = y + 12; + y += 12; } - if (sensors_scroll) sensors_scroll_offset = (sensors_scroll_offset+1)%sensors_nb; + if (need_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % avail_count; else sensors_scroll_offset = 0; #endif } else if (_page == HomePage::SETTINGS) { diff --git a/src/helpers/SensorManager.h b/src/helpers/SensorManager.h index 89a174c2..a1ca4306 100644 --- a/src/helpers/SensorManager.h +++ b/src/helpers/SensorManager.h @@ -23,6 +23,7 @@ public: virtual const char* getSettingValue(int i) const { return NULL; } virtual bool setSettingValue(const char* name, const char* value) { return false; } virtual LocationProvider* getLocationProvider() { return NULL; } + virtual int getAvailableLPPTypes(uint8_t* types, int max_count) const { return 0; } // Helper functions to manage setting by keys (useful in many places ...) const char* getSettingByKey(const char* key) { diff --git a/src/helpers/sensors/EnvironmentSensorManager.cpp b/src/helpers/sensors/EnvironmentSensorManager.cpp index 19472406..3e421944 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.cpp +++ b/src/helpers/sensors/EnvironmentSensorManager.cpp @@ -540,6 +540,46 @@ bool EnvironmentSensorManager::querySensors(uint8_t requester_permissions, Cayen } +int EnvironmentSensorManager::getAvailableLPPTypes(uint8_t* types, int max_count) const { + int n = 0; + auto add = [&](uint8_t t) { if (n < max_count) types[n++] = t; }; + + add(LPP_VOLTAGE); // battery voltage — always present + +#if ENV_INCLUDE_GPS + add(LPP_GPS); +#endif + + bool has_temp = AHTX0_initialized || BME680_initialized || BME280_initialized || + BMP280_initialized || SHTC3_initialized || SHT4X_initialized || + LPS22HB_initialized || MLX90614_initialized || BMP085_initialized || + RAK12035_initialized; + if (has_temp) add(LPP_TEMPERATURE); + + bool has_hum = AHTX0_initialized || BME680_initialized || BME280_initialized || + SHTC3_initialized || SHT4X_initialized || RAK12035_initialized; + if (has_hum) add(LPP_RELATIVE_HUMIDITY); + + bool has_press = BME680_initialized || LPS22HB_initialized; + if (has_press) add(LPP_BAROMETRIC_PRESSURE); + + bool has_alt = BME680_initialized || BME280_initialized || BMP280_initialized || BMP085_initialized; + if (has_alt) add(LPP_ALTITUDE); + + bool has_power = INA3221_initialized || INA219_initialized || INA260_initialized || INA226_initialized; + if (has_power) { add(LPP_CURRENT); add(LPP_POWER); } + +#if ENV_INCLUDE_VL53L0X + if (VL53L0X_initialized) add(LPP_DISTANCE); +#endif + +#if ENV_INCLUDE_RAK12035 + if (RAK12035_initialized) add(LPP_PERCENTAGE); +#endif + + return n; +} + int EnvironmentSensorManager::getNumSettings() const { int settings = 0; #if ENV_INCLUDE_GPS diff --git a/src/helpers/sensors/EnvironmentSensorManager.h b/src/helpers/sensors/EnvironmentSensorManager.h index 32413ebc..dea329ad 100644 --- a/src/helpers/sensors/EnvironmentSensorManager.h +++ b/src/helpers/sensors/EnvironmentSensorManager.h @@ -49,6 +49,7 @@ public: #endif bool begin() override; bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + int getAvailableLPPTypes(uint8_t* types, int max_count) const override; #if ENV_INCLUDE_GPS void loop() override; #endif diff --git a/src/helpers/sensors/LPPDataHelpers.h b/src/helpers/sensors/LPPDataHelpers.h index 37b50f3f..964395da 100644 --- a/src/helpers/sensors/LPPDataHelpers.h +++ b/src/helpers/sensors/LPPDataHelpers.h @@ -136,6 +136,22 @@ public: m = getFloat(&_buf[_pos], 2, 1, true); _pos += 2; return _pos <= _len; } + bool readLuminosity(float& lux) { + lux = getFloat(&_buf[_pos], 2, 1, false); _pos += 2; + return _pos <= _len; + } + bool readPercentage(float& pct) { + pct = getFloat(&_buf[_pos], 1, 1, false); _pos += 1; + return _pos <= _len; + } + bool readConcentration(float& ppm) { + ppm = getFloat(&_buf[_pos], 2, 1, false); _pos += 2; + return _pos <= _len; + } + bool readDistance(float& m) { + m = getFloat(&_buf[_pos], 4, 1000, false); _pos += 4; + return _pos <= _len; + } void skipData(uint8_t type) { switch (type) { From 861a65d4b1266d8cd946d81d85fa11981d5d7fd3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 08:19:27 +0200 Subject: [PATCH 036/183] Fix buzzer notifications, unread tracking, OLED dimming; remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyMesh: notify() now always fires for channel/contact messages regardless of serial connection; called after addChannelMsg so channel index is set - ChannelHist: fix unread watermark direction (histEntry is newest-first, index 0=newest); counter now decrements correctly as user reads messages - ChannelHist: reset unread to 0 after sending (user is active in channel) - SH1106Display: add pre-charge register (0xD9) to setBrightness for wider dimming range; re-apply contrast+precharge in turnOn(); tune curve - UITask: remove MsgPreviewScreen dead code (~180 lines, never shown) - UITask: fix strcpy→snprintf in showAlert() Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 9 +- examples/companion_radio/ui-new/UITask.cpp | 207 ++------------------- examples/companion_radio/ui-new/UITask.h | 1 - src/helpers/ui/SH1106Display.cpp | 19 +- src/helpers/ui/SH1106Display.h | 4 +- 5 files changed, 33 insertions(+), 207 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d4157789..2344a18d 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -465,9 +465,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; if (should_display && _ui) { _ui->newMsg(path_len, from.name, text, offline_queue_len, from.type); - if (!_serial->isConnected()) { - _ui->notify(UIEventType::contactMessage); - } + _ui->notify(UIEventType::contactMessage); } #endif } @@ -561,13 +559,10 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe uint8_t frame[1]; frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle' _serial->writeFrame(frame, 1); - } else { -#ifdef DISPLAY_CLASS - if (_ui) _ui->notify(UIEventType::channelMessage); -#endif } #ifdef DISPLAY_CLASS if (_ui) _ui->addChannelMsg(channel_idx, text); + if (_ui) _ui->notify(UIEventType::channelMessage); const char *channel_name = "Unknown"; ChannelDetails channel_details; if (getChannel(channel_idx, channel_details)) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ac6dc39a..c4d9df9c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -692,8 +692,8 @@ class QuickMsgScreen : public UIScreen { int _hist_sel, _hist_scroll; bool _hist_fullscreen; int _hist_fs_scroll; - int _viewing_unread_start; // index of first "unread" message when entering CHANNEL_HIST - int _viewing_max_seen; // highest _hist_sel reached in current session + int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _viewing_max_seen; // highest _hist_sel reached in current session static const int CH_HIST_MAX = 32; static const int FS_CHARS = 21; @@ -843,6 +843,11 @@ class QuickMsgScreen : public UIScreen { char entry[sizeof(ChHistEntry::text)]; snprintf(entry, sizeof(entry), "Me: %s", msg); addChannelMsg(_sel_channel_idx, entry); + // After inserting sent msg at index 0, the unread index range is stale. + // User is active in this channel — treat as fully read. + _ch_unread[_sel_channel_idx] = 0; + _unread_at_entry = 0; + _viewing_max_seen = 0; _task->showAlert("Sent!", 600); } else { _task->showAlert(ok ? "Sent!" : "Send failed", 1500); @@ -929,7 +934,7 @@ public: _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), _hist_fullscreen(false), _hist_fs_scroll(0), - _viewing_unread_start(0), _viewing_max_seen(0), + _unread_at_entry(0), _viewing_max_seen(0), _hist_head(0), _hist_count(0), _kb_row(0), _kb_col(0), _kb_len(0), _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), @@ -966,10 +971,9 @@ public: void updateChannelUnread() { if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; - int hist_count = histCountForChannel(_sel_channel_idx); - int watermark = _viewing_max_seen > (_viewing_unread_start - 1) - ? _viewing_max_seen : (_viewing_unread_start - 1); - int remaining = (hist_count - 1) - watermark; + // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. + // Each step down from 0 sees one more message; seen count = max_seen + 1. + int remaining = _unread_at_entry - (_viewing_max_seen + 1); _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); } @@ -991,7 +995,7 @@ public: _ctx_open = false; _ctx_dirty = false; _ctx_sel = 0; - _viewing_unread_start = 0; + _unread_at_entry = 0; _viewing_max_seen = 0; } @@ -1476,8 +1480,7 @@ public: if (c == KEY_ENTER && _num_channels > 0) { _sel_channel_idx = _channel_indices[_channel_sel]; int hc = histCountForChannel(_sel_channel_idx); - _viewing_unread_start = hc - (int)_ch_unread[_sel_channel_idx]; - if (_viewing_unread_start < 0) _viewing_unread_start = 0; + _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; _hist_scroll = 0; _hist_sel = hc > 0 ? 0 : -1; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; @@ -2133,186 +2136,6 @@ public: } }; -class MsgPreviewScreen : public UIScreen { - UITask* _task; - mesh::RTCClock* _rtc; - - struct MsgEntry { - uint32_t timestamp; - char origin[62]; - char msg[78]; - }; - #define MAX_UNREAD_MSGS 32 - int num_unread; - int head; - int _view_offset; // 0=newest, num_unread-1=oldest - bool _fullscreen; - int _fs_scroll; // line scroll offset in fullscreen - MsgEntry unread[MAX_UNREAD_MSGS]; - - static const int FS_CHARS = 21; // chars per line at text size 1, 128px wide - static const int FS_LINE_H = 9; - static const int FS_START_Y = 12; - static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; // 5 lines - - // word-wrap msg into lines; returns line count - int wrapLines(const char* text, char out[][FS_CHARS + 1], int max_lines) const { - int count = 0; - const char* p = text; - while (*p && count < max_lines) { - int len = strlen(p); - if (len <= FS_CHARS) { - strncpy(out[count++], p, FS_CHARS); - out[count-1][len] = '\0'; - break; - } - int brk = FS_CHARS; - for (int i = FS_CHARS - 1; i > 0; i--) { - if (p[i] == ' ') { brk = i; break; } - } - strncpy(out[count], p, brk); - out[count++][brk] = '\0'; - p += brk + (p[brk] == ' ' ? 1 : 0); - } - return count; - } - -public: - MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) - : _task(task), _rtc(rtc), num_unread(0), head(MAX_UNREAD_MSGS - 1), - _view_offset(0), _fullscreen(false), _fs_scroll(0) {} - - void addPreview(uint8_t path_len, const char* from_name, const char* msg) { - head = (head + 1) % MAX_UNREAD_MSGS; - if (num_unread < MAX_UNREAD_MSGS) num_unread++; - _view_offset = 0; - _fullscreen = false; - - auto p = &unread[head]; - p->timestamp = _rtc->getCurrentTime(); - if (path_len == 0xFF) { - sprintf(p->origin, "(D) %s:", from_name); - } else { - sprintf(p->origin, "(%d) %s:", (uint32_t)path_len, from_name); - } - StrHelper::strncpy(p->msg, msg, sizeof(p->msg)); - } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - - int msg_idx = (head - _view_offset + MAX_UNREAD_MSGS) % MAX_UNREAD_MSGS; - auto p = &unread[msg_idx]; - - char filtered_origin[sizeof(p->origin)]; - display.translateUTF8ToBlocks(filtered_origin, p->origin, sizeof(filtered_origin)); - char filtered_msg[sizeof(p->msg)]; - display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg)); - - // header: origin + counter, tekst + separator - char counter[8]; - snprintf(counter, sizeof(counter), "%d/%d", _view_offset + 1, num_unread); - int counter_w = display.getTextWidth(counter); - display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(2, 1, display.width() - counter_w - 6, filtered_origin); - display.setCursor(display.width() - counter_w - 2, 1); - display.print(counter); - display.fillRect(0, 10, display.width(), 1); - - if (_fullscreen) { - char lines[10][FS_CHARS + 1]; - int line_count = wrapLines(filtered_msg, lines, 10); - int max_scroll = (line_count > FS_VISIBLE) ? line_count - FS_VISIBLE : 0; - if (_fs_scroll > max_scroll) _fs_scroll = max_scroll; - - for (int i = 0; i < FS_VISIBLE && (_fs_scroll + i) < line_count; i++) { - display.setCursor(0, FS_START_Y + i * FS_LINE_H); - display.print(lines[_fs_scroll + i]); - } - if (_fs_scroll > 0) { - display.setCursor(display.width() - 6, FS_START_Y); - display.print("^"); - } - if (_fs_scroll < max_scroll) { - display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); - display.print("v"); - } - if (_view_offset < num_unread - 1) { - display.setCursor(0, 56); - display.print("<"); - } - if (_view_offset > 0) { - display.setCursor(display.width() - 6, 56); - display.print(">"); - } - } else { - display.setCursor(0, 13); - display.printWordWrap(filtered_msg, display.width()); - - // nav hints - if (_view_offset < num_unread - 1) { - display.setCursor(display.width() - 6, 13); - display.print("^"); - } - if (_view_offset > 0) { - display.setCursor(display.width() - 6, 55); - display.print("v"); - } - - // timestamp - char tmp[10]; - int secs = _rtc->getCurrentTime() - p->timestamp; - if (secs < 60) snprintf(tmp, sizeof(tmp), "%ds", secs); - else if (secs < 3600) snprintf(tmp, sizeof(tmp), "%dm", secs / 60); - else snprintf(tmp, sizeof(tmp), "%dh", secs / 3600); - display.setCursor(0, 56); - display.print(tmp); - } - -#if AUTO_OFF_MILLIS==0 - return 10000; -#else - return 1000; -#endif - } - - bool handleInput(char c) override { - if (_fullscreen) { - if (c == KEY_UP) { if (_fs_scroll > 0) _fs_scroll--; return true; } - if (c == KEY_DOWN) { _fs_scroll++; return true; } - if (c == KEY_LEFT) { - if (_view_offset < num_unread - 1) { _view_offset++; _fs_scroll = 0; } - return true; - } - if (c == KEY_RIGHT) { - if (_view_offset > 0) { _view_offset--; _fs_scroll = 0; } - return true; - } - if (c == KEY_ENTER || c == KEY_CANCEL) { _fullscreen = false; return true; } - return true; - } - if (c == KEY_UP || c == KEY_LEFT) { - if (_view_offset < num_unread - 1) _view_offset++; - return true; - } - if (c == KEY_DOWN || c == KEY_RIGHT) { - if (_view_offset > 0) _view_offset--; - return true; - } - if (c == KEY_ENTER) { - _fullscreen = true; - _fs_scroll = 0; - return true; - } - if (c == KEY_CANCEL) { - num_unread = 0; - _task->gotoHomeScreen(); - return true; - } - return false; - } -}; void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) { _display = display; @@ -2352,7 +2175,6 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no splash = new SplashScreen(this); home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); - msg_preview = new MsgPreviewScreen(this, &rtc_clock); settings = new SettingsScreen(this); quick_msg = new QuickMsgScreen(this); setCurrScreen(splash); @@ -2380,7 +2202,7 @@ int UITask::getChannelUnreadCount() const { } void UITask::showAlert(const char* text, int duration_millis) { - strcpy(_alert, text); + snprintf(_alert, sizeof(_alert), "%s", text); _alert_expiry = millis() + duration_millis; } @@ -2436,7 +2258,6 @@ void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { _room_unread = 0; - if (curr == msg_preview) gotoHomeScreen(); } } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 90ab7431..35cecf3a 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -53,7 +53,6 @@ class UITask : public AbstractUITask { UIScreen* splash; UIScreen* home; - UIScreen* msg_preview; UIScreen* settings; UIScreen* quick_msg; UIScreen* curr; diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 48dfd331..72c8c2a5 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -17,6 +17,9 @@ bool SH1106Display::begin() void SH1106Display::turnOn() { display.oled_command(SH110X_DISPLAYON); + uint8_t pre[] = { 0xD9, _precharge }; + display.oled_commandList(pre, 2); + display.setContrast(_contrast); _isOn = true; } @@ -87,11 +90,17 @@ uint16_t SH1106Display::getTextWidth(const char *str) void SH1106Display::setBrightness(uint8_t level) { - // OLED contrast is highly non-linear: most perceptible change is in the low range. - // Values are tuned so each step looks visually distinct. - static const uint8_t contrast_values[] = { 8, 30, 80, 160, 255 }; - uint8_t contrast = contrast_values[level < 5 ? level : 4]; - display.setContrast(contrast); + // Contrast alone has limited effect on some OLED panels; combining with + // pre-charge period (0xD9) gives a wider perceptible dimming range. + // Pre-charge 0x11 = phase1=1,phase2=1 (minimum drive); 0x1F = default. + static const uint8_t contrast_values[] = { 0, 25, 60, 150, 255 }; + static const uint8_t precharge_values[] = { 0x11, 0x15, 0x1F, 0x1F, 0x1F }; + uint8_t idx = level < 5 ? level : 4; + _contrast = contrast_values[idx]; + _precharge = precharge_values[idx]; + uint8_t pre[] = { 0xD9, _precharge }; + display.oled_commandList(pre, 2); + display.setContrast(_contrast); } void SH1106Display::endFrame() diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 45f1e2b3..3855f3d6 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -19,11 +19,13 @@ class SH1106Display : public DisplayDriver Adafruit_SH1106G display; bool _isOn; uint8_t _color; + uint8_t _contrast; + uint8_t _precharge; bool i2c_probe(TwoWire &wire, uint8_t addr); public: - SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; } + SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; _contrast = 255; _precharge = 0x1F; } bool begin(); bool isOn() override { return _isOn; } From ffa568938fe40cf00bc264e098afb94c1af88e1a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 09:17:37 +0200 Subject: [PATCH 037/183] Settings: add buzzer volume control (5 levels, like brightness) - NodePrefs: add buzzer_volume field (0=min..4=max, default 4) - DataStore: persist buzzer_volume at end of prefs file (backward-compat) - buzzer: setVolume()/getVolume(); applyVolume() sets PWM duty cycle via analogWrite() after each rtttl::play() call to control output amplitude - Settings UI: new BzrVol bar item in Sound section (left/right to adjust) - UITask: apply saved volume on startup alongside brightness Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 ++++ examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/NodePrefs.h | 1 + examples/companion_radio/ui-new/UITask.cpp | 28 ++++++++++++++++++++++ examples/companion_radio/ui-new/UITask.h | 2 ++ src/helpers/ui/buzzer.cpp | 18 +++++++++++++- src/helpers/ui/buzzer.h | 4 ++++ 7 files changed, 57 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 464516ed..46db233a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -244,6 +244,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 file.read((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 file.read((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 + if (file.available()) { + file.read((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 + } } file.close(); @@ -295,6 +298,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 + file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 2344a18d..62515031 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -864,6 +864,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.gps_enabled = 0; // GPS disabled by default _prefs.gps_interval = 0; // No automatic GPS updates by default _prefs.display_brightness = 2; // medium brightness by default + _prefs.buzzer_volume = 4; // max volume by default _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 667eb36d..76ea7ece 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -25,6 +25,7 @@ struct NodePrefs { // persisted to file uint32_t ble_pin; uint8_t advert_loc_policy; uint8_t buzzer_quiet; + uint8_t buzzer_volume; // 0=min..4=max, default 4 uint8_t gps_enabled; // GPS enabled flag (0=disabled, 1=enabled) uint32_t gps_interval; // GPS read interval in seconds uint8_t autoadd_config; // bitmask for auto-add contacts config diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c4d9df9c..cbbfc6d1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -116,6 +116,7 @@ class SettingsScreen : public UIScreen { // Sound section SECTION_SOUND, BUZZER, + BUZZER_VOLUME, // Radio section SECTION_RADIO, TX_POWER, @@ -273,6 +274,14 @@ class SettingsScreen : public UIScreen { display.print(_task->isBuzzerQuiet() ? "OFF" : "ON"); #else display.print("N/A"); +#endif + } else if (item == BUZZER_VOLUME) { + display.print("BzrVol"); +#ifdef PIN_BUZZER + renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); +#else + display.setCursor(VAL_X, y); + display.print("N/A"); #endif } else if (item == TX_POWER) { display.print("TX Pwr"); @@ -577,6 +586,14 @@ public: _task->toggleBuzzer(); // saves immediately internally return true; } + if (_selected == BUZZER_VOLUME) { +#ifdef PIN_BUZZER + uint8_t lvl = _task->getBuzzerVolume(); + if (right && lvl < 4) { _task->setBuzzerVolumeLevel(lvl + 1); _dirty = true; return true; } + if (left && lvl > 0) { _task->setBuzzerVolumeLevel(lvl - 1); _dirty = true; return true; } +#endif + return right || left; + } if (_selected == TX_POWER && p) { if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } @@ -2157,6 +2174,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no #ifdef PIN_BUZZER buzzer.quiet(_node_prefs->buzzer_quiet); + buzzer.setVolume(_node_prefs->buzzer_volume); buzzer.begin(); #endif @@ -2589,6 +2607,16 @@ void UITask::setBrightnessLevel(uint8_t level) { _next_refresh = 0; } +void UITask::setBuzzerVolumeLevel(uint8_t level) { +#ifdef PIN_BUZZER + if (_node_prefs == NULL) return; + if (level > 4) level = 4; + _node_prefs->buzzer_volume = level; + buzzer.setVolume(level); + _next_refresh = 0; +#endif +} + void UITask::toggleBuzzer() { // Toggle buzzer quiet mode #ifdef PIN_BUZZER diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 35cecf3a..bfc2e6e1 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -107,6 +107,8 @@ public: void applyBrightness(); void setBrightnessLevel(uint8_t level); uint8_t getBrightnessLevel() const { return _node_prefs ? _node_prefs->display_brightness : 2; } + void setBuzzerVolumeLevel(uint8_t level); + uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; } void applyTxPower(); void applyGPSInterval(); uint32_t autoOffMillis() const { diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 983e5c93..9ef3fd0d 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -31,8 +31,24 @@ bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); } +void genericBuzzer::applyVolume() { + // After tone() sets 50% duty, analogWrite overrides duty cycle on the same PWM channel. + // Level 4 = 50% (leave tone as-is), lower levels reduce duty = quieter output. + static const uint8_t duty[] = { 6, 20, 50, 90, 128 }; + uint8_t d = duty[_volume_level < 5 ? _volume_level : 4]; + if (d < 128) analogWrite(PIN_BUZZER, d); +} + +void genericBuzzer::setVolume(uint8_t level) { + _volume_level = level < 5 ? level : 4; + if (isPlaying()) applyVolume(); +} + void genericBuzzer::loop() { - if (!rtttl::done()) rtttl::play(); + if (!rtttl::done()) { + rtttl::play(); + if (_volume_level < 4) applyVolume(); + } } void genericBuzzer::startup() { diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index ae040814..a67e42fb 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -28,8 +28,12 @@ class genericBuzzer bool isPlaying(); // returns true if a sound is still playing else false void quiet(bool buzzer_state); // enables or disables the buzzer bool isQuiet(); // get buzzer state on/off + void setVolume(uint8_t level); // 0=min..4=max + uint8_t getVolume() const { return _volume_level; } private: + uint8_t _volume_level = 4; + void applyVolume(); // gemini's picks: const char *startup_song = "Startup:d=4,o=5,b=160:16c6,16e6,8g6"; const char *shutdown_song = "Shutdown:d=4,o=5,b=100:8g5,16e5,16c5"; From 4891a36c72bd2ba3c98f47f518671b8858e77832 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 09:40:37 +0200 Subject: [PATCH 038/183] Add ringtone editor, buzzer volume, unread fixes and buzzer notification fixes - RingtoneEditorScreen: 32-note step sequencer accessible via new Tools page on home screen. U/D=pitch, ENTER=octave cycle, L/R=cursor, MENU=options (play, duration, BPM, insert/delete, save). Notes stored packed in NodePrefs and persisted via DataStore. - ToolsScreen: new "Tools" home page entry launching the editor - Buzzer volume: 0-4 bar control in Settings > Sound, persisted to prefs - Buzzer notifications: fixed notify() firing inside wrong serial-guard branch and before addChannelMsg set the channel index - Unread counter: fixed newest-first index direction; watermark formula now correct - OLED brightness: wider range via pre-charge register 0xD9 combined with contrast Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 11 +- examples/companion_radio/MyMesh.cpp | 2 + examples/companion_radio/NodePrefs.h | 3 + examples/companion_radio/ui-new/UITask.cpp | 378 +++++++++++++++++++++ examples/companion_radio/ui-new/UITask.h | 7 + src/helpers/ui/buzzer.cpp | 4 + src/helpers/ui/buzzer.h | 1 + 7 files changed, 405 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 46db233a..9f053b87 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -246,6 +246,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 if (file.available()) { file.read((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 + if (file.available()) { + file.read((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); // 1563 + file.read((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); // 1564 + if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0; + file.read((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); // 1565 + } } } @@ -298,7 +304,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 - file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 + file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 + file.write((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); // 1563 + file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); // 1564 + file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); // 1565 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 62515031..c246c112 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -865,6 +865,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.gps_interval = 0; // No automatic GPS updates by default _prefs.display_brightness = 2; // medium brightness by default _prefs.buzzer_volume = 4; // max volume by default + _prefs.ringtone_bpm_idx = 2; // 120 bpm default + _prefs.ringtone_len = 0; // no custom ringtone by default _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 76ea7ece..cf25bd2b 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -45,4 +45,7 @@ struct NodePrefs { // persisted to file uint64_t ch_notif_muted; // bitmask: bit i = channel i muted (only if override bit set) uint8_t dm_show_all; // 0=favourites only (default), 1=all chat contacts uint8_t room_fav_only; // 0=all room servers (default), 1=favourites only + uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180} + uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default) + uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index cbbfc6d1..63ab912d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1681,6 +1681,343 @@ public: } }; +// ── RingtoneEditorScreen ────────────────────────────────────────────────────── +class RingtoneEditorScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int MAX_NOTES = 32; + static const int VISIBLE_NOTES = 7; + static const int CELL_W = 18; + static const int NOTES_Y = 12; + static const int CELL_H = 14; + static const int MENU_VISIBLE = 5; + + static const uint16_t BPM_OPTS[5]; + static const uint8_t DUR_VALS[4]; + static const char* DUR_LABELS[4]; + static const char PITCH_NAMES[8]; // lowercase rtttl names + + uint8_t _notes[MAX_NOTES]; + uint8_t _len; + uint8_t _bpm_idx; + int _cursor; + int _scroll; + bool _menu_open; + int _menu_sel; + int _menu_scroll; + + enum MenuOpt { + M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN, + M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT + }; + static const char* MENU_LABELS[M_COUNT]; + + static uint8_t notePitch(uint8_t b) { return b & 0x07; } + static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } + static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; } + static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { + return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5); + } + + void clampScroll() { + if (_cursor < _scroll) _scroll = _cursor; + if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1; + if (_scroll < 0) _scroll = 0; + } + + void buildRTTTL(char* buf, int buf_len) { + uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2]; + int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm); + for (int i = 0; i < _len && pos < buf_len - 8; i++) { + if (i > 0 && pos < buf_len - 1) buf[pos++] = ','; + uint8_t pitch = notePitch(_notes[i]); + uint8_t octave = noteOctave(_notes[i]); + uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])]; + if (pitch == 0) + pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val); + else + pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave); + } + if (pos < buf_len) buf[pos] = '\0'; + } + +public: + RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { + _bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2; + _len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0; + if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes)); + _cursor = 0; + _scroll = 0; + _menu_open = false; + _menu_sel = 0; + _menu_scroll = 0; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + char hdr[32]; + snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES); + display.setCursor(0, 0); + display.print(hdr); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < VISIBLE_NOTES; i++) { + int ni = _scroll + i; + int cx = i * CELL_W; + bool sel = (ni == _cursor); + if (ni < _len) { + uint8_t pitch = notePitch(_notes[ni]); + uint8_t octave = noteOctave(_notes[ni]); + char label[3]; + if (pitch == 0) { + label[0] = '-'; label[1] = '-'; label[2] = '\0'; + } else { + label[0] = PITCH_NAMES[pitch] - 32; // uppercase + label[1] = '0' + octave; + label[2] = '\0'; + } + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + } + display.setCursor(cx + 3, NOTES_Y + 3); + display.print(label); + display.setColor(DisplayDriver::LIGHT); + } else if (ni == _len && _len < MAX_NOTES) { + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(cx + 6, NOTES_Y + 3); + display.print("+"); + display.setColor(DisplayDriver::LIGHT); + } + } + + int info_y = NOTES_Y + CELL_H + 2; + if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); } + if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); } + if (_cursor < _len) { + char info[24]; + snprintf(info, sizeof(info), "oct:%d dur:%s", + noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]); + display.setCursor(10, info_y); + display.print(info); + } else if (_cursor == _len) { + display.setCursor(10, info_y); + display.print("U/D to add note"); + } + + display.setCursor(0, 54); + display.print("ENT:oct MENU:opts"); + + if (_menu_open) { + static const int mw = 100, mh = MENU_VISIBLE * 10 + 4; + int mx = (128 - mw) / 2; + int my = (64 - mh) / 2; + display.setColor(DisplayDriver::DARK); + display.fillRect(mx, my, mw, mh); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(mx, my, mw, mh); + for (int i = 0; i < MENU_VISIBLE; i++) { + int item = _menu_scroll + i; + if (item >= M_COUNT) break; + int iy = my + 2 + i * 10; + bool sel = (item == _menu_sel); + if (sel) { + display.fillRect(mx + 1, iy - 1, mw - 2, 10); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(mx + 4, iy); + if (item == M_PLAY_STOP) + display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); + else + display.print(MENU_LABELS[item]); + display.setColor(DisplayDriver::LIGHT); + } + if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); } + if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); } + } + + return 200; + } + + bool handleInput(char c) override { + bool up = (c == KEY_UP); + bool down = (c == KEY_DOWN); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool enter = (c == KEY_ENTER); + bool menu_key = (c == KEY_CONTEXT_MENU); + bool cancel = (c == KEY_CANCEL); + + if (_menu_open) { + if (cancel) { _menu_open = false; return true; } + if (up && _menu_sel > 0) { + _menu_sel--; + if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel; + return true; + } + if (down && _menu_sel < M_COUNT - 1) { + _menu_sel++; + if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1; + return true; + } + if (!enter) return true; + + _menu_open = false; + switch ((MenuOpt)_menu_sel) { + case M_PLAY_STOP: + if (_task->isMelodyPlaying()) { + _task->stopMelody(); + } else if (_len > 0) { + char buf[220]; + buildRTTTL(buf, sizeof(buf)); + _task->playMelody(buf); + } + break; + case M_DURATION: + if (_cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03; + _notes[_cursor] = packNote(p, o, di); + } + break; + case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break; + case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break; + case M_INSERT: + if (_len < MAX_NOTES) { + int ins = (_cursor < _len) ? _cursor + 1 : _cursor; + for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; + _notes[ins] = packNote(1, 5, 1); + _len++; + _cursor = ins; + clampScroll(); + } + break; + case M_DELETE: + if (_len > 0 && _cursor < _len) { + for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; + _len--; + if (_cursor >= _len && _len > 0) _cursor = _len - 1; + else if (_len == 0) _cursor = 0; + clampScroll(); + } + break; + case M_SAVE: + if (_prefs) { + _prefs->ringtone_bpm_idx = _bpm_idx; + _prefs->ringtone_len = _len; + memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + the_mesh.savePrefs(); + } + _task->stopMelody(); + _task->gotoHomeScreen(); + break; + case M_DISCARD: + _task->stopMelody(); + _task->gotoHomeScreen(); + break; + default: break; + } + return true; + } + + if (cancel) { _task->stopMelody(); _task->gotoHomeScreen(); return true; } + if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } + + if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } + if (right) { + int max_cur = (_len < MAX_NOTES) ? _len : _len - 1; + if (_cursor < max_cur) { _cursor++; clampScroll(); return true; } + } + + if ((up || down) && _cursor == _len && _len < MAX_NOTES) { + _notes[_len] = packNote(1, 5, 1); + _len++; + clampScroll(); + } + if ((up || down) && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + if (up) p = (p + 1) & 0x07; + if (down) p = (p + 7) & 0x07; + _notes[_cursor] = packNote(p, o, di); + return true; + } + + if (enter && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + if (p != 0) o = (o < 6) ? o + 1 : 4; + _notes[_cursor] = packNote(p, o, di); + return true; + } + if (enter && _cursor == _len && _len < MAX_NOTES) { + _notes[_len] = packNote(1, 5, 1); + _len++; + clampScroll(); + return true; + } + return false; + } +}; + +const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; +const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; +const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; +const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; +const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { + "Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" +}; + +// ── ToolsScreen ─────────────────────────────────────────────────────────────── +class ToolsScreen : public UIScreen { + UITask* _task; + +public: + ToolsScreen(UITask* task) : _task(task) {} + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "TOOLS"); + display.fillRect(0, 10, display.width(), 1); + + display.fillRect(0, 12, display.width(), 11); + display.setColor(DisplayDriver::DARK); + display.setCursor(4, 13); + display.print("Ringtone Editor"); + display.setColor(DisplayDriver::LIGHT); + + display.setCursor(0, 54); + display.print("ENT:open CANCEL:back"); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } + if (c == KEY_ENTER) { _task->gotoRingtoneEditor(); return true; } + return false; + } +}; + +// ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen : public UIScreen { enum HomePage { CLOCK, @@ -1695,6 +2032,7 @@ class HomeScreen : public UIScreen { SENSORS, #endif SETTINGS, + TOOLS, QUICK_MSG, SHUTDOWN, Count // keep as last @@ -2070,6 +2408,11 @@ public: display.setTextSize(1); display.drawTextCentered(display.width() / 2, 30, "Settings"); display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); + } else if (_page == HomePage::TOOLS) { + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 30, "Tools"); + display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -2141,6 +2484,10 @@ public: _task->gotoSettingsScreen(); return true; } + if (c == KEY_ENTER && _page == HomePage::TOOLS) { + _task->gotoToolsScreen(); + return true; + } if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) { _task->gotoQuickMsgScreen(); return true; @@ -2195,6 +2542,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no home = new HomeScreen(this, &rtc_clock, sensors, node_prefs); settings = new SettingsScreen(this); quick_msg = new QuickMsgScreen(this); + tools_screen = new ToolsScreen(this); + ringtone_edit = new RingtoneEditorScreen(this, node_prefs); setCurrScreen(splash); applyBrightness(); @@ -2205,6 +2554,35 @@ void UITask::gotoSettingsScreen() { setCurrScreen(settings); } +void UITask::gotoToolsScreen() { + setCurrScreen(tools_screen); +} + +void UITask::gotoRingtoneEditor() { + ((RingtoneEditorScreen*)ringtone_edit)->enter(); + setCurrScreen(ringtone_edit); +} + +void UITask::playMelody(const char* melody) { +#ifdef PIN_BUZZER + buzzer.playForced(melody); +#endif +} + +void UITask::stopMelody() { +#ifdef PIN_BUZZER + buzzer.stop(); +#endif +} + +bool UITask::isMelodyPlaying() { +#ifdef PIN_BUZZER + return buzzer.isPlaying(); +#else + return false; +#endif +} + void UITask::gotoQuickMsgScreen() { ((QuickMsgScreen*)quick_msg)->reset(); setCurrScreen(quick_msg); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index bfc2e6e1..db689ce5 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -55,6 +55,8 @@ class UITask : public AbstractUITask { UIScreen* home; UIScreen* settings; UIScreen* quick_msg; + UIScreen* tools_screen; + UIScreen* ringtone_edit; UIScreen* curr; void userLedHandler(); @@ -84,6 +86,11 @@ public: void gotoHomeScreen() { setCurrScreen(home); } void gotoSettingsScreen(); void gotoQuickMsgScreen(); + void gotoToolsScreen(); + void gotoRingtoneEditor(); + void playMelody(const char* melody); + void stopMelody(); + bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); void addChannelMsg(uint8_t channel_idx, const char* text) override; int getMsgCount() const { return _msgcount; } diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 9ef3fd0d..4411a407 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -44,6 +44,10 @@ void genericBuzzer::setVolume(uint8_t level) { if (isPlaying()) applyVolume(); } +void genericBuzzer::stop() { + rtttl::stop(); +} + void genericBuzzer::loop() { if (!rtttl::done()) { rtttl::play(); diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index a67e42fb..46185871 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -30,6 +30,7 @@ class genericBuzzer bool isQuiet(); // get buzzer state on/off void setVolume(uint8_t level); // 0=min..4=max uint8_t getVolume() const { return _volume_level; } + void stop(); // stop any playing melody private: uint8_t _volume_level = 4; From 95805da4b285843f02e640dc0fa2bc40872ea88a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 09:42:55 +0200 Subject: [PATCH 039/183] RingtoneEditor: play note preview on pitch/octave change When UP/DOWN changes pitch or ENTER cycles octave, a short 1/16-note preview (BPM 240) is played immediately so the user hears the current note while composing. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 63ab912d..9b112036 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1742,6 +1742,14 @@ class RingtoneEditorScreen : public UIScreen { if (pos < buf_len) buf[pos] = '\0'; } + void previewNote(uint8_t note_byte) { + uint8_t pitch = notePitch(note_byte); + if (pitch == 0) { _task->stopMelody(); return; } + char buf[28]; + snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); + _task->playMelody(buf); + } + public: RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} @@ -1957,6 +1965,7 @@ public: if (up) p = (p + 1) & 0x07; if (down) p = (p + 7) & 0x07; _notes[_cursor] = packNote(p, o, di); + previewNote(_notes[_cursor]); return true; } @@ -1966,12 +1975,14 @@ public: uint8_t di = noteDurIdx(_notes[_cursor]); if (p != 0) o = (o < 6) ? o + 1 : 4; _notes[_cursor] = packNote(p, o, di); + previewNote(_notes[_cursor]); return true; } if (enter && _cursor == _len && _len < MAX_NOTES) { _notes[_len] = packNote(1, 5, 1); _len++; clampScroll(); + previewNote(_notes[_cursor]); return true; } return false; From 2fc9e094e77e3545fe4f8313f998a97d5962db36 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 09:51:30 +0200 Subject: [PATCH 040/183] Settings: add Home Pages visibility control New "Home Pages" section in Settings lets the user toggle which pages appear in the home screen carousel. Settings and Messages are always shown; Clock, Recent, Radio, Bluetooth, Advert, GPS, Sensors, Tools and Shutdown can each be hidden individually. - NodePrefs: home_pages_mask uint16_t (bit per page, 0=all visible) - HomeScreen: isPageVisible/navPage skip hidden pages; dots indicator counts only visible pages; enforces visible page on re-entry - SettingsScreen: SECTION_HOME_PAGES with per-page ON/OFF toggles - DataStore: persisted at offset 1598 - Default: all pages visible (0x01FF) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/NodePrefs.h | 1 + examples/companion_radio/ui-new/UITask.cpp | 170 ++++++++++++++++++--- 4 files changed, 159 insertions(+), 17 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 9f053b87..a4170a9e 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -251,6 +251,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); // 1564 if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0; file.read((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); // 1565 + if (file.available()) { + file.read((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); // 1598 + } } } } @@ -308,6 +311,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); // 1563 file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); // 1564 file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); // 1565 + file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); // 1598 file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c246c112..3522accb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -867,6 +867,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.buzzer_volume = 4; // max volume by default _prefs.ringtone_bpm_idx = 2; // 120 bpm default _prefs.ringtone_len = 0; // no custom ringtone by default + _prefs.home_pages_mask = 0x01FF; // all pages visible _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index cf25bd2b..8a43b19f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -48,4 +48,5 @@ struct NodePrefs { // persisted to file uint8_t ringtone_bpm_idx; // index into {60,90,120,150,180} uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default) uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx + uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9b112036..dd0a27c8 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -84,6 +84,19 @@ public: static const int QUICK_MSGS_MAX = 10; +// Bit positions for NodePrefs::home_pages_mask. +// Bit=1 means page is shown. 0 in the field means ALL visible (default/unset). +static const uint16_t HP_CLOCK = 1 << 0; +static const uint16_t HP_RECENT = 1 << 1; +static const uint16_t HP_RADIO = 1 << 2; +static const uint16_t HP_BLUETOOTH = 1 << 3; +static const uint16_t HP_ADVERT = 1 << 4; +static const uint16_t HP_GPS = 1 << 5; +static const uint16_t HP_SENSORS = 1 << 6; +static const uint16_t HP_TOOLS = 1 << 7; +static const uint16_t HP_SHUTDOWN = 1 << 8; +static const uint16_t HP_ALL = 0x01FF; + // On-screen keyboard layout (4 rows × 10 cols) static const char KB_CHARS[4][10] = { {'a','b','c','d','e','f','g','h','i','j'}, @@ -117,6 +130,16 @@ class SettingsScreen : public UIScreen { SECTION_SOUND, BUZZER, BUZZER_VOLUME, + // Home pages section + SECTION_HOME_PAGES, + HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, +#if ENV_INCLUDE_GPS == 1 + HOME_GPS, +#endif +#if UI_SENSORS_PAGE == 1 + HOME_SENSORS, +#endif + HOME_TOOLS, HOME_SHUTDOWN, // Radio section SECTION_RADIO, TX_POWER, @@ -198,6 +221,7 @@ class SettingsScreen : public UIScreen { bool isSection(int item) const { return item == SECTION_DISPLAY || item == SECTION_SOUND || + item == SECTION_HOME_PAGES || item == SECTION_RADIO || item == SECTION_SYSTEM || item == SECTION_CONTACTS || item == SECTION_MESSAGES #if ENV_INCLUDE_GPS == 1 @@ -207,18 +231,74 @@ class SettingsScreen : public UIScreen { } const char* sectionName(int item) const { - if (item == SECTION_DISPLAY) return "Display"; - if (item == SECTION_SOUND) return "Sound"; - if (item == SECTION_RADIO) return "Radio"; - if (item == SECTION_SYSTEM) return "System"; - if (item == SECTION_CONTACTS) return "Contacts"; - if (item == SECTION_MESSAGES) return "Messages"; + if (item == SECTION_DISPLAY) return "Display"; + if (item == SECTION_SOUND) return "Sound"; + if (item == SECTION_HOME_PAGES) return "Home Pages"; + if (item == SECTION_RADIO) return "Radio"; + if (item == SECTION_SYSTEM) return "System"; + if (item == SECTION_CONTACTS) return "Contacts"; + if (item == SECTION_MESSAGES) return "Messages"; #if ENV_INCLUDE_GPS == 1 - if (item == SECTION_GPS) return "GPS"; + if (item == SECTION_GPS) return "GPS"; #endif return ""; } + bool isHomePage(int item) const { + return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || + item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || + item == HOME_SHUTDOWN +#if ENV_INCLUDE_GPS == 1 + || item == HOME_GPS +#endif +#if UI_SENSORS_PAGE == 1 + || item == HOME_SENSORS +#endif + ; + } + + uint16_t homePageBit(int item) const { + if (item == HOME_CLOCK) return HP_CLOCK; + if (item == HOME_RECENT) return HP_RECENT; + if (item == HOME_RADIO) return HP_RADIO; + if (item == HOME_BT) return HP_BLUETOOTH; + if (item == HOME_ADVERT) return HP_ADVERT; + if (item == HOME_TOOLS) return HP_TOOLS; + if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return HP_GPS; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return HP_SENSORS; +#endif + return 0; + } + + const char* homePageLabel(int item) const { + if (item == HOME_CLOCK) return "Clock"; + if (item == HOME_RECENT) return "Recent"; + if (item == HOME_RADIO) return "Radio"; + if (item == HOME_BT) return "Bluetooth"; + if (item == HOME_ADVERT) return "Advert"; + if (item == HOME_TOOLS) return "Tools"; + if (item == HOME_SHUTDOWN) return "Shutdown"; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return "GPS"; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return "Sensors"; +#endif + return ""; + } + + bool homePageVisible(int item, const NodePrefs* p) const { + uint16_t bit = homePageBit(item); + if (!bit) return false; + uint16_t mask = p ? p->home_pages_mask : HP_ALL; + if (mask == 0) mask = HP_ALL; + return (mask & bit) != 0; + } + bool isMsgSlot(int item) const { return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; } @@ -283,6 +363,10 @@ class SettingsScreen : public UIScreen { display.setCursor(VAL_X, y); display.print("N/A"); #endif + } else if (isHomePage(item)) { + display.print(homePageLabel(item)); + display.setCursor(VAL_X, y); + display.print(homePageVisible(item, p) ? "ON" : "OFF"); } else if (item == TX_POWER) { display.print("TX Pwr"); char buf[8]; @@ -594,6 +678,13 @@ public: #endif return right || left; } + if (isHomePage(_selected) && p && (left || right || enter)) { + uint16_t bit = homePageBit(_selected); + uint16_t mask = p->home_pages_mask ? p->home_pages_mask : HP_ALL; + p->home_pages_mask = mask ^ bit; + _dirty = true; + return true; + } if (_selected == TX_POWER && p) { if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } @@ -2057,6 +2148,38 @@ class HomeScreen : public UIScreen { bool _shutdown_init; AdvertPath recent[UI_RECENT_LIST_SIZE]; + int pageBit(int page) const { + if (page == CLOCK) return 0; + if (page == RECENT) return 1; + if (page == RADIO) return 2; + if (page == BLUETOOTH) return 3; + if (page == ADVERT) return 4; +#if ENV_INCLUDE_GPS == 1 + if (page == GPS) return 5; +#endif +#if UI_SENSORS_PAGE == 1 + if (page == SENSORS) return 6; +#endif + if (page == TOOLS) return 7; + if (page == SHUTDOWN) return 8; + return -1; // SETTINGS, QUICK_MSG always visible + } + + bool isPageVisible(int page) const { + int bit = pageBit(page); + if (bit < 0) return true; + uint16_t mask = _node_prefs ? _node_prefs->home_pages_mask : HP_ALL; + if (mask == 0) mask = HP_ALL; + return (mask >> bit) & 1; + } + + int navPage(int from, int dir) const { + for (int i = 1; i < (int)Count; i++) { + int next = ((from + dir * i) % (int)Count + (int)Count) % (int)Count; + if (isPageVisible(next)) return next; + } + return from; + } void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { #ifndef BATT_MIN_MILLIVOLTS @@ -2201,14 +2324,27 @@ public: // battery voltage renderBatteryIndicator(display, _task->getBattMilliVolts()); - // curr page indicator - int y = 14; - int x = display.width() / 2 - 5 * (HomePage::Count-1); - for (uint8_t i = 0; i < HomePage::Count; i++, x += 10) { - if (i == _page) { - display.fillRect(x-1, y-1, 3, 3); - } else { - display.fillRect(x, y, 1, 1); + // ensure current page is visible (e.g. after settings change) + if (!isPageVisible(_page)) { + for (int i = 0; i < (int)Count; i++) { if (isPageVisible(i)) { _page = i; break; } } + } + + // curr page indicator — only visible pages get a dot + { + int vis_count = 0, curr_vis = 0; + for (int i = 0; i < (int)Count; i++) { + if (!isPageVisible(i)) continue; + if (i == _page) curr_vis = vis_count; + vis_count++; + } + int y = 14; + int x = display.width() / 2 - 5 * (vis_count - 1); + int vi = 0; + for (int i = 0; i < (int)Count; i++) { + if (!isPageVisible(i)) continue; + if (vi == curr_vis) display.fillRect(x-1, y-1, 3, 3); + else display.fillRect(x, y, 1, 1); + x += 10; vi++; } } @@ -2451,11 +2587,11 @@ public: bool handleInput(char c) override { if (c == KEY_LEFT || c == KEY_PREV) { - _page = (_page + HomePage::Count - 1) % HomePage::Count; + _page = navPage(_page, -1); return true; } if (c == KEY_NEXT || c == KEY_RIGHT) { - _page = (_page + 1) % HomePage::Count; + _page = navPage(_page, +1); if (_page == HomePage::RECENT) { _task->showAlert("Recent adverts", 800); } From f0f142890f1e76d726ceb09c497e744b47d3895a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 09:57:21 +0200 Subject: [PATCH 041/183] Fix home_pages_mask=0 inconsistency and navPage fallback - Remove mask==0 guard from isPageVisible/homePageVisible/toggle: mask=0 now correctly means "all optional pages hidden" instead of silently reverting to all-visible. Initialization always sets 0x01FF before loadPrefs so 0 only occurs if user explicitly hides everything. - Simplify toggle handler: direct XOR on home_pages_mask. - HomeScreen: use navPage(+1) as fallback when current page becomes hidden, instead of always jumping to i=0. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index dd0a27c8..3aa097bb 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -295,7 +295,6 @@ class SettingsScreen : public UIScreen { uint16_t bit = homePageBit(item); if (!bit) return false; uint16_t mask = p ? p->home_pages_mask : HP_ALL; - if (mask == 0) mask = HP_ALL; return (mask & bit) != 0; } @@ -679,9 +678,7 @@ public: return right || left; } if (isHomePage(_selected) && p && (left || right || enter)) { - uint16_t bit = homePageBit(_selected); - uint16_t mask = p->home_pages_mask ? p->home_pages_mask : HP_ALL; - p->home_pages_mask = mask ^ bit; + p->home_pages_mask ^= homePageBit(_selected); _dirty = true; return true; } @@ -2169,7 +2166,6 @@ class HomeScreen : public UIScreen { int bit = pageBit(page); if (bit < 0) return true; uint16_t mask = _node_prefs ? _node_prefs->home_pages_mask : HP_ALL; - if (mask == 0) mask = HP_ALL; return (mask >> bit) & 1; } @@ -2325,9 +2321,7 @@ public: renderBatteryIndicator(display, _task->getBattMilliVolts()); // ensure current page is visible (e.g. after settings change) - if (!isPageVisible(_page)) { - for (int i = 0; i < (int)Count; i++) { if (isPageVisible(i)) { _page = i; break; } } - } + if (!isPageVisible(_page)) _page = navPage(_page, +1); // curr page indicator — only visible pages get a dot { From b4d8fad6f5d8f0d7e931ef5d727f61474e170693 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 10:42:19 +0200 Subject: [PATCH 042/183] DataStore: remove misleading byte-offset comments The hardcoded offset numbers were inaccurate from the start (e.g. autoadd_config listed as //87 but actually at //90) and drifted further with each new field. The read/write is sequential with no seeks, so the numbers add no value and only mislead. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 176 ++++++++++++------------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index a4170a9e..bf94416a 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -204,55 +204,55 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (file) { uint8_t pad[8]; - file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 - file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 - file.read(pad, 4); // 36 - file.read((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.read((uint8_t *)&node_lon, sizeof(node_lon)); // 48 - file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 - file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 - file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 - file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 - file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 - file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 - file.read((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69 - file.read((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70 - file.read((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71 - file.read((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72 - file.read((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76 - file.read((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77 - file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78 - file.read(pad, 1); // 79 - file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 - file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 - file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87 - file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88 - file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 - file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 - file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 - file.read((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); // 137 - file.read((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); // 138 - file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); // 140 - file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); // 141 - file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); // 143 + file.read((uint8_t *)&_prefs.airtime_factor, sizeof(float)); + file.read((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); + file.read(pad, 4); + file.read((uint8_t *)&node_lat, sizeof(node_lat)); + file.read((uint8_t *)&node_lon, sizeof(node_lon)); + file.read((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); + file.read((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); + file.read((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); + file.read((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); + file.read((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); + file.read((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); + file.read((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); + file.read((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); + file.read((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); + file.read((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); + file.read((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); + file.read((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); + file.read((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); + file.read((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); + file.read(pad, 1); + file.read((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); + file.read((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); + file.read((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); + file.read((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); + file.read((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); + file.read((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); + file.read((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); + file.read((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); + file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); + file.read((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); + file.read((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); + file.read((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); + file.read((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); + file.read((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); if (file.available()) { - file.read((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); // 144 - file.read((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); // 1544 - file.read((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 - file.read((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 - file.read((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 + file.read((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); + file.read((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); + file.read((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); + file.read((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); + file.read((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); if (file.available()) { - file.read((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 + file.read((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); if (file.available()) { - file.read((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); // 1563 - file.read((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); // 1564 + file.read((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); + file.read((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0; - file.read((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); // 1565 + file.read((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); if (file.available()) { - file.read((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); // 1598 + file.read((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); } } } @@ -268,50 +268,50 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ uint8_t pad[8]; memset(pad, 0, sizeof(pad)); - file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 - file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 - file.write(pad, 4); // 36 - file.write((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.write((uint8_t *)&node_lon, sizeof(node_lon)); // 48 - file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 - file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 - file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 - file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 - file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 - file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 - file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69 - file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70 - file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71 - file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72 - file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76 - file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77 - file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78 - file.write(pad, 1); // 79 - file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 - file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 - file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87 - file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88 - file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 - file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 - file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 - file.write((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); // 137 - file.write((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); // 138 - file.write((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); // 140 - file.write((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); // 141 - file.write((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); // 143 - file.write((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); // 144 - file.write((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); // 1544 - file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); // 1552 - file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); // 1560 - file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); // 1561 - file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); // 1562 - file.write((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); // 1563 - file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); // 1564 - file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); // 1565 - file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); // 1598 + file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); + file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); + file.write(pad, 4); + file.write((uint8_t *)&node_lat, sizeof(node_lat)); + file.write((uint8_t *)&node_lon, sizeof(node_lon)); + file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); + file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); + file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); + file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); + file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); + file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); + file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); + file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); + file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); + file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); + file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); + file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); + file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); + file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); + file.write(pad, 1); + file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); + file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); + file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); + file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); + file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); + file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); + file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); + file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); + file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); + file.write((uint8_t *)&_prefs.display_brightness, sizeof(_prefs.display_brightness)); + file.write((uint8_t *)&_prefs.auto_off_secs, sizeof(_prefs.auto_off_secs)); + file.write((uint8_t *)&_prefs.tz_offset_hours, sizeof(_prefs.tz_offset_hours)); + file.write((uint8_t *)&_prefs.low_batt_mv, sizeof(_prefs.low_batt_mv)); + file.write((uint8_t *)&_prefs.batt_display_mode, sizeof(_prefs.batt_display_mode)); + file.write((uint8_t *)_prefs.custom_msgs, sizeof(_prefs.custom_msgs)); + file.write((uint8_t *)&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override)); + file.write((uint8_t *)&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted)); + file.write((uint8_t *)&_prefs.dm_show_all, sizeof(_prefs.dm_show_all)); + file.write((uint8_t *)&_prefs.room_fav_only, sizeof(_prefs.room_fav_only)); + file.write((uint8_t *)&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume)); + file.write((uint8_t *)&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx)); + file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); + file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); + file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); file.close(); } From 58afc23df3e6cfee460ca0ba8fa33fcbda04f21c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 11:47:33 +0200 Subject: [PATCH 043/183] Add Auto-Reply Bot and Tools screen to home carousel - New 'Tools' page on the home screen carousel with Ringtone Editor and Auto-Reply Bot items - Bot settings: enable/disable toggle, channel picker, trigger phrase, reply text (keyboard with pre-fill) - Bot logic in MyMesh: case-insensitive substring match, 10 s cooldown to prevent reply loops - Settings: Home Pages visibility control (which carousel pages are shown; Settings and Messages always visible) - DataStore: persist home_pages_mask and bot fields with backward-compat guards on read Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 10 + examples/companion_radio/MyMesh.cpp | 30 +++ examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/NodePrefs.h | 4 + examples/companion_radio/ui-new/UITask.cpp | 274 ++++++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 2 + 6 files changed, 314 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index bf94416a..4c99f992 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -253,6 +253,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); if (file.available()) { file.read((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); + if (file.available()) { + file.read((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); + file.read((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); + file.read((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); + file.read((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply)); + } } } } @@ -312,6 +318,10 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ringtone_len, sizeof(_prefs.ringtone_len)); file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); + file.write((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); + file.write((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); + file.write((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); + file.write((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply)); file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3522accb..a195fe11 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -570,6 +570,32 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe } if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0); #endif + + // auto-reply bot + if (_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + channel_idx == _prefs.bot_channel_idx && + millis() - _bot_last_reply_ms > 10000UL) { + // case-insensitive substring match + const char* t = text; + const char* tr = _prefs.bot_trigger; + int tlen = strlen(tr); + bool matched = false; + for (; *t && !matched; t++) { + int i = 0; + while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; + if (i == tlen) matched = true; + } + if (matched) { + 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)) { + _bot_last_reply_ms = millis(); + } + } + } + } } void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, @@ -868,6 +894,10 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.ringtone_bpm_idx = 2; // 120 bpm default _prefs.ringtone_len = 0; // no custom ringtone by default _prefs.home_pages_mask = 0x01FF; // all pages visible + _prefs.bot_enabled = 0; + _prefs.bot_channel_idx = 0; + _prefs.bot_trigger[0] = '\0'; + _prefs.bot_reply[0] = '\0'; _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 90bc928f..46651b88 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -221,6 +221,7 @@ private: uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; + unsigned long _bot_last_reply_ms; TransportKey send_scope; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 8a43b19f..57302120 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -49,4 +49,8 @@ struct NodePrefs { // persisted to file uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default) uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible + uint8_t bot_enabled; // 0=disabled, 1=enabled + uint8_t bot_channel_idx; // channel index to monitor + char bot_trigger[64]; // trigger phrase (case-insensitive contains match) + char bot_reply[140]; // auto-reply text }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 3aa097bb..b42248a4 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2085,12 +2085,254 @@ const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] "Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" }; +// ── BotScreen ───────────────────────────────────────────────────────────────── +class BotScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int ITEM_COUNT = 4; + static const int ITEM_H = 11; + static const int START_Y = 12; + static const int VAL_X = 70; + + // keyboard state (reused for trigger and reply fields) + int _kb_field; // -1=off, 2=trigger, 3=reply + char _kb_buf[140]; + int _kb_len; + int _kb_maxlen; + int _kb_row, _kb_col; + bool _kb_caps; + + int _sel; + + static const char KB_CHARS[4][10]; + static const int KB_ROWS = 4, KB_COLS = 10; + + // channel count cache (refreshed on enter) + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + + void refreshChannels() { + _num_channels = 0; + ChannelDetails ch; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + + // returns display index of current bot_channel_idx in _channel_indices + int currentChanDisplayIdx() const { + for (int i = 0; i < _num_channels; i++) + if (_channel_indices[i] == _prefs->bot_channel_idx) return i; + return 0; + } + +public: + BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { + _sel = 0; + _kb_field = -1; + refreshChannels(); + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + if (_kb_field >= 0) { + // keyboard mode + 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); + display.print(preview); + display.fillRect(0, 10, display.width(), 1); + + for (int row = 0; row < KB_ROWS; row++) { + int y = 14 + row * 11; + for (int col = 0; col < KB_COLS; 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 (sel) { + display.fillRect(cx, y - 1, 11, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(cx + 3, y); + display.print(buf); + display.setColor(DisplayDriver::LIGHT); + } + } + // special row + const char* spec[] = { "[^]", "[Sp]", "[Del]", "[OK]" }; + for (int i = 0; i < 4; i++) { + bool sel = (_kb_row == KB_ROWS && _kb_col == i); + bool active = (i == 0 && _kb_caps); + int sx = i * 32; + if (sel || active) { + display.fillRect(sx, 58, 31, 9); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(sx + 2, 59); + display.print(spec[i]); + display.setColor(DisplayDriver::LIGHT); + } + return 50; + } + + display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); + display.fillRect(0, 10, display.width(), 1); + + static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply" }; + for (int i = 0; i < ITEM_COUNT; i++) { + int y = START_Y + i * ITEM_H; + bool sel = (i == _sel); + if (sel) { + display.fillRect(0, y - 1, display.width(), ITEM_H); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(2, y); + display.print(labels[i]); + display.setCursor(VAL_X, y); + + if (i == 0) { + display.print(_prefs->bot_enabled ? "ON" : "OFF"); + } else if (i == 1) { + if (_num_channels == 0) { + display.print("none"); + } else { + ChannelDetails ch; + if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name); + else + display.print("Ch?"); + } + } else if (i == 2) { + const char* tr = _prefs->bot_trigger; + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)"); + } else { + const char* rp = _prefs->bot_reply; + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); + } + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, 54); + display.print("ENT:edit CANCEL:save"); + return 300; + } + + bool handleInput(char c) override { + bool up = (c == KEY_UP); + bool down = (c == KEY_DOWN); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool enter = (c == KEY_ENTER); + 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++; 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_len < _kb_maxlen) { + char ch = KB_CHARS[_kb_row][_kb_col]; + if (_kb_caps && ch >= 'a' && ch <= 'z') ch -= 32; + _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 3: + // commit + if (_kb_field == 2) + strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1); + else + strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1); + _kb_field = -1; + break; + } + } + return true; + } + return true; + } + + if (cancel) { + the_mesh.savePrefs(); + _task->gotoToolsScreen(); + return true; + } + if (up && _sel > 0) { _sel--; return true; } + if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; } + + if (_sel == 0 && (enter || left || right)) { + _prefs->bot_enabled ^= 1; + return true; + } + if (_sel == 1 && _num_channels > 0) { + int idx = currentChanDisplayIdx(); + if (right || enter) idx = (idx + 1) % _num_channels; + if (left) idx = (idx + _num_channels - 1) % _num_channels; + if (left || right || enter) { _prefs->bot_channel_idx = _channel_indices[idx]; return true; } + } + if ((_sel == 2 || _sel == 3) && enter) { + _kb_field = _sel; + _kb_row = 0; + _kb_col = 0; + _kb_caps = false; + if (_sel == 2) { + strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1); + _kb_maxlen = sizeof(_prefs->bot_trigger) - 1; + } else { + strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1); + _kb_maxlen = sizeof(_prefs->bot_reply) - 1; + } + _kb_buf[sizeof(_kb_buf) - 1] = '\0'; + _kb_len = strlen(_kb_buf); + return true; + } + return false; + } +}; + +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; + int _sel; + + static const int ITEM_COUNT = 2; + static const char* ITEMS[ITEM_COUNT]; public: - ToolsScreen(UITask* task) : _task(task) {} + ToolsScreen(UITask* task) : _task(task), _sel(0) {} int render(DisplayDriver& display) override { display.setTextSize(1); @@ -2098,11 +2340,17 @@ public: display.drawTextCentered(display.width() / 2, 0, "TOOLS"); display.fillRect(0, 10, display.width(), 1); - display.fillRect(0, 12, display.width(), 11); - display.setColor(DisplayDriver::DARK); - display.setCursor(4, 13); - display.print("Ringtone Editor"); - display.setColor(DisplayDriver::LIGHT); + for (int i = 0; i < ITEM_COUNT; i++) { + int y = 14 + i * 12; + bool sel = (i == _sel); + if (sel) { + display.fillRect(0, y - 1, display.width(), 11); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(4, y); + display.print(ITEMS[i]); + display.setColor(DisplayDriver::LIGHT); + } display.setCursor(0, 54); display.print("ENT:open CANCEL:back"); @@ -2110,11 +2358,17 @@ public: } bool handleInput(char c) override { + if (c == KEY_UP && _sel > 0) { _sel--; return true; } + if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } - if (c == KEY_ENTER) { _task->gotoRingtoneEditor(); return true; } + if (c == KEY_ENTER) { + if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } + if (_sel == 1) { _task->gotoBotScreen(); return true; } + } return false; } }; +const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" }; // ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen : public UIScreen { @@ -2685,6 +2939,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no quick_msg = new QuickMsgScreen(this); tools_screen = new ToolsScreen(this); ringtone_edit = new RingtoneEditorScreen(this, node_prefs); + bot_screen = new BotScreen(this, node_prefs); setCurrScreen(splash); applyBrightness(); @@ -2704,6 +2959,11 @@ void UITask::gotoRingtoneEditor() { setCurrScreen(ringtone_edit); } +void UITask::gotoBotScreen() { + ((BotScreen*)bot_screen)->enter(); + setCurrScreen(bot_screen); +} + void UITask::playMelody(const char* melody) { #ifdef PIN_BUZZER buzzer.playForced(melody); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index db689ce5..48d1c030 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -57,6 +57,7 @@ class UITask : public AbstractUITask { UIScreen* quick_msg; UIScreen* tools_screen; UIScreen* ringtone_edit; + UIScreen* bot_screen; UIScreen* curr; void userLedHandler(); @@ -88,6 +89,7 @@ public: void gotoQuickMsgScreen(); void gotoToolsScreen(); void gotoRingtoneEditor(); + void gotoBotScreen(); void playMelody(const char* melody); void stopMelody(); bool isMelodyPlaying(); From 9756bb062ce5b2cfa268019378057c33c55bdc18 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 11:54:18 +0200 Subject: [PATCH 044/183] Fix 4 bugs found in code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BotScreen keyboard: add explicit null-terminator after strncpy when trigger/reply text reaches max length (strncpy skips it in that case) - BotScreen keyboard: clamp _kb_col to 3 when moving DOWN to special row, so the cursor doesn't become invisible (col > 3 has no special-row item) - MyMesh: initialise _bot_last_reply_ms = 0 in constructor (was relying on BSS zero-init, which is technically UB for a non-trivial class member) - RingtoneEditor: exit via CANCEL/Save/Discard goes to ToolsScreen instead of HomeScreen — consistent with BotScreen and expected navigation flow Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/ui-new/UITask.cpp | 19 +++++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a195fe11..a5e40a84 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -871,6 +871,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _cli_rescue = false; offline_queue_len = 0; app_target_ver = 0; + _bot_last_reply_ms = 0; clearPendingReqs(); next_ack_idx = 0; sign_data = NULL; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b42248a4..c00ca6e9 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2021,18 +2021,18 @@ public: the_mesh.savePrefs(); } _task->stopMelody(); - _task->gotoHomeScreen(); + _task->gotoToolsScreen(); break; case M_DISCARD: _task->stopMelody(); - _task->gotoHomeScreen(); + _task->gotoToolsScreen(); break; default: break; } return true; } - if (cancel) { _task->stopMelody(); _task->gotoHomeScreen(); return true; } + if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; } if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } @@ -2245,7 +2245,11 @@ public: // 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++; return true; } + if (down && _kb_row < KB_ROWS) { + _kb_row++; + if (_kb_row == KB_ROWS && _kb_col > 3) _kb_col = 3; + 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) { @@ -2265,10 +2269,13 @@ public: case 2: if (_kb_len > 0) { _kb_buf[--_kb_len] = '\0'; } break; case 3: // commit - if (_kb_field == 2) + if (_kb_field == 2) { strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1); - else + _prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0'; + } else { strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1); + _prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0'; + } _kb_field = -1; break; } From e6389558987295c88ea4a489081dfa42293f6381 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 13:48:27 +0200 Subject: [PATCH 045/183] Bot: add DM contact target alongside channel The bot can now reply to a specific DM contact instead of a channel. UI (BotScreen): item 1 cycles through all channels followed by all DM contacts (ADV_TYPE_CHAT). The label switches between "Channel" / "Contact" and the value shows the channel or contact name. Left/right/enter navigate the combined list. Logic (MyMesh::onMessageRecv): when bot_target_type==1, match the trigger against incoming DM text and reply via sendMessage(). A 4-byte pubkey prefix identifies the target; all-zero prefix matches any DM sender. Persistence: bot_target_type and bot_dm_pubkey[4] appended to prefs with backward-compat read guard (old firmware defaults to channel mode). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 6 ++ examples/companion_radio/MyMesh.cpp | 29 +++++++ examples/companion_radio/NodePrefs.h | 4 +- examples/companion_radio/ui-new/UITask.cpp | 89 +++++++++++++++++----- 4 files changed, 109 insertions(+), 19 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4c99f992..9832de42 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -258,6 +258,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); file.read((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); file.read((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply)); + if (file.available()) { + file.read((uint8_t *)&_prefs.bot_target_type, sizeof(_prefs.bot_target_type)); + file.read((uint8_t *)_prefs.bot_dm_pubkey, sizeof(_prefs.bot_dm_pubkey)); + } } } } @@ -322,6 +326,8 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); file.write((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); file.write((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply)); + file.write((uint8_t *)&_prefs.bot_target_type, sizeof(_prefs.bot_target_type)); + file.write((uint8_t *)_prefs.bot_dm_pubkey, sizeof(_prefs.bot_dm_pubkey)); file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a5e40a84..1147577b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -512,6 +512,33 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); + + // DM auto-reply bot + if (_prefs.bot_enabled && _prefs.bot_target_type == 1 && + _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + millis() - _bot_last_reply_ms > 10000UL) { + // all-zero pubkey = any sender; otherwise match first 4 bytes + bool key_ok = (_prefs.bot_dm_pubkey[0] == 0 && _prefs.bot_dm_pubkey[1] == 0 && + _prefs.bot_dm_pubkey[2] == 0 && _prefs.bot_dm_pubkey[3] == 0) || + memcmp(from.id.pub_key, _prefs.bot_dm_pubkey, 4) == 0; + if (key_ok) { + const char* t = text; + const char* tr = _prefs.bot_trigger; + int tlen = strlen(tr); + bool matched = false; + for (; *t && !matched; t++) { + int i = 0; + while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; + if (i == tlen) matched = true; + } + if (matched) { + uint32_t ts = getRTCClock()->getCurrentTime(); + uint32_t expected_ack, est_timeout; + if (sendMessage(from, ts, 0, _prefs.bot_reply, expected_ack, est_timeout) != MSG_SEND_FAILED) + _bot_last_reply_ms = millis(); + } + } + } } void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -899,6 +926,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.bot_channel_idx = 0; _prefs.bot_trigger[0] = '\0'; _prefs.bot_reply[0] = '\0'; + _prefs.bot_target_type = 0; + memset(_prefs.bot_dm_pubkey, 0, sizeof(_prefs.bot_dm_pubkey)); _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 57302120..68bf8dc6 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -50,7 +50,9 @@ struct NodePrefs { // persisted to file uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible uint8_t bot_enabled; // 0=disabled, 1=enabled - uint8_t bot_channel_idx; // channel index to monitor + uint8_t bot_channel_idx; // channel index to monitor (when bot_target_type==0) char bot_trigger[64]; // trigger phrase (case-insensitive contains match) char bot_reply[140]; // auto-reply text + uint8_t bot_target_type; // 0=channel (default), 1=DM contact + uint8_t bot_dm_pubkey[4]; // pubkey prefix of DM target; all-zero = any DM sender }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c00ca6e9..65751a32 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2108,10 +2108,16 @@ class BotScreen : public UIScreen { static const char KB_CHARS[4][10]; static const int KB_ROWS = 4, KB_COLS = 10; - // channel count cache (refreshed on enter) - int _num_channels; + // channel + DM contact caches (refreshed on enter) + static const int MAX_BOT_DM = 16; + + int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + int _num_dm; + uint8_t _dm_pubkeys[MAX_BOT_DM][4]; + char _dm_names[MAX_BOT_DM][32]; + void refreshChannels() { _num_channels = 0; ChannelDetails ch; @@ -2121,11 +2127,32 @@ class BotScreen : public UIScreen { } } - // returns display index of current bot_channel_idx in _channel_indices - int currentChanDisplayIdx() const { - for (int i = 0; i < _num_channels; i++) - if (_channel_indices[i] == _prefs->bot_channel_idx) return i; - return 0; + void refreshContacts() { + _num_dm = 0; + ContactInfo ci; + int n = the_mesh.getNumContacts(); + for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) { + if (!the_mesh.getContactByIdx(i, ci)) continue; + if (ci.type != ADV_TYPE_CHAT) continue; + memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4); + strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1); + _dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0'; + _num_dm++; + } + } + + // returns combined index in [channels..., DM contacts...] + int currentTargetIdx() const { + if (_prefs->bot_target_type == 0) { + for (int i = 0; i < _num_channels; i++) + if (_channel_indices[i] == _prefs->bot_channel_idx) return i; + return 0; + } else { + for (int i = 0; i < _num_dm; i++) + if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0) + return _num_channels + i; + return _num_channels; // fallback: first DM + } } public: @@ -2135,6 +2162,7 @@ public: _sel = 0; _kb_field = -1; refreshChannels(); + refreshContacts(); } int render(DisplayDriver& display) override { @@ -2194,7 +2222,7 @@ public: display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); display.fillRect(0, 10, display.width(), 1); - static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply" }; + static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" }; for (int i = 0; i < ITEM_COUNT; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _sel); @@ -2203,20 +2231,33 @@ public: display.setColor(DisplayDriver::DARK); } display.setCursor(2, y); - display.print(labels[i]); + if (i == 1) + display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact"); + else + display.print(labels[i]); display.setCursor(VAL_X, y); if (i == 0) { display.print(_prefs->bot_enabled ? "ON" : "OFF"); } else if (i == 1) { - if (_num_channels == 0) { + int total = _num_channels + _num_dm; + if (total == 0) { display.print("none"); - } else { + } else if (_prefs->bot_target_type == 0) { ChannelDetails ch; - if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) + if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name); else - display.print("Ch?"); + display.print("?"); + } else { + bool found = false; + for (int j = 0; j < _num_dm && !found; j++) { + if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) { + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]); + found = true; + } + } + if (!found) display.print("?"); } } else if (i == 2) { const char* tr = _prefs->bot_trigger; @@ -2297,11 +2338,23 @@ public: _prefs->bot_enabled ^= 1; return true; } - if (_sel == 1 && _num_channels > 0) { - int idx = currentChanDisplayIdx(); - if (right || enter) idx = (idx + 1) % _num_channels; - if (left) idx = (idx + _num_channels - 1) % _num_channels; - if (left || right || enter) { _prefs->bot_channel_idx = _channel_indices[idx]; return true; } + if (_sel == 1) { + int total = _num_channels + _num_dm; + if (total == 0) return false; + int idx = currentTargetIdx(); + if (right || enter) idx = (idx + 1) % total; + if (left) idx = (idx + total - 1) % total; + if (left || right || enter) { + if (idx < _num_channels) { + _prefs->bot_target_type = 0; + _prefs->bot_channel_idx = _channel_indices[idx]; + } else { + int di = idx - _num_channels; + _prefs->bot_target_type = 1; + memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4); + } + return true; + } } if ((_sel == 2 || _sel == 3) && enter) { _kb_field = _sel; From ac44630cb8aa01285b120801003058f8f2b6d580 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 13:53:52 +0200 Subject: [PATCH 046/183] ToolsScreen: match list style of other screens; bot DM: favourites only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolsScreen now follows the same rendering convention as QuickMsgScreen and SettingsScreen: START_Y=12, '>' prefix on selected item, explicit setColor per item (light fill → dark text for selection). Bot DM contact picker now filters to favourites-only (flags & 0x01), consistent with the DM contact list behaviour elsewhere in the UI. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 65751a32..8bc86c23 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2134,6 +2134,7 @@ class BotScreen : public UIScreen { for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) { if (!the_mesh.getContactByIdx(i, ci)) continue; if (ci.type != ADV_TYPE_CHAT) continue; + if (!(ci.flags & 0x01)) continue; // favourites only memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4); strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1); _dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0'; @@ -2401,16 +2402,21 @@ public: display.fillRect(0, 10, display.width(), 1); for (int i = 0; i < ITEM_COUNT; i++) { - int y = 14 + i * 12; + int y = 12 + i * 12; bool sel = (i == _sel); if (sel) { + display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y - 1, display.width(), 11); display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); } - display.setCursor(4, y); + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); display.print(ITEMS[i]); - display.setColor(DisplayDriver::LIGHT); } + display.setColor(DisplayDriver::LIGHT); display.setCursor(0, 54); display.print("ENT:open CANCEL:back"); 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 047/183] 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; } From d51d8c0a75ccfe20db0ab2b19256d7db2a251104 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 14:45:02 +0200 Subject: [PATCH 048/183] Extract RingtoneEditorScreen, BotScreen, ToolsScreen to separate headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move three custom screens out of UITask.cpp into their own .h files (RingtoneEditorScreen.h, BotScreen.h, ToolsScreen.h) and replace them with three #include lines. UITask.cpp now only contains screens that overlap with upstream (SplashScreen, SettingsScreen, QuickMsgScreen, HomeScreen), making future upstream merges easier — custom screens live in files that upstream will never touch. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/BotScreen.h | 363 +++++++++ .../ui-new/RingtoneEditorScreen.h | 318 ++++++++ examples/companion_radio/ui-new/ToolsScreen.h | 51 ++ examples/companion_radio/ui-new/UITask.cpp | 729 +----------------- 4 files changed, 736 insertions(+), 725 deletions(-) create mode 100644 examples/companion_radio/ui-new/BotScreen.h create mode 100644 examples/companion_radio/ui-new/RingtoneEditorScreen.h create mode 100644 examples/companion_radio/ui-new/ToolsScreen.h diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h new file mode 100644 index 00000000..6c833f9b --- /dev/null +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -0,0 +1,363 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after the global KB_* constants are defined. + +class BotScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int ITEM_COUNT = 4; + static const int ITEM_H = 11; + static const int START_Y = 12; + static const int VAL_X = 70; + + // keyboard state (reused for trigger and reply fields) + int _kb_field; // -1=off, 2=trigger, 3=reply + 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; + + // channel + DM contact caches (refreshed on enter) + static const int MAX_BOT_DM = 16; + + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + + int _num_dm; + uint8_t _dm_pubkeys[MAX_BOT_DM][4]; + char _dm_names[MAX_BOT_DM][32]; + + void refreshChannels() { + _num_channels = 0; + ChannelDetails ch; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + + void refreshContacts() { + _num_dm = 0; + ContactInfo ci; + int n = the_mesh.getNumContacts(); + for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) { + if (!the_mesh.getContactByIdx(i, ci)) continue; + if (ci.type != ADV_TYPE_CHAT) continue; + if (!(ci.flags & 0x01)) continue; // favourites only + memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4); + strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1); + _dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0'; + _num_dm++; + } + } + + // returns combined index in [channels..., DM contacts...] + int currentTargetIdx() const { + if (_prefs->bot_target_type == 0) { + for (int i = 0; i < _num_channels; i++) + if (_channel_indices[i] == _prefs->bot_channel_idx) return i; + return 0; + } else { + for (int i = 0; i < _num_dm; i++) + if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0) + return _num_channels + i; + return _num_channels; // fallback: first DM + } + } + +public: + BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { + _sel = 0; + _kb_field = -1; + refreshChannels(); + refreshContacts(); + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + if (_kb_field >= 0) { + // keyboard mode — uses same layout as global keyboard + const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:"; + 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, KB_SEP_Y, display.width(), 1); + + // 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 = ch - 'a' + 'A'; + char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; + int cx = col * KB_CELL_W; + if (sel) { + 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(ch_buf); + } + } + + // 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 * 25; + if (sel || active) { + 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 + 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; + } + + display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); + display.fillRect(0, 10, display.width(), 1); + + static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" }; + for (int i = 0; i < ITEM_COUNT; i++) { + int y = START_Y + i * ITEM_H; + bool sel = (i == _sel); + if (sel) { + display.fillRect(0, y - 1, display.width(), ITEM_H); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(2, y); + if (i == 1) + display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact"); + else + display.print(labels[i]); + display.setCursor(VAL_X, y); + + if (i == 0) { + display.print(_prefs->bot_enabled ? "ON" : "OFF"); + } else if (i == 1) { + int total = _num_channels + _num_dm; + if (total == 0) { + display.print("none"); + } else if (_prefs->bot_target_type == 0) { + ChannelDetails ch; + if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name); + else + display.print("?"); + } else { + bool found = false; + for (int j = 0; j < _num_dm && !found; j++) { + if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) { + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]); + found = true; + } + } + if (!found) display.print("?"); + } + } else if (i == 2) { + const char* tr = _prefs->bot_trigger; + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)"); + } else { + const char* rp = _prefs->bot_reply; + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); + } + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, 54); + display.print("ENT:edit CANCEL:save"); + return 300; + } + + bool handleInput(char c) override { + bool up = (c == KEY_UP); + bool down = (c == KEY_DOWN); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool enter = (c == KEY_ENTER); + bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); + + if (_kb_field >= 0) { + // 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 (enter) { + 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 = ch - 'a' + 'A'; + _kb_buf[_kb_len++] = ch; + _kb_buf[_kb_len] = '\0'; + } + } else { + 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 3: + _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'; + } else { + strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1); + _prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0'; + } + _kb_field = -1; + break; + } + } + return true; + } + return true; + } + + if (cancel) { + the_mesh.savePrefs(); + _task->gotoToolsScreen(); + return true; + } + if (up && _sel > 0) { _sel--; return true; } + if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; } + + if (_sel == 0 && (enter || left || right)) { + _prefs->bot_enabled ^= 1; + return true; + } + if (_sel == 1) { + int total = _num_channels + _num_dm; + if (total == 0) return false; + int idx = currentTargetIdx(); + if (right || enter) idx = (idx + 1) % total; + if (left) idx = (idx + total - 1) % total; + if (left || right || enter) { + if (idx < _num_channels) { + _prefs->bot_target_type = 0; + _prefs->bot_channel_idx = _channel_indices[idx]; + } else { + int di = idx - _num_channels; + _prefs->bot_target_type = 1; + memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4); + } + return true; + } + } + if ((_sel == 2 || _sel == 3) && enter) { + _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; + } else { + strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1); + _kb_maxlen = sizeof(_prefs->bot_reply) - 1; + } + _kb_buf[sizeof(_kb_buf) - 1] = '\0'; + _kb_len = strlen(_kb_buf); + return true; + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h new file mode 100644 index 00000000..2704042f --- /dev/null +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -0,0 +1,318 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after the global KB_* constants are defined. + +class RingtoneEditorScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int MAX_NOTES = 32; + static const int VISIBLE_NOTES = 7; + static const int CELL_W = 18; + static const int NOTES_Y = 12; + static const int CELL_H = 14; + static const int MENU_VISIBLE = 5; + + static const uint16_t BPM_OPTS[5]; + static const uint8_t DUR_VALS[4]; + static const char* DUR_LABELS[4]; + static const char PITCH_NAMES[8]; // lowercase rtttl names + + uint8_t _notes[MAX_NOTES]; + uint8_t _len; + uint8_t _bpm_idx; + int _cursor; + int _scroll; + bool _menu_open; + int _menu_sel; + int _menu_scroll; + + enum MenuOpt { + M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN, + M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT + }; + static const char* MENU_LABELS[M_COUNT]; + + static uint8_t notePitch(uint8_t b) { return b & 0x07; } + static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } + static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; } + static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { + return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5); + } + + void clampScroll() { + if (_cursor < _scroll) _scroll = _cursor; + if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1; + if (_scroll < 0) _scroll = 0; + } + + void buildRTTTL(char* buf, int buf_len) { + uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2]; + int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm); + for (int i = 0; i < _len && pos < buf_len - 8; i++) { + if (i > 0 && pos < buf_len - 1) buf[pos++] = ','; + uint8_t pitch = notePitch(_notes[i]); + uint8_t octave = noteOctave(_notes[i]); + uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])]; + if (pitch == 0) + pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val); + else + pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave); + } + if (pos < buf_len) buf[pos] = '\0'; + } + + void previewNote(uint8_t note_byte) { + uint8_t pitch = notePitch(note_byte); + if (pitch == 0) { _task->stopMelody(); return; } + char buf[28]; + snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); + _task->playMelody(buf); + } + +public: + RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { + _bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2; + _len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0; + if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes)); + _cursor = 0; + _scroll = 0; + _menu_open = false; + _menu_sel = 0; + _menu_scroll = 0; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + char hdr[32]; + snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES); + display.setCursor(0, 0); + display.print(hdr); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < VISIBLE_NOTES; i++) { + int ni = _scroll + i; + int cx = i * CELL_W; + bool sel = (ni == _cursor); + if (ni < _len) { + uint8_t pitch = notePitch(_notes[ni]); + uint8_t octave = noteOctave(_notes[ni]); + char label[3]; + if (pitch == 0) { + label[0] = '-'; label[1] = '-'; label[2] = '\0'; + } else { + label[0] = PITCH_NAMES[pitch] - 32; // uppercase + label[1] = '0' + octave; + label[2] = '\0'; + } + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + } + display.setCursor(cx + 3, NOTES_Y + 3); + display.print(label); + display.setColor(DisplayDriver::LIGHT); + } else if (ni == _len && _len < MAX_NOTES) { + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(cx + 6, NOTES_Y + 3); + display.print("+"); + display.setColor(DisplayDriver::LIGHT); + } + } + + int info_y = NOTES_Y + CELL_H + 2; + if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); } + if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); } + if (_cursor < _len) { + char info[24]; + snprintf(info, sizeof(info), "oct:%d dur:%s", + noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]); + display.setCursor(10, info_y); + display.print(info); + } else if (_cursor == _len) { + display.setCursor(10, info_y); + display.print("U/D to add note"); + } + + display.setCursor(0, 54); + display.print("ENT:oct MENU:opts"); + + if (_menu_open) { + static const int mw = 100, mh = MENU_VISIBLE * 10 + 4; + int mx = (128 - mw) / 2; + int my = (64 - mh) / 2; + display.setColor(DisplayDriver::DARK); + display.fillRect(mx, my, mw, mh); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(mx, my, mw, mh); + for (int i = 0; i < MENU_VISIBLE; i++) { + int item = _menu_scroll + i; + if (item >= M_COUNT) break; + int iy = my + 2 + i * 10; + bool sel = (item == _menu_sel); + if (sel) { + display.fillRect(mx + 1, iy - 1, mw - 2, 10); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(mx + 4, iy); + if (item == M_PLAY_STOP) + display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); + else + display.print(MENU_LABELS[item]); + display.setColor(DisplayDriver::LIGHT); + } + if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); } + if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); } + } + + return 200; + } + + bool handleInput(char c) override { + bool up = (c == KEY_UP); + bool down = (c == KEY_DOWN); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool enter = (c == KEY_ENTER); + bool menu_key = (c == KEY_CONTEXT_MENU); + bool cancel = (c == KEY_CANCEL); + + if (_menu_open) { + if (cancel) { _menu_open = false; return true; } + if (up && _menu_sel > 0) { + _menu_sel--; + if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel; + return true; + } + if (down && _menu_sel < M_COUNT - 1) { + _menu_sel++; + if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1; + return true; + } + if (!enter) return true; + + _menu_open = false; + switch ((MenuOpt)_menu_sel) { + case M_PLAY_STOP: + if (_task->isMelodyPlaying()) { + _task->stopMelody(); + } else if (_len > 0) { + char buf[220]; + buildRTTTL(buf, sizeof(buf)); + _task->playMelody(buf); + } + break; + case M_DURATION: + if (_cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03; + _notes[_cursor] = packNote(p, o, di); + } + break; + case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break; + case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break; + case M_INSERT: + if (_len < MAX_NOTES) { + int ins = (_cursor < _len) ? _cursor + 1 : _cursor; + for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; + _notes[ins] = packNote(1, 5, 1); + _len++; + _cursor = ins; + clampScroll(); + } + break; + case M_DELETE: + if (_len > 0 && _cursor < _len) { + for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; + _len--; + if (_cursor >= _len && _len > 0) _cursor = _len - 1; + else if (_len == 0) _cursor = 0; + clampScroll(); + } + break; + case M_SAVE: + if (_prefs) { + _prefs->ringtone_bpm_idx = _bpm_idx; + _prefs->ringtone_len = _len; + memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + the_mesh.savePrefs(); + } + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + case M_DISCARD: + _task->stopMelody(); + _task->gotoToolsScreen(); + break; + default: break; + } + return true; + } + + if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; } + if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } + + if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } + if (right) { + int max_cur = (_len < MAX_NOTES) ? _len : _len - 1; + if (_cursor < max_cur) { _cursor++; clampScroll(); return true; } + } + + if ((up || down) && _cursor == _len && _len < MAX_NOTES) { + _notes[_len] = packNote(1, 5, 1); + _len++; + clampScroll(); + } + if ((up || down) && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + if (up) p = (p + 1) & 0x07; + if (down) p = (p + 7) & 0x07; + _notes[_cursor] = packNote(p, o, di); + previewNote(_notes[_cursor]); + return true; + } + + if (enter && _cursor < _len) { + uint8_t p = notePitch(_notes[_cursor]); + uint8_t o = noteOctave(_notes[_cursor]); + uint8_t di = noteDurIdx(_notes[_cursor]); + if (p != 0) o = (o < 6) ? o + 1 : 4; + _notes[_cursor] = packNote(p, o, di); + previewNote(_notes[_cursor]); + return true; + } + if (enter && _cursor == _len && _len < MAX_NOTES) { + _notes[_len] = packNote(1, 5, 1); + _len++; + clampScroll(); + previewNote(_notes[_cursor]); + return true; + } + return false; + } +}; + +const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; +const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; +const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; +const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; +const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { + "Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" +}; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h new file mode 100644 index 00000000..d2cf363e --- /dev/null +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -0,0 +1,51 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp just before HomeScreen. + +class ToolsScreen : public UIScreen { + UITask* _task; + int _sel; + + static const int ITEM_COUNT = 2; + static const char* ITEMS[ITEM_COUNT]; + +public: + ToolsScreen(UITask* task) : _task(task), _sel(0) {} + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "TOOLS"); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < ITEM_COUNT; i++) { + int y = 12 + i * 12; + bool sel = (i == _sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), 11); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + display.print(ITEMS[i]); + } + display.setColor(DisplayDriver::LIGHT); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_UP && _sel > 0) { _sel--; return true; } + if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; } + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } + if (c == KEY_ENTER) { + if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } + if (_sel == 1) { _task->gotoBotScreen(); return true; } + } + return false; + } +}; +const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 4def736f..ebfbdb0b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1744,731 +1744,10 @@ public: } }; -// ── RingtoneEditorScreen ────────────────────────────────────────────────────── -class RingtoneEditorScreen : public UIScreen { - UITask* _task; - NodePrefs* _prefs; - - static const int MAX_NOTES = 32; - static const int VISIBLE_NOTES = 7; - static const int CELL_W = 18; - static const int NOTES_Y = 12; - static const int CELL_H = 14; - static const int MENU_VISIBLE = 5; - - static const uint16_t BPM_OPTS[5]; - static const uint8_t DUR_VALS[4]; - static const char* DUR_LABELS[4]; - static const char PITCH_NAMES[8]; // lowercase rtttl names - - uint8_t _notes[MAX_NOTES]; - uint8_t _len; - uint8_t _bpm_idx; - int _cursor; - int _scroll; - bool _menu_open; - int _menu_sel; - int _menu_scroll; - - enum MenuOpt { - M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN, - M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT - }; - static const char* MENU_LABELS[M_COUNT]; - - static uint8_t notePitch(uint8_t b) { return b & 0x07; } - static uint8_t noteOctave(uint8_t b) { return ((b >> 3) & 0x03) + 4; } - static uint8_t noteDurIdx(uint8_t b) { return (b >> 5) & 0x03; } - static uint8_t packNote(uint8_t pitch, uint8_t octave, uint8_t dur_idx) { - return (pitch & 0x07) | (((octave - 4) & 0x03) << 3) | ((dur_idx & 0x03) << 5); - } - - void clampScroll() { - if (_cursor < _scroll) _scroll = _cursor; - if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1; - if (_scroll < 0) _scroll = 0; - } - - void buildRTTTL(char* buf, int buf_len) { - uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2]; - int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm); - for (int i = 0; i < _len && pos < buf_len - 8; i++) { - if (i > 0 && pos < buf_len - 1) buf[pos++] = ','; - uint8_t pitch = notePitch(_notes[i]); - uint8_t octave = noteOctave(_notes[i]); - uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])]; - if (pitch == 0) - pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val); - else - pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave); - } - if (pos < buf_len) buf[pos] = '\0'; - } - - void previewNote(uint8_t note_byte) { - uint8_t pitch = notePitch(note_byte); - if (pitch == 0) { _task->stopMelody(); return; } - char buf[28]; - snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); - _task->playMelody(buf); - } - -public: - RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - - void enter() { - _bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2; - _len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0; - if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes)); - _cursor = 0; - _scroll = 0; - _menu_open = false; - _menu_sel = 0; - _menu_scroll = 0; - } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - - char hdr[32]; - snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES); - display.setCursor(0, 0); - display.print(hdr); - display.fillRect(0, 10, display.width(), 1); - - for (int i = 0; i < VISIBLE_NOTES; i++) { - int ni = _scroll + i; - int cx = i * CELL_W; - bool sel = (ni == _cursor); - if (ni < _len) { - uint8_t pitch = notePitch(_notes[ni]); - uint8_t octave = noteOctave(_notes[ni]); - char label[3]; - if (pitch == 0) { - label[0] = '-'; label[1] = '-'; label[2] = '\0'; - } else { - label[0] = PITCH_NAMES[pitch] - 32; // uppercase - label[1] = '0' + octave; - label[2] = '\0'; - } - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H); - } - display.setCursor(cx + 3, NOTES_Y + 3); - display.print(label); - display.setColor(DisplayDriver::LIGHT); - } else if (ni == _len && _len < MAX_NOTES) { - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(cx + 6, NOTES_Y + 3); - display.print("+"); - display.setColor(DisplayDriver::LIGHT); - } - } - - int info_y = NOTES_Y + CELL_H + 2; - if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); } - if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); } - if (_cursor < _len) { - char info[24]; - snprintf(info, sizeof(info), "oct:%d dur:%s", - noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]); - display.setCursor(10, info_y); - display.print(info); - } else if (_cursor == _len) { - display.setCursor(10, info_y); - display.print("U/D to add note"); - } - - display.setCursor(0, 54); - display.print("ENT:oct MENU:opts"); - - if (_menu_open) { - static const int mw = 100, mh = MENU_VISIBLE * 10 + 4; - int mx = (128 - mw) / 2; - int my = (64 - mh) / 2; - display.setColor(DisplayDriver::DARK); - display.fillRect(mx, my, mw, mh); - display.setColor(DisplayDriver::LIGHT); - display.drawRect(mx, my, mw, mh); - for (int i = 0; i < MENU_VISIBLE; i++) { - int item = _menu_scroll + i; - if (item >= M_COUNT) break; - int iy = my + 2 + i * 10; - bool sel = (item == _menu_sel); - if (sel) { - display.fillRect(mx + 1, iy - 1, mw - 2, 10); - display.setColor(DisplayDriver::DARK); - } - display.setCursor(mx + 4, iy); - if (item == M_PLAY_STOP) - display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); - else - display.print(MENU_LABELS[item]); - display.setColor(DisplayDriver::LIGHT); - } - if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); } - if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); } - } - - return 200; - } - - bool handleInput(char c) override { - bool up = (c == KEY_UP); - bool down = (c == KEY_DOWN); - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); - bool enter = (c == KEY_ENTER); - bool menu_key = (c == KEY_CONTEXT_MENU); - bool cancel = (c == KEY_CANCEL); - - if (_menu_open) { - if (cancel) { _menu_open = false; return true; } - if (up && _menu_sel > 0) { - _menu_sel--; - if (_menu_sel < _menu_scroll) _menu_scroll = _menu_sel; - return true; - } - if (down && _menu_sel < M_COUNT - 1) { - _menu_sel++; - if (_menu_sel >= _menu_scroll + MENU_VISIBLE) _menu_scroll = _menu_sel - MENU_VISIBLE + 1; - return true; - } - if (!enter) return true; - - _menu_open = false; - switch ((MenuOpt)_menu_sel) { - case M_PLAY_STOP: - if (_task->isMelodyPlaying()) { - _task->stopMelody(); - } else if (_len > 0) { - char buf[220]; - buildRTTTL(buf, sizeof(buf)); - _task->playMelody(buf); - } - break; - case M_DURATION: - if (_cursor < _len) { - uint8_t p = notePitch(_notes[_cursor]); - uint8_t o = noteOctave(_notes[_cursor]); - uint8_t di = (noteDurIdx(_notes[_cursor]) + 1) & 0x03; - _notes[_cursor] = packNote(p, o, di); - } - break; - case M_BPM_UP: if (_bpm_idx < 4) _bpm_idx++; break; - case M_BPM_DOWN: if (_bpm_idx > 0) _bpm_idx--; break; - case M_INSERT: - if (_len < MAX_NOTES) { - int ins = (_cursor < _len) ? _cursor + 1 : _cursor; - for (int i = _len; i > ins; i--) _notes[i] = _notes[i - 1]; - _notes[ins] = packNote(1, 5, 1); - _len++; - _cursor = ins; - clampScroll(); - } - break; - case M_DELETE: - if (_len > 0 && _cursor < _len) { - for (int i = _cursor; i < _len - 1; i++) _notes[i] = _notes[i + 1]; - _len--; - if (_cursor >= _len && _len > 0) _cursor = _len - 1; - else if (_len == 0) _cursor = 0; - clampScroll(); - } - break; - case M_SAVE: - if (_prefs) { - _prefs->ringtone_bpm_idx = _bpm_idx; - _prefs->ringtone_len = _len; - memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); - the_mesh.savePrefs(); - } - _task->stopMelody(); - _task->gotoToolsScreen(); - break; - case M_DISCARD: - _task->stopMelody(); - _task->gotoToolsScreen(); - break; - default: break; - } - return true; - } - - if (cancel) { _task->stopMelody(); _task->gotoToolsScreen(); return true; } - if (menu_key) { _menu_open = true; _menu_sel = 0; _menu_scroll = 0; return true; } - - if (left && _cursor > 0) { _cursor--; clampScroll(); return true; } - if (right) { - int max_cur = (_len < MAX_NOTES) ? _len : _len - 1; - if (_cursor < max_cur) { _cursor++; clampScroll(); return true; } - } - - if ((up || down) && _cursor == _len && _len < MAX_NOTES) { - _notes[_len] = packNote(1, 5, 1); - _len++; - clampScroll(); - } - if ((up || down) && _cursor < _len) { - uint8_t p = notePitch(_notes[_cursor]); - uint8_t o = noteOctave(_notes[_cursor]); - uint8_t di = noteDurIdx(_notes[_cursor]); - if (up) p = (p + 1) & 0x07; - if (down) p = (p + 7) & 0x07; - _notes[_cursor] = packNote(p, o, di); - previewNote(_notes[_cursor]); - return true; - } - - if (enter && _cursor < _len) { - uint8_t p = notePitch(_notes[_cursor]); - uint8_t o = noteOctave(_notes[_cursor]); - uint8_t di = noteDurIdx(_notes[_cursor]); - if (p != 0) o = (o < 6) ? o + 1 : 4; - _notes[_cursor] = packNote(p, o, di); - previewNote(_notes[_cursor]); - return true; - } - if (enter && _cursor == _len && _len < MAX_NOTES) { - _notes[_len] = packNote(1, 5, 1); - _len++; - clampScroll(); - previewNote(_notes[_cursor]); - return true; - } - return false; - } -}; - -const uint16_t RingtoneEditorScreen::BPM_OPTS[5] = { 60, 90, 120, 150, 180 }; -const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; -const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; -const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; -const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { - "Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" -}; - -// ── BotScreen ───────────────────────────────────────────────────────────────── -class BotScreen : public UIScreen { - UITask* _task; - NodePrefs* _prefs; - - static const int ITEM_COUNT = 4; - static const int ITEM_H = 11; - static const int START_Y = 12; - static const int VAL_X = 70; - - // keyboard state (reused for trigger and reply fields) - int _kb_field; // -1=off, 2=trigger, 3=reply - 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; - - // channel + DM contact caches (refreshed on enter) - static const int MAX_BOT_DM = 16; - - int _num_channels; - uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - - int _num_dm; - uint8_t _dm_pubkeys[MAX_BOT_DM][4]; - char _dm_names[MAX_BOT_DM][32]; - - void refreshChannels() { - _num_channels = 0; - ChannelDetails ch; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { - if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') - _channel_indices[_num_channels++] = (uint8_t)i; - } - } - - void refreshContacts() { - _num_dm = 0; - ContactInfo ci; - int n = the_mesh.getNumContacts(); - for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) { - if (!the_mesh.getContactByIdx(i, ci)) continue; - if (ci.type != ADV_TYPE_CHAT) continue; - if (!(ci.flags & 0x01)) continue; // favourites only - memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4); - strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1); - _dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0'; - _num_dm++; - } - } - - // returns combined index in [channels..., DM contacts...] - int currentTargetIdx() const { - if (_prefs->bot_target_type == 0) { - for (int i = 0; i < _num_channels; i++) - if (_channel_indices[i] == _prefs->bot_channel_idx) return i; - return 0; - } else { - for (int i = 0; i < _num_dm; i++) - if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0) - return _num_channels + i; - return _num_channels; // fallback: first DM - } - } - -public: - BotScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - - void enter() { - _sel = 0; - _kb_field = -1; - refreshChannels(); - refreshContacts(); - } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - - if (_kb_field >= 0) { - // keyboard mode — uses same layout as global keyboard - const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:"; - 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, KB_SEP_Y, display.width(), 1); - - // 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 = ch - 'a' + 'A'; - char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; - int cx = col * KB_CELL_W; - if (sel) { - 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(ch_buf); - } - } - - // 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 * 25; - if (sel || active) { - 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 + 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; - } - - display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); - display.fillRect(0, 10, display.width(), 1); - - static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" }; - for (int i = 0; i < ITEM_COUNT; i++) { - int y = START_Y + i * ITEM_H; - bool sel = (i == _sel); - if (sel) { - display.fillRect(0, y - 1, display.width(), ITEM_H); - display.setColor(DisplayDriver::DARK); - } - display.setCursor(2, y); - if (i == 1) - display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact"); - else - display.print(labels[i]); - display.setCursor(VAL_X, y); - - if (i == 0) { - display.print(_prefs->bot_enabled ? "ON" : "OFF"); - } else if (i == 1) { - int total = _num_channels + _num_dm; - if (total == 0) { - display.print("none"); - } else if (_prefs->bot_target_type == 0) { - ChannelDetails ch; - if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name); - else - display.print("?"); - } else { - bool found = false; - for (int j = 0; j < _num_dm && !found; j++) { - if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) { - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]); - found = true; - } - } - if (!found) display.print("?"); - } - } else if (i == 2) { - const char* tr = _prefs->bot_trigger; - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)"); - } else { - const char* rp = _prefs->bot_reply; - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); - } - display.setColor(DisplayDriver::LIGHT); - } - - display.setCursor(0, 54); - display.print("ENT:edit CANCEL:save"); - return 300; - } - - bool handleInput(char c) override { - bool up = (c == KEY_UP); - bool down = (c == KEY_DOWN); - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool right = (c == KEY_RIGHT || c == KEY_NEXT); - bool enter = (c == KEY_ENTER); - bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); - - if (_kb_field >= 0) { - // 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 (enter) { - 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 = ch - 'a' + 'A'; - _kb_buf[_kb_len++] = ch; - _kb_buf[_kb_len] = '\0'; - } - } else { - 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 3: - _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'; - } else { - strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1); - _prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0'; - } - _kb_field = -1; - break; - } - } - return true; - } - return true; - } - - if (cancel) { - the_mesh.savePrefs(); - _task->gotoToolsScreen(); - return true; - } - if (up && _sel > 0) { _sel--; return true; } - if (down && _sel < ITEM_COUNT - 1) { _sel++; return true; } - - if (_sel == 0 && (enter || left || right)) { - _prefs->bot_enabled ^= 1; - return true; - } - if (_sel == 1) { - int total = _num_channels + _num_dm; - if (total == 0) return false; - int idx = currentTargetIdx(); - if (right || enter) idx = (idx + 1) % total; - if (left) idx = (idx + total - 1) % total; - if (left || right || enter) { - if (idx < _num_channels) { - _prefs->bot_target_type = 0; - _prefs->bot_channel_idx = _channel_indices[idx]; - } else { - int di = idx - _num_channels; - _prefs->bot_target_type = 1; - memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4); - } - return true; - } - } - if ((_sel == 2 || _sel == 3) && enter) { - _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; - } else { - strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1); - _kb_maxlen = sizeof(_prefs->bot_reply) - 1; - } - _kb_buf[sizeof(_kb_buf) - 1] = '\0'; - _kb_len = strlen(_kb_buf); - return true; - } - return false; - } -}; - -// ── ToolsScreen ─────────────────────────────────────────────────────────────── -class ToolsScreen : public UIScreen { - UITask* _task; - int _sel; - - static const int ITEM_COUNT = 2; - static const char* ITEMS[ITEM_COUNT]; - -public: - ToolsScreen(UITask* task) : _task(task), _sel(0) {} - - int render(DisplayDriver& display) override { - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width() / 2, 0, "TOOLS"); - display.fillRect(0, 10, display.width(), 1); - - for (int i = 0; i < ITEM_COUNT; i++) { - int y = 12 + i * 12; - bool sel = (i == _sel); - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), 11); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(8, y); - display.print(ITEMS[i]); - } - display.setColor(DisplayDriver::LIGHT); - return 500; - } - - bool handleInput(char c) override { - if (c == KEY_UP && _sel > 0) { _sel--; return true; } - if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; } - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } - if (c == KEY_ENTER) { - if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } - if (_sel == 1) { _task->gotoBotScreen(); return true; } - } - return false; - } -}; -const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" }; +// ── Custom screens (separate files to ease upstream merges) ─────────────────── +#include "RingtoneEditorScreen.h" +#include "BotScreen.h" +#include "ToolsScreen.h" // ── HomeScreen ──────────────────────────────────────────────────────────────── class HomeScreen : public UIScreen { From dd2c09174f9c3b4202fc914d34fc0025e4c6f771 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 14:51:19 +0200 Subject: [PATCH 049/183] Extract bot logic from MyMesh into MyMeshBot.h Move tryBotReplyDM() and tryBotReplyChannel() implementations to MyMeshBot.h (included at end of MyMesh.cpp). onMessageRecv and onChannelMessageRecv now contain only a single-line call each, minimising the diff against upstream in those methods. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 64 ++------------------------- examples/companion_radio/MyMesh.h | 3 ++ examples/companion_radio/MyMeshBot.h | 66 ++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 60 deletions(-) create mode 100644 examples/companion_radio/MyMeshBot.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6b2457d3..e42afa08 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -515,37 +515,7 @@ void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t markConnectionActive(from); // in case this is from a server, and we have a connection queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); - // DM auto-reply bot - if (_prefs.bot_enabled && _prefs.bot_target_type == 1 && - _prefs.bot_trigger[0] && _prefs.bot_reply[0] && - millis() - _bot_last_reply_ms > 10000UL) { - // all-zero pubkey = any sender; otherwise match first 4 bytes - bool key_ok = (_prefs.bot_dm_pubkey[0] == 0 && _prefs.bot_dm_pubkey[1] == 0 && - _prefs.bot_dm_pubkey[2] == 0 && _prefs.bot_dm_pubkey[3] == 0) || - memcmp(from.id.pub_key, _prefs.bot_dm_pubkey, 4) == 0; - if (key_ok) { - const char* t = text; - const char* tr = _prefs.bot_trigger; - int tlen = strlen(tr); - bool matched = false; - for (; *t && !matched; t++) { - int i = 0; - while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; - if (i == tlen) matched = true; - } - 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, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) - _bot_last_reply_ms = millis(); - } - } - } + tryBotReplyDM(from, text); } void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, @@ -605,35 +575,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len, 0); #endif - // auto-reply bot - if (_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply[0] && - channel_idx == _prefs.bot_channel_idx && - millis() - _bot_last_reply_ms > 10000UL) { - // case-insensitive substring match - const char* t = text; - const char* tr = _prefs.bot_trigger; - int tlen = strlen(tr); - bool matched = false; - for (; *t && !matched; t++) { - int i = 0; - while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; - if (i == tlen) matched = true; - } - if (matched) { - ChannelDetails ch; - if (getChannel(channel_idx, ch)) { - 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); - int rlen = strlen(expanded); - if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) - _bot_last_reply_ms = millis(); - } - } - } + tryBotReplyChannel(channel_idx, text); } void MyMesh::onChannelDataRecv(const mesh::GroupChannel &channel, mesh::Packet *pkt, uint16_t data_type, @@ -2266,3 +2208,5 @@ bool MyMesh::advert() { return false; } } + +#include "MyMeshBot.h" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index c9eee993..61210e6c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -179,6 +179,9 @@ public: #endif private: + void tryBotReplyDM(const ContactInfo& from, const char* text); + void tryBotReplyChannel(uint8_t channel_idx, const char* text); + void writeOKFrame(); void writeErrFrame(uint8_t err_code); void writeDisabledFrame(); diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h new file mode 100644 index 00000000..8099e9b1 --- /dev/null +++ b/examples/companion_radio/MyMeshBot.h @@ -0,0 +1,66 @@ +#pragma once +// Bot logic — not part of upstream MyMesh.cpp. +// Included at the bottom of MyMesh.cpp after all class definitions. + +void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { + if (!(_prefs.bot_enabled && _prefs.bot_target_type == 1 && + _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + millis() - _bot_last_reply_ms > 10000UL)) + return; + + // all-zero pubkey = any sender; otherwise match first 4 bytes + bool key_ok = (_prefs.bot_dm_pubkey[0] == 0 && _prefs.bot_dm_pubkey[1] == 0 && + _prefs.bot_dm_pubkey[2] == 0 && _prefs.bot_dm_pubkey[3] == 0) || + memcmp(from.id.pub_key, _prefs.bot_dm_pubkey, 4) == 0; + if (!key_ok) return; + + const char* tr = _prefs.bot_trigger; + int tlen = strlen(tr); + bool matched = false; + for (const char* t = text; *t && !matched; t++) { + int i = 0; + while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; + if (i == tlen) matched = true; + } + if (!matched) return; + + 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, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) + _bot_last_reply_ms = millis(); +} + +void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { + if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + channel_idx == _prefs.bot_channel_idx && + millis() - _bot_last_reply_ms > 10000UL)) + return; + + const char* tr = _prefs.bot_trigger; + int tlen = strlen(tr); + bool matched = false; + for (const char* t = text; *t && !matched; t++) { + int i = 0; + while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; + if (i == tlen) matched = true; + } + if (!matched) return; + + ChannelDetails ch; + if (!getChannel(channel_idx, ch)) return; + + 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); + int rlen = strlen(expanded); + if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) + _bot_last_reply_ms = millis(); +} From af7da7906aa0d5b12e080d1804cb1362eff6ad3a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 15:07:33 +0200 Subject: [PATCH 050/183] Fix: tryBotReplyChannel must check bot_target_type == 0 Without the check, a DM-mode bot (bot_target_type==1) would also reply to channel messages whenever the trigger phrase and channel index happened to match. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 8099e9b1..728e9882 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -36,7 +36,8 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { } void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { - if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + if (!(_prefs.bot_enabled && _prefs.bot_target_type == 0 && + _prefs.bot_trigger[0] && _prefs.bot_reply[0] && channel_idx == _prefs.bot_channel_idx && millis() - _bot_last_reply_ms > 10000UL)) return; From 71635e5e6f1a62191bbed407d885de2f2aa39669 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 20:12:54 +0200 Subject: [PATCH 051/183] Bot: DM+channel modes with separate replies; buzzer auto-mute; UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bot redesign: DM bot active for all private messages (no pubkey filter), channel bot optional with separate enable flag; both can run simultaneously - Bot: one shared trigger, separate reply texts for DM and channel - Buzzer: add Auto mode (mutes when BT connected, unmutes on disconnect); per-channel force-on overrides still work as intended - Settings: defer flash writes to menu exit (only when dirty) - Settings: add Seconds toggle (hide clock seconds, refresh drops 1s→60s) - Fullscreen msg preview: fix truncation (fmsg buf 79→140 chars) - RingtoneEditor: fix melody playback cut after first note (RTTTL lib holds pointer — moved play buffer to member variable) - BotScreen: remove key hint footer - .gitattributes: protect README.md from upstream merge conflicts Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 1 + examples/companion_radio/DataStore.cpp | 18 ++- examples/companion_radio/MyMesh.cpp | 6 +- examples/companion_radio/MyMeshBot.h | 16 +- examples/companion_radio/NodePrefs.h | 14 +- examples/companion_radio/ui-new/BotScreen.h | 139 +++++++----------- .../ui-new/RingtoneEditorScreen.h | 25 ++-- examples/companion_radio/ui-new/UITask.cpp | 72 +++++++-- examples/companion_radio/ui-new/UITask.h | 2 + 9 files changed, 154 insertions(+), 139 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b0ff8112 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +README.md merge=ours diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 9832de42..2d719396 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -255,12 +255,16 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); if (file.available()) { file.read((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); + file.read((uint8_t *)&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled)); file.read((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); file.read((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); - file.read((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply)); + file.read((uint8_t *)_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm)); + file.read((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); if (file.available()) { - file.read((uint8_t *)&_prefs.bot_target_type, sizeof(_prefs.bot_target_type)); - file.read((uint8_t *)_prefs.bot_dm_pubkey, sizeof(_prefs.bot_dm_pubkey)); + file.read((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); + if (file.available()) { + file.read((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + } } } } @@ -323,11 +327,13 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes)); file.write((uint8_t *)&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask)); file.write((uint8_t *)&_prefs.bot_enabled, sizeof(_prefs.bot_enabled)); + file.write((uint8_t *)&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled)); file.write((uint8_t *)&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx)); file.write((uint8_t *)_prefs.bot_trigger, sizeof(_prefs.bot_trigger)); - file.write((uint8_t *)_prefs.bot_reply, sizeof(_prefs.bot_reply)); - file.write((uint8_t *)&_prefs.bot_target_type, sizeof(_prefs.bot_target_type)); - file.write((uint8_t *)_prefs.bot_dm_pubkey, sizeof(_prefs.bot_dm_pubkey)); + file.write((uint8_t *)_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm)); + file.write((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); + file.write((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); + file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index e42afa08..4d8669fc 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -876,11 +876,11 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.ringtone_len = 0; // no custom ringtone by default _prefs.home_pages_mask = 0x01FF; // all pages visible _prefs.bot_enabled = 0; + _prefs.bot_channel_enabled = 0; _prefs.bot_channel_idx = 0; _prefs.bot_trigger[0] = '\0'; - _prefs.bot_reply[0] = '\0'; - _prefs.bot_target_type = 0; - memset(_prefs.bot_dm_pubkey, 0, sizeof(_prefs.bot_dm_pubkey)); + _prefs.bot_reply_dm[0] = '\0'; + _prefs.bot_reply_ch[0] = '\0'; _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 728e9882..a413f76a 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -3,17 +3,10 @@ // Included at the bottom of MyMesh.cpp after all class definitions. void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { - if (!(_prefs.bot_enabled && _prefs.bot_target_type == 1 && - _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_dm[0] && millis() - _bot_last_reply_ms > 10000UL)) return; - // all-zero pubkey = any sender; otherwise match first 4 bytes - bool key_ok = (_prefs.bot_dm_pubkey[0] == 0 && _prefs.bot_dm_pubkey[1] == 0 && - _prefs.bot_dm_pubkey[2] == 0 && _prefs.bot_dm_pubkey[3] == 0) || - memcmp(from.id.pub_key, _prefs.bot_dm_pubkey, 4) == 0; - if (!key_ok) return; - const char* tr = _prefs.bot_trigger; int tlen = strlen(tr); bool matched = false; @@ -26,7 +19,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { uint32_t ts = getRTCClock()->getCurrentTime(); char expanded[200]; - expandMsg(_prefs.bot_reply, expanded, sizeof(expanded), + expandMsg(_prefs.bot_reply_dm, expanded, sizeof(expanded), sensors.node_lat, sensors.node_lon, sensors.node_lat != 0.0 || sensors.node_lon != 0.0, ts, _prefs.tz_offset_hours); @@ -36,8 +29,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { } void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { - if (!(_prefs.bot_enabled && _prefs.bot_target_type == 0 && - _prefs.bot_trigger[0] && _prefs.bot_reply[0] && + if (!(_prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && channel_idx == _prefs.bot_channel_idx && millis() - _bot_last_reply_ms > 10000UL)) return; @@ -57,7 +49,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { uint32_t ts = getRTCClock()->getCurrentTime(); char expanded[200]; - expandMsg(_prefs.bot_reply, expanded, sizeof(expanded), + expandMsg(_prefs.bot_reply_ch, expanded, sizeof(expanded), sensors.node_lat, sensors.node_lon, sensors.node_lat != 0.0 || sensors.node_lon != 0.0, ts, _prefs.tz_offset_hours); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 68bf8dc6..4a8d0bcb 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -49,10 +49,12 @@ struct NodePrefs { // persisted to file uint8_t ringtone_len; // number of notes in custom ringtone (0 = use default) uint8_t ringtone_notes[32]; // packed: bits0-2=pitch, bits3-4=octave-4, bits5-6=dur_idx uint16_t home_pages_mask; // bitmask of visible home pages (bit0=Clock..bit8=Shutdown); 0=all visible - uint8_t bot_enabled; // 0=disabled, 1=enabled - uint8_t bot_channel_idx; // channel index to monitor (when bot_target_type==0) - char bot_trigger[64]; // trigger phrase (case-insensitive contains match) - char bot_reply[140]; // auto-reply text - uint8_t bot_target_type; // 0=channel (default), 1=DM contact - uint8_t bot_dm_pubkey[4]; // pubkey prefix of DM target; all-zero = any DM sender + uint8_t bot_enabled; // 0=disabled, 1=DM bot active (responds to all DMs) + uint8_t bot_channel_enabled; // 0=disabled, 1=channel bot active for bot_channel_idx + uint8_t bot_channel_idx; // channel index for channel bot + char bot_trigger[64]; // trigger phrase (case-insensitive contains match) + char bot_reply_dm[140]; // auto-reply text for DM + char bot_reply_ch[140]; // auto-reply text for channel + uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s + uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 6c833f9b..1603ac48 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -6,11 +6,14 @@ class BotScreen : public UIScreen { UITask* _task; NodePrefs* _prefs; - static const int ITEM_COUNT = 4; + // Items: 0=Enable(DM), 1=Channel, 2=Trigger, 3=Reply DM, 4=Reply Ch + static const int ITEM_COUNT = 5; static const int ITEM_H = 11; static const int START_Y = 12; static const int VAL_X = 70; + int _sel; + // keyboard state (reused for trigger and reply fields) int _kb_field; // -1=off, 2=trigger, 3=reply char _kb_buf[KB_MAX_LEN + 1]; @@ -21,18 +24,10 @@ class BotScreen : public UIScreen { bool _kb_ph_mode; int _kb_ph_sel; - int _sel; - - // channel + DM contact caches (refreshed on enter) - static const int MAX_BOT_DM = 16; - + // channel cache (refreshed on enter) int _num_channels; uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - int _num_dm; - uint8_t _dm_pubkeys[MAX_BOT_DM][4]; - char _dm_names[MAX_BOT_DM][32]; - void refreshChannels() { _num_channels = 0; ChannelDetails ch; @@ -42,33 +37,12 @@ class BotScreen : public UIScreen { } } - void refreshContacts() { - _num_dm = 0; - ContactInfo ci; - int n = the_mesh.getNumContacts(); - for (int i = 0; i < n && _num_dm < MAX_BOT_DM; i++) { - if (!the_mesh.getContactByIdx(i, ci)) continue; - if (ci.type != ADV_TYPE_CHAT) continue; - if (!(ci.flags & 0x01)) continue; // favourites only - memcpy(_dm_pubkeys[_num_dm], ci.id.pub_key, 4); - strncpy(_dm_names[_num_dm], ci.name, sizeof(_dm_names[0]) - 1); - _dm_names[_num_dm][sizeof(_dm_names[0]) - 1] = '\0'; - _num_dm++; - } - } - - // returns combined index in [channels..., DM contacts...] - int currentTargetIdx() const { - if (_prefs->bot_target_type == 0) { - for (int i = 0; i < _num_channels; i++) - if (_channel_indices[i] == _prefs->bot_channel_idx) return i; - return 0; - } else { - for (int i = 0; i < _num_dm; i++) - if (memcmp(_dm_pubkeys[i], _prefs->bot_dm_pubkey, 4) == 0) - return _num_channels + i; - return _num_channels; // fallback: first DM - } + // Returns index into _channel_indices[] for current bot_channel_idx, or -1 if not found/disabled. + int currentChannelListIdx() const { + if (!_prefs->bot_channel_enabled) return -1; + for (int i = 0; i < _num_channels; i++) + if (_channel_indices[i] == _prefs->bot_channel_idx) return i; + return -1; } public: @@ -78,7 +52,6 @@ public: _sel = 0; _kb_field = -1; refreshChannels(); - refreshContacts(); } int render(DisplayDriver& display) override { @@ -86,7 +59,6 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_field >= 0) { - // keyboard mode — uses same layout as global keyboard const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:"; const char* disp_start = _kb_buf; int disp_len = _kb_len; @@ -97,7 +69,6 @@ public: display.print(preview); display.fillRect(0, KB_SEP_Y, display.width(), 1); - // 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++) { @@ -118,7 +89,6 @@ public: } } - // 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); @@ -136,7 +106,6 @@ public: 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); @@ -165,7 +134,7 @@ public: display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); display.fillRect(0, 10, display.width(), 1); - static const char* labels[] = { "Enable", NULL, "Trigger", "Reply" }; + static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply DM", "Reply Ch" }; for (int i = 0; i < ITEM_COUNT; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _sel); @@ -174,46 +143,33 @@ public: display.setColor(DisplayDriver::DARK); } display.setCursor(2, y); - if (i == 1) - display.print(_prefs->bot_target_type == 0 ? "Channel" : "Contact"); - else - display.print(labels[i]); + display.print(labels[i]); display.setCursor(VAL_X, y); if (i == 0) { display.print(_prefs->bot_enabled ? "ON" : "OFF"); } else if (i == 1) { - int total = _num_channels + _num_dm; - if (total == 0) { - display.print("none"); - } else if (_prefs->bot_target_type == 0) { + if (!_prefs->bot_channel_enabled || _num_channels == 0) { + display.print("OFF"); + } else { ChannelDetails ch; - if (_num_channels > 0 && the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) + if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name); else display.print("?"); - } else { - bool found = false; - for (int j = 0; j < _num_dm && !found; j++) { - if (memcmp(_dm_pubkeys[j], _prefs->bot_dm_pubkey, 4) == 0) { - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, _dm_names[j]); - found = true; - } - } - if (!found) display.print("?"); } } else if (i == 2) { const char* tr = _prefs->bot_trigger; display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)"); + } else if (i == 3) { + const char* rp = _prefs->bot_reply_dm; + display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); } else { - const char* rp = _prefs->bot_reply; + const char* rp = _prefs->bot_reply_ch; display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); } display.setColor(DisplayDriver::LIGHT); } - - display.setCursor(0, 54); - display.print("ENT:edit CANCEL:save"); return 300; } @@ -226,7 +182,6 @@ public: bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); if (_kb_field >= 0) { - // 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; } @@ -249,7 +204,7 @@ public: if (up) { if (_kb_row > 0) { _kb_row--; - if (_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward + if (_kb_row == KB_ROWS_CHAR - 1) _kb_col = _kb_col * KB_COLS_CHAR / KB_SPECIAL; } return true; @@ -257,15 +212,12 @@ public: if (down) { if (_kb_row < KB_ROWS_CHAR) { _kb_row++; - if (_kb_row == KB_ROWS_CHAR) // entering special row + if (_kb_row == KB_ROWS_CHAR) _kb_col = _kb_col * KB_SPECIAL / KB_COLS_CHAR; } return true; } - if (left) { - if (_kb_col > 0) _kb_col--; - 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++; @@ -293,13 +245,15 @@ public: _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'; + } else if (_kb_field == 3) { + strncpy(_prefs->bot_reply_dm, _kb_buf, sizeof(_prefs->bot_reply_dm) - 1); + _prefs->bot_reply_dm[sizeof(_prefs->bot_reply_dm) - 1] = '\0'; } else { - strncpy(_prefs->bot_reply, _kb_buf, sizeof(_prefs->bot_reply) - 1); - _prefs->bot_reply[sizeof(_prefs->bot_reply) - 1] = '\0'; + strncpy(_prefs->bot_reply_ch, _kb_buf, sizeof(_prefs->bot_reply_ch) - 1); + _prefs->bot_reply_ch[sizeof(_prefs->bot_reply_ch) - 1] = '\0'; } _kb_field = -1; break; @@ -323,24 +277,28 @@ public: return true; } if (_sel == 1) { - int total = _num_channels + _num_dm; - if (total == 0) return false; - int idx = currentTargetIdx(); - if (right || enter) idx = (idx + 1) % total; - if (left) idx = (idx + total - 1) % total; + if (_num_channels == 0) return false; + // Cycle: OFF → ch[0] → ch[1] → ... → OFF + int idx = currentChannelListIdx(); // -1 = OFF + if (right || enter) { + idx++; + if (idx >= _num_channels) idx = -1; // wrap to OFF + } + if (left) { + idx--; + if (idx < -1) idx = _num_channels - 1; + } if (left || right || enter) { - if (idx < _num_channels) { - _prefs->bot_target_type = 0; - _prefs->bot_channel_idx = _channel_indices[idx]; + if (idx < 0) { + _prefs->bot_channel_enabled = 0; } else { - int di = idx - _num_channels; - _prefs->bot_target_type = 1; - memcpy(_prefs->bot_dm_pubkey, _dm_pubkeys[di], 4); + _prefs->bot_channel_enabled = 1; + _prefs->bot_channel_idx = _channel_indices[idx]; } return true; } } - if ((_sel == 2 || _sel == 3) && enter) { + if ((_sel == 2 || _sel == 3 || _sel == 4) && enter) { _kb_field = _sel; _kb_row = 0; _kb_col = 0; @@ -350,9 +308,12 @@ public: if (_sel == 2) { strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1); _kb_maxlen = sizeof(_prefs->bot_trigger) - 1; + } else if (_sel == 3) { + strncpy(_kb_buf, _prefs->bot_reply_dm, sizeof(_kb_buf) - 1); + _kb_maxlen = sizeof(_prefs->bot_reply_dm) - 1; } else { - strncpy(_kb_buf, _prefs->bot_reply, sizeof(_kb_buf) - 1); - _kb_maxlen = sizeof(_prefs->bot_reply) - 1; + strncpy(_kb_buf, _prefs->bot_reply_ch, sizeof(_kb_buf) - 1); + _kb_maxlen = sizeof(_prefs->bot_reply_ch) - 1; } _kb_buf[sizeof(_kb_buf) - 1] = '\0'; _kb_len = strlen(_kb_buf); diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 2704042f..ab2b9d79 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -26,6 +26,7 @@ class RingtoneEditorScreen : public UIScreen { bool _menu_open; int _menu_sel; int _menu_scroll; + char _play_buf[220]; // persistent RTTTL buffer — library holds a pointer into it enum MenuOpt { M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN, @@ -46,28 +47,27 @@ class RingtoneEditorScreen : public UIScreen { if (_scroll < 0) _scroll = 0; } - void buildRTTTL(char* buf, int buf_len) { + void buildRTTTL() { uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2]; - int pos = snprintf(buf, buf_len, "Ring:d=8,o=5,b=%u:", bpm); - for (int i = 0; i < _len && pos < buf_len - 8; i++) { - if (i > 0 && pos < buf_len - 1) buf[pos++] = ','; + int pos = snprintf(_play_buf, sizeof(_play_buf), "Ring:d=8,o=5,b=%u:", bpm); + for (int i = 0; i < _len && pos < (int)sizeof(_play_buf) - 8; i++) { + if (i > 0 && pos < (int)sizeof(_play_buf) - 1) _play_buf[pos++] = ','; uint8_t pitch = notePitch(_notes[i]); uint8_t octave = noteOctave(_notes[i]); uint8_t dur_val = DUR_VALS[noteDurIdx(_notes[i])]; if (pitch == 0) - pos += snprintf(buf + pos, buf_len - pos, "%dp", dur_val); + pos += snprintf(_play_buf + pos, sizeof(_play_buf) - pos, "%dp", dur_val); else - pos += snprintf(buf + pos, buf_len - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave); + pos += snprintf(_play_buf + pos, sizeof(_play_buf) - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave); } - if (pos < buf_len) buf[pos] = '\0'; + if (pos < (int)sizeof(_play_buf)) _play_buf[pos] = '\0'; } void previewNote(uint8_t note_byte) { uint8_t pitch = notePitch(note_byte); if (pitch == 0) { _task->stopMelody(); return; } - char buf[28]; - snprintf(buf, sizeof(buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); - _task->playMelody(buf); + snprintf(_play_buf, sizeof(_play_buf), "P:d=16,o=5,b=240:%c%d", PITCH_NAMES[pitch], noteOctave(note_byte)); + _task->playMelody(_play_buf); } public: @@ -211,9 +211,8 @@ public: if (_task->isMelodyPlaying()) { _task->stopMelody(); } else if (_len > 0) { - char buf[220]; - buildRTTTL(buf, sizeof(buf)); - _task->playMelody(buf); + buildRTTTL(); + _task->playMelody(_play_buf); } break; case M_DURATION: diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ebfbdb0b..1a33d263 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -126,6 +126,7 @@ class SettingsScreen : public UIScreen { BRIGHTNESS, AUTO_OFF, BATT_DISPLAY, + CLOCK_SECONDS, // Sound section SECTION_SOUND, BUZZER, @@ -350,7 +351,9 @@ class SettingsScreen : public UIScreen { display.print("Buzzer"); display.setCursor(VAL_X, y); #ifdef PIN_BUZZER - display.print(_task->isBuzzerQuiet() ? "OFF" : "ON"); + { static const char* labels[] = { "ON", "OFF", "Auto" }; + int m = _task->getBuzzerMode(); + display.print(labels[m < 3 ? m : 0]); } #else display.print("N/A"); #endif @@ -399,6 +402,10 @@ class SettingsScreen : public UIScreen { display.setCursor(VAL_X, y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); + } else if (item == CLOCK_SECONDS) { + display.print("Seconds"); + display.setCursor(VAL_X, y); + display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); } else if (item == DM_FILTER) { display.print("DM"); display.setCursor(VAL_X, y); @@ -615,11 +622,11 @@ public: _edit_ph_mode = true; _edit_ph_sel = 0; } else { - // OK — save + // OK — commit to prefs, save deferred to settings exit if (p) { strncpy(p->custom_msgs[_edit_slot], _edit_buf, sizeof(p->custom_msgs[0]) - 1); p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; - the_mesh.savePrefs(); + _dirty = true; } _edit_slot = -1; } @@ -666,7 +673,8 @@ public: return right || left; } if (_selected == BUZZER && (left || right || enter)) { - _task->toggleBuzzer(); // saves immediately internally + _task->cycleBuzzerMode(); + _dirty = true; return true; } if (_selected == BUZZER_VOLUME) { @@ -721,6 +729,11 @@ public: if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } } + if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { + p->clock_hide_seconds ^= 1; + _dirty = true; + return true; + } if (_selected == DM_FILTER && p && (left || right || enter)) { p->dm_show_all = p->dm_show_all ? 0 : 1; _dirty = true; @@ -1237,7 +1250,7 @@ public: if (ring_pos >= 0) { const char* ftext = _hist[ring_pos].text; const char* fsep = strstr(ftext, ": "); - char fsender[22], fmsg[79]; + char fsender[22], fmsg[140]; if (fsep) { int nl = fsep - ftext; if (nl > 21) nl = 21; strncpy(fsender, ftext, nl); fsender[nl] = '\0'; @@ -1992,7 +2005,11 @@ public: char buf[24]; display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); - sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); + bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; + if (show_sec) + sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); + else + sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); display.drawTextCentered(display.width() / 2, 18, buf); display.setTextSize(1); @@ -2209,7 +2226,11 @@ public: display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); } } - return (_page == HomePage::CLOCK) ? 1000 : 5000; + if (_page == HomePage::CLOCK) { + bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; + return show_sec ? 1000 : 60000; + } + return 5000; } bool handleInput(char c) override { @@ -2604,6 +2625,13 @@ void UITask::loop() { userLedHandler(); #ifdef PIN_BUZZER + if (_node_prefs && _node_prefs->buzzer_auto) { + bool should_quiet = hasConnection(); + if (buzzer.isQuiet() != should_quiet) { + buzzer.quiet(should_quiet); + _next_refresh = 0; + } + } if (buzzer.isPlaying()) buzzer.loop(); #endif @@ -2776,17 +2804,41 @@ void UITask::setBuzzerVolumeLevel(uint8_t level) { } void UITask::toggleBuzzer() { - // Toggle buzzer quiet mode #ifdef PIN_BUZZER + if (_node_prefs) _node_prefs->buzzer_auto = 0; // exit auto mode if (buzzer.isQuiet()) { buzzer.quiet(false); notify(UIEventType::ack); } else { buzzer.quiet(true); } - _node_prefs->buzzer_quiet = buzzer.isQuiet(); + if (_node_prefs) _node_prefs->buzzer_quiet = buzzer.isQuiet(); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); - _next_refresh = 0; // trigger refresh + _next_refresh = 0; #endif } + +int UITask::getBuzzerMode() { +#ifdef PIN_BUZZER + if (_node_prefs && _node_prefs->buzzer_auto) return 2; + return buzzer.isQuiet() ? 1 : 0; +#else + return 1; +#endif +} + +void UITask::cycleBuzzerMode() { +#ifdef PIN_BUZZER + if (!_node_prefs) return; + int mode = getBuzzerMode(); + mode = (mode + 1) % 3; // ON(0) → OFF(1) → Auto(2) → ON + _node_prefs->buzzer_auto = (mode == 2) ? 1 : 0; + if (mode == 0) { buzzer.quiet(false); _node_prefs->buzzer_quiet = 0; notify(UIEventType::ack); } + if (mode == 1) { buzzer.quiet(true); _node_prefs->buzzer_quiet = 1; } + if (mode == 2) { buzzer.quiet(hasConnection()); } + static const char* labels[] = { "Buzzer: ON", "Buzzer: OFF", "Buzzer: Auto" }; + showAlert(labels[mode], 800); + _next_refresh = 0; +#endif +} diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 48d1c030..6b809c01 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -111,6 +111,8 @@ public: } void toggleBuzzer(); + void cycleBuzzerMode(); // ON → OFF → Auto → ON + int getBuzzerMode(); // 0=ON, 1=OFF, 2=Auto bool getGPSState(); void toggleGPS(); void applyBrightness(); From 4e9d15e82ac4ffc57f812d3a597b82188f412372 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 21:08:08 +0200 Subject: [PATCH 052/183] docs: add README with features, bot description, and dev setup note Co-Authored-By: Claude Sonnet 4.6 --- README.md | 147 ++++++++++++++++++------------------------------------ 1 file changed, 49 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index f8b9e5e0..04ca3eca 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,71 @@ -## About MeshCore +# Wio Tracker L1 — Extended Companion Radio Firmware -MeshCore is a lightweight, portable C++ library that enables multi-hop packet routing for embedded projects using LoRa and other packet radios. It is designed for developers who want to create resilient, decentralized communication networks that work without the internet. +This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1**. -## 🔍 What is MeshCore? +## New Features -MeshCore now supports a range of LoRa devices, allowing for easy flashing without the need to compile firmware manually. Users can flash a pre-built binary using tools like Adafruit ESPTool and interact with the network through a serial console. -MeshCore provides the ability to create wireless mesh networks, similar to Meshtastic and Reticulum but with a focus on lightweight multi-hop packet routing for embedded projects. Unlike Meshtastic, which is tailored for casual LoRa communication, or Reticulum, which offers advanced networking, MeshCore balances simplicity with scalability, making it ideal for custom embedded solutions., where devices (nodes) can communicate over long distances by relaying messages through intermediate nodes. This is especially useful in off-grid, emergency, or tactical situations where traditional communication infrastructure is unavailable. +### Messages Screen -## ⚡ Key Features +View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are available by default. -* Multi-Hop Packet Routing - * Devices can forward messages across multiple nodes, extending range beyond a single radio's reach. - * Supports up to a configurable number of hops to balance network efficiency and prevent excessive traffic. - * Nodes use fixed roles where "Companion" nodes are not repeating messages at all to prevent adverse routing paths from being used. -* Supports LoRa Radios – Works with Heltec, RAK Wireless, and other LoRa-based hardware. -* Decentralized & Resilient – No central server or internet required; the network is self-healing. -* Low Power Consumption – Ideal for battery-powered or solar-powered devices. -* Simple to Deploy – Pre-built example applications make it easy to get started. +Hold Enter on a message or channel to open a context menu: change per-channel notification settings (overrides the global sound setting) or mark messages as read. -## 🎯 What Can You Use MeshCore For? +### Settings Screen -* Off-Grid Communication: Stay connected even in remote areas. -* Emergency Response & Disaster Recovery: Set up instant networks where infrastructure is down. -* Outdoor Activities: Hiking, camping, and adventure racing communication. -* Tactical & Security Applications: Military, law enforcement, and private security use cases. -* IoT & Sensor Networks: Collect data from remote sensors and relay it back to a central location. +All settings are saved to flash and restored on next boot. -## 🚀 How to Get Started +- **Display** + - Brightness + - Auto-off timeout + - Battery display mode (icon, %, V) + - Clock seconds (show/hide — hiding reduces display refresh from 1 s to 60 s) +- **Sound** + - Buzzer: On / Off / **Auto** — Auto mode silences the device while connected via Bluetooth, and re-enables sound when the connection drops + - Volume +- **Home Pages** — toggle visibility of individual home screen pages +- **Radio** + - TX power +- **System** + - Timezone (UTC offset in hours) + - Low battery shutdown threshold +- **GPS** + - Position broadcast interval +- **Contacts** + - Show all DMs or favourites only + - Show all room servers or favourites only +- **Messages** + - Edit up to 10 quick reply templates -- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby. -- Watch the [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4) by Liam Cottle. -- Read through our [Frequently Asked Questions](./docs/faq.md) and [Documentation](https://docs.meshcore.io). -- Flash the MeshCore firmware on a supported device. -- Connect with a supported client. +### Clock Screen -For developers; +A dedicated clock page on the home screen shows the current time and date, synchronized from GPS or via Bluetooth. Timezone offset is applied from Settings. -- Install [PlatformIO](https://docs.platformio.org) in [Visual Studio Code](https://code.visualstudio.com). -- Clone and open the MeshCore repository in Visual Studio Code. -- See the example applications you can modify and run: - - [Companion Radio](./examples/companion_radio) - For use with an external chat app, over BLE, USB or WiFi. - - [KISS Modem](./examples/kiss_modem) - Serial KISS protocol bridge for host applications. ([protocol docs](./docs/kiss_modem_protocol.md)) - - [Simple Repeater](./examples/simple_repeater) - Extends network coverage by relaying messages. - - [Simple Room Server](./examples/simple_room_server) - A simple BBS server for shared Posts. - - [Simple Secure Chat](./examples/simple_secure_chat) - Secure terminal based text communication between devices. - - [Simple Sensor](./examples/simple_sensor) - Remote sensor node with telemetry and alerting. +### Tools Screen -The Simple Secure Chat example can be interacted with through the Serial Monitor in Visual Studio Code, or with a Serial USB Terminal on Android. +#### Ringtone Editor -## ⚡️ MeshCore Flasher +A step sequencer for composing custom ringtones stored on the device. Supports up to 32 notes with adjustable pitch, octave, duration, and BPM. Playback preview is available directly from the editor menu. -We have prebuilt firmware ready to flash on supported devices. +> Custom ringtones as per-channel or per-contact notification sounds are planned for a future update. -- Launch https://meshcore.io/flasher -- Select a supported device -- Flash one of the firmware types: - - Companion, Repeater or Room Server -- Once flashing is complete, you can connect with one of the MeshCore clients below. +#### Auto-Reply Bot -## 📱 MeshCore Clients +Automatically replies to incoming messages that contain a configured trigger word (case-insensitive). -**Companion Firmware** +- **DM mode** — when enabled, the bot listens to all incoming private messages and replies with the DM reply text. +- **Channel mode** — optionally, select a channel for the bot to monitor. When a trigger is matched, it replies with a separate channel reply text. +- Both modes can be active simultaneously and share the same trigger word but use independent reply texts. +- Replies support placeholders (`{time}`, `{loc}`). +- A 10-second cooldown prevents repeated replies in quick succession. -The companion firmware can be connected to via BLE, USB or WiFi depending on the firmware type you flashed. +--- -- Web: https://app.meshcore.nz -- Android: https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android -- iOS: https://apps.apple.com/us/app/meshcore/id6742354151?platform=iphone -- NodeJS: https://github.com/liamcottle/meshcore.js -- Python: https://github.com/fdlamotte/meshcore-cli +Feel free to explore, share feedback and feature requests! -**Repeater and Room Server Firmware** +## Development -The repeater and room server firmwares can be setup via USB in the web config tool. +This fork tracks the upstream [MeshCore](https://github.com/ripplebiz/MeshCore) repository. To prevent upstream changes from overwriting this README during merges, `README.md` is protected via `.gitattributes`. After cloning, run once: -- https://config.meshcore.io - -They can also be managed via LoRa in the mobile app by using the Remote Management feature. - -## 🛠 Hardware Compatibility - -MeshCore is designed for devices listed in the [MeshCore Flasher](https://meshcore.io/flasher) - -## 📜 License - -MeshCore is open-source software released under the MIT License. You are free to use, modify, and distribute it for personal and commercial projects. - -## Contributing - -Please submit PR's using 'dev' as the base branch! -For minor changes just submit your PR and we'll try to review it, but for anything more 'impactful' please open an Issue first and start a discussion. Is better to sound out what it is you want to achieve first, and try to come to a consensus on what the best approach is, especially when it impacts the structure or architecture of this codebase. - -Here are some general principals you should try to adhere to: -* Keep it simple. Please, don't think like a high-level lang programmer. Think embedded, and keep code concise, without any unnecessary layers. -* No dynamic memory allocation, except during setup/begin functions. -* Use the same brace and indenting style that's in the core source modules. (A .clang-format is prob going to be added soon, but please do NOT retroactively re-format existing code. This just creates unnecessary diffs that make finding problems harder) - -Help us prioritize! Please react with thumbs-up to issues/PRs you care about most. We look at reaction counts when planning work. - -## Road-Map / To-Do - -There are a number of fairly major features in the pipeline, with no particular time-frames attached yet. In very rough chronological order: -- [X] Companion radio: UI redesign -- [X] Repeater + Room Server: add ACL's (like Sensor Node has) -- [X] Standardise Bridge mode for repeaters -- [ ] Repeater/Bridge: Standardise the Transport Codes for zoning/filtering -- [X] Core + Repeater: enhanced zero-hop neighbour discovery -- [ ] Core: round-trip manual path support -- [ ] Companion + Apps: support for multiple sub-meshes (and 'off-grid' client repeat mode) -- [ ] Core + Apps: support for LZW message compression -- [ ] Core: dynamic CR (Coding Rate) for weak vs strong hops -- [ ] Core: new framework for hosting multiple virtual nodes on one physical device -- [ ] V2 protocol spec: discussion and consensus around V2 packet protocol, including path hashes, new encryption specs, etc - -## 📞 Get Support - -- Report bugs and request features on the [GitHub Issues](https://github.com/ripplebiz/MeshCore/issues) page. -- Find additional guides and components on [my site](https://buymeacoffee.com/ripplebiz). -- Join [MeshCore Discord](https://meshcore.gg) to chat with the developers and get help from the community. +```sh +git config merge.ours.driver true +``` From f6e251cfe3424e67688c8208501b3b07da3711a3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 12 May 2026 21:13:46 +0200 Subject: [PATCH 053/183] chore: adopt vX.Y-plus.N versioning scheme vX.Y tracks upstream major.minor; plus.N is fork-specific revision. Bump to v1.15-plus.1. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 61210e6c..240fdbcc 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,12 @@ #define FIRMWARE_VER_CODE 11 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "19 Apr 2026" +#define FIRMWARE_BUILD_DATE "12 May 2026" #endif +// Versioning: vX.Y = upstream base, plus.N = fork revision #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.15.3" +#define FIRMWARE_VERSION "v1.15-plus.1" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) From 4a4ff76f10f84afc4676f64efa0b139305156ef1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 10:25:55 +0200 Subject: [PATCH 054/183] feat: DM chat history, per-contact unread badges, bot history, contact index fix - Add DM_HIST phase in QuickMsgScreen: 32-entry ring buffer tracks incoming and outgoing DMs per contact; selecting a contact now opens history view before compose - Per-contact DM unread badge in contact list (replaces hop count display) - Bot replies (DM and channel) stored in history so they appear on-screen - Fix contact index truncation: _sorted was uint8_t (max 255), causing contacts at indices 256-349 to map to wrong entries; changed to uint16_t - Add addDMMsg() virtual to AbstractUITask; wire through MyMesh.cpp - Incoming chat DMs call addDMMsg(); outgoing after successful send too - msgRead(0) from companion app clears per-contact unread table Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/AbstractUITask.h | 3 +- examples/companion_radio/MyMesh.cpp | 20 +- examples/companion_radio/MyMeshBot.h | 17 +- examples/companion_radio/ui-new/UITask.cpp | 222 +++++++++++++++++++-- examples/companion_radio/ui-new/UITask.h | 19 +- 5 files changed, 255 insertions(+), 26 deletions(-) diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index ecac65d5..105f00a1 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -40,8 +40,9 @@ public: void enableSerial() { _serial->enable(); } void disableSerial() { _serial->disable(); } virtual void msgRead(int msgcount) = 0; - virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0) = 0; + virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) = 0; virtual void notify(UIEventType t = UIEventType::none) = 0; virtual void addChannelMsg(uint8_t channel_idx, const char* text) {} + virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) {} virtual void loop() = 0; }; diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4d8669fc..a670be14 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -465,8 +465,10 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; if (should_display && _ui) { - _ui->newMsg(path_len, from.name, text, offline_queue_len, from.type); + _ui->newMsg(path_len, from.name, text, offline_queue_len, from.type, from.id.pub_key); _ui->notify(UIEventType::contactMessage); + if (from.type == ADV_TYPE_CHAT) + _ui->addDMMsg(from.id.pub_key, false, text); } #endif } @@ -881,6 +883,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.bot_trigger[0] = '\0'; _prefs.bot_reply_dm[0] = '\0'; _prefs.bot_reply_ch[0] = '\0'; + _prefs.dm_show_all = 1; // show all contacts by default _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default @@ -1272,7 +1275,9 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); uint32_t last_mod = getRTCClock()->getCurrentTime(); // fallback value if not present in cmd_frame if (recipient) { + uint8_t saved_type = recipient->type; // type is authoritative from advert, not app updateContactFromFrame(*recipient, last_mod, cmd_frame, len); + if (saved_type != ADV_TYPE_NONE) recipient->type = saved_type; recipient->lastmod = last_mod; dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); writeOKFrame(); @@ -1690,13 +1695,22 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_NOT_FOUND); } } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32) { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // not supported (yet) + uint8_t channel_idx = cmd_frame[1]; + ChannelDetails channel; + StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); + memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key + if (setChannel(channel_idx, channel)) { + saveChannels(); + writeOKFrame(); + } else { + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + } } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) { uint8_t channel_idx = cmd_frame[1]; ChannelDetails channel; StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); - memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported + memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key if (setChannel(channel_idx, channel)) { saveChannels(); writeOKFrame(); diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index a413f76a..b5068237 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -3,6 +3,7 @@ // Included at the bottom of MyMesh.cpp after all class definitions. void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { + if (from.type != ADV_TYPE_CHAT) return; if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_dm[0] && millis() - _bot_last_reply_ms > 10000UL)) return; @@ -24,8 +25,12 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { 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, expanded, 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(); +#ifdef DISPLAY_CLASS + if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded); +#endif + } } void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { @@ -54,6 +59,14 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { 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)) + if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) { _bot_last_reply_ms = millis(); +#ifdef DISPLAY_CLASS + if (_ui) { + char with_sender[160]; + snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, expanded); + _ui->addChannelMsg(channel_idx, with_sender); + } +#endif + } } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 1a33d263..d3939492 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -295,7 +295,7 @@ class SettingsScreen : public UIScreen { bool homePageVisible(int item, const NodePrefs* p) const { uint16_t bit = homePageBit(item); if (!bit) return false; - uint16_t mask = p ? p->home_pages_mask : HP_ALL; + uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; return (mask & bit) != 0; } @@ -780,7 +780,7 @@ const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; class QuickMsgScreen : public UIScreen { UITask* _task; - enum Phase { MODE_SELECT, CONTACT_PICK, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; + enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; Phase _phase; // MODE_SELECT @@ -789,7 +789,7 @@ class QuickMsgScreen : public UIScreen { // CONTACT_PICK int _contact_sel, _contact_scroll; int _num_contacts; - uint8_t _sorted[MAX_CONTACTS]; + uint16_t _sorted[MAX_CONTACTS]; ContactInfo _sel_contact; bool _room_mode; // true = picking a room server, false = picking a DM contact @@ -857,6 +857,13 @@ class QuickMsgScreen : public UIScreen { ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; + // DM_HIST + struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; + static const int DM_HIST_MAX = 32; + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; + int _dm_hist_sel, _dm_hist_scroll; + static const int VISIBLE = 4; static const int ITEM_H = 12; static const int START_Y = 12; @@ -928,6 +935,40 @@ class QuickMsgScreen : public UIScreen { return -1; } + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + void afterSend(bool ok, const char* msg) { if (ok && _sending_to_channel) { _hist_sel = 0; @@ -942,8 +983,14 @@ class QuickMsgScreen : public UIScreen { _unread_at_entry = 0; _viewing_max_seen = 0; _task->showAlert("Sent!", 600); + } else if (ok) { + storeDMMsg(_sel_contact.id.pub_key, true, msg); + _dm_hist_sel = 0; + _dm_hist_scroll = 0; + _phase = DM_HIST; + _task->showAlert("Sent!", 600); } else { - _task->showAlert(ok ? "Sent!" : "Send failed", 1500); + _task->showAlert("Send failed", 1500); _task->gotoHomeScreen(); } } @@ -1029,6 +1076,8 @@ public: _hist_fullscreen(false), _hist_fs_scroll(0), _unread_at_entry(0), _viewing_max_seen(0), _hist_head(0), _hist_count(0), + _dm_hist_head(0), _dm_hist_count(0), + _dm_hist_sel(-1), _dm_hist_scroll(0), _kb_row(0), _kb_col(0), _kb_len(0), _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), _ctx_open(false), _ctx_dirty(false), _ctx_sel(0) { @@ -1055,6 +1104,14 @@ public: } } + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + storeDMMsg(pub_key, outgoing, text); + } + + int getDMUnreadTotal() const { + return _task->getDMUnreadTotal(); + } + int getTotalChannelUnread() const { int total = 0; for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; @@ -1101,11 +1158,10 @@ public: display.fillRect(0, 10, display.width(), 1); const char* opts[] = { "Direct message", "Channels", "Room Servers" }; int badges[3] = { - _task->getMsgCount() - _task->getRoomUnreadCount(), // DM only + getDMUnreadTotal(), _task->getChannelUnreadCount(), _task->getRoomUnreadCount() }; - if (badges[0] < 0) badges[0] = 0; for (int i = 0; i < 3; i++) { int y = START_Y + i * ITEM_H; bool sel = (i == _mode_sel); @@ -1159,11 +1215,18 @@ public: display.print(sel ? ">" : " "); char filtered[sizeof(c.name)]; display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); - display.drawTextEllipsized(8, y, display.width() - 24, filtered); - char hop[5]; - snprintf(hop, sizeof(hop), c.out_path_len == 0xFF ? "D" : "%dh", (int)c.out_path_len); - display.setCursor(display.width() - display.getTextWidth(hop) - 1, y); - display.print(hop); + uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); + char badge[5] = ""; + int bw = 0; + if (dm_unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)dm_unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); + if (dm_unread > 0) { + display.setCursor(display.width() - bw + 1, y); + display.print(badge); + } } } display.setColor(DisplayDriver::LIGHT); @@ -1243,6 +1306,74 @@ public: display.setColor(DisplayDriver::LIGHT); } + } else if (_phase == DM_HIST) { + char title[24]; + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + char filtered_name[sizeof(_sel_contact.name)]; + display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); + snprintf(title, sizeof(title), "%.23s", filtered_name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 9, display.width(), 1); + + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + + for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { + int item = _dm_hist_scroll + i; + bool sel = (item == _dm_hist_sel); + int y = HIST_START_Y + i * HIST_ITEM_H; + + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + if (ring_pos < 0) continue; + + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), HIST_BOX_H); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + } + } + + if (dm_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 32, "No messages yet"); + } + + display.setColor(DisplayDriver::LIGHT); + if (_dm_hist_scroll > 0) { + display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.print("^"); + } + if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { + display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + display.print("v"); + } + + bool compose_sel = (_dm_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1, cby = 55; + if (compose_sel) { + display.fillRect(cbx, cby - 1, ctw + 4, 9); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(cbx + 2, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + return dm_count > 0 ? 500 : 2000; + } else if (_phase == CHANNEL_HIST) { if (_hist_fullscreen && _hist_sel >= 0) { int fs_hist_count = histCountForChannel(_sel_channel_idx); @@ -1529,9 +1660,10 @@ public: } if (c == KEY_ENTER && _num_contacts > 0) { if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { - _sending_to_channel = false; - setupMsgPick(); - _phase = MSG_PICK; + _task->clearDMUnread(_sel_contact.id.pub_key); + _dm_hist_sel = -1; + _dm_hist_scroll = 0; + _phase = DM_HIST; } return true; } @@ -1588,6 +1720,34 @@ public: return true; } + } else if (_phase == DM_HIST) { + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } + if (c == KEY_UP) { + if (_dm_hist_sel > 0) { + _dm_hist_sel--; + if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; + } else if (_dm_hist_sel == 0) { + _dm_hist_sel = -1; + } + return true; + } + if (c == KEY_DOWN) { + if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } + else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { + _dm_hist_sel++; + if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) + _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; + } + return true; + } + if (c == KEY_ENTER) { + _sending_to_channel = false; + setupMsgPick(); + _phase = MSG_PICK; + return true; + } + } else if (_phase == CHANNEL_HIST) { int ch_hist_count = histCountForChannel(_sel_channel_idx); if (_hist_fullscreen) { @@ -1722,7 +1882,7 @@ public: } else { // MSG_PICK int total_msg_items = 1 + _active_msg_count; if (c == KEY_CANCEL) { - _phase = _sending_to_channel ? CHANNEL_HIST : CONTACT_PICK; + _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; return true; } if (c == KEY_UP && _msg_sel > 0) { @@ -1811,7 +1971,7 @@ class HomeScreen : public UIScreen { bool isPageVisible(int page) const { int bit = pageBit(page); if (bit < 0) return true; - uint16_t mask = _node_prefs ? _node_prefs->home_pages_mask : HP_ALL; + uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : HP_ALL; return (mask >> bit) & 1; } @@ -2208,8 +2368,7 @@ public: display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); display.drawTextCentered(display.width() / 2, 22, "Messages"); - int total_unread = _task->getMsgCount() + _task->getChannelUnreadCount(); - // getMsgCount() already includes room msgs via offline_queue_len; avoid double-count + int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); if (total_unread > 0) { char badge[20]; snprintf(badge, sizeof(badge), "%d unread", total_unread); @@ -2398,6 +2557,17 @@ int UITask::getChannelUnreadCount() const { return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread(); } +void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + ((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text); +} + +int UITask::getDMUnreadTotal() const { + int total = 0; + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) + total += _dm_unread_table[i].count; + return total; +} + void UITask::showAlert(const char* text, int duration_millis) { snprintf(_alert, sizeof(_alert), "%s", text); _alert_expiry = millis() + duration_millis; @@ -2455,12 +2625,26 @@ void UITask::msgRead(int msgcount) { _msgcount = msgcount; if (msgcount == 0) { _room_unread = 0; + memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); } } -void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type) { +void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type, const uint8_t* pub_key) { _msgcount = msgcount; if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; + if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) { + int slot = -1, empty_slot = -1; + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) { + if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; } + if (empty_slot < 0 && _dm_unread_table[i].count == 0) empty_slot = i; + } + if (slot >= 0) { + if (_dm_unread_table[slot].count < 99) _dm_unread_table[slot].count++; + } else if (empty_slot >= 0) { + memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4); + _dm_unread_table[empty_slot].count = 1; + } + } char alert_buf[80]; snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 6b809c01..f538d742 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -38,6 +38,9 @@ class UITask : public AbstractUITask { int _msgcount; int _room_unread; int _last_notif_ch_idx; + struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; }; + static const int DM_UNREAD_TABLE_SIZE = 16; + DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE]; unsigned long ui_started_at, next_batt_chck; uint16_t _batt_mv; // EMA-filtered battery voltage int next_backlight_btn_check = 0; @@ -78,6 +81,7 @@ public: _batt_mv = 0; _msgcount = _room_unread = 0; _last_notif_ch_idx = -1; + memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); curr = NULL; } void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs); @@ -95,10 +99,23 @@ public: bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); void addChannelMsg(uint8_t channel_idx, const char* text) override; + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) override; + int getDMUnreadTotal() const; int getMsgCount() const { return _msgcount; } int getChannelUnreadCount() const; int getRoomUnreadCount() const { return _room_unread; } void clearRoomUnread() { _room_unread = 0; } + uint8_t getDMUnread(const uint8_t* pub_key) const { + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) + if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) + return _dm_unread_table[i].count; + return 0; + } + void clearDMUnread(const uint8_t* pub_key) { + for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) + if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) + { _dm_unread_table[i].count = 0; return; } + } bool hasDisplay() const { return _display != NULL; } bool isButtonPressed() const; @@ -130,7 +147,7 @@ public: // from AbstractUITask void msgRead(int msgcount) override; - void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0) override; + void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount, uint8_t contact_type = 0, const uint8_t* pub_key = nullptr) override; void notify(UIEventType t = UIEventType::none) override; void loop() override; From b50f21060c4da2a7040447bc8a2ac3bd542bc435 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 10:36:37 +0200 Subject: [PATCH 055/183] fix: clear DM and channel unread counters on app sync completion When companion app finishes syncing all messages (msgRead(0)), clear both _dm_unread_table and _ch_unread so indicators match the app's read state. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index d3939492..b2eb75a5 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1118,6 +1118,10 @@ public: return total; } + void clearAllChannelUnread() { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + void updateChannelUnread() { if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; @@ -2626,6 +2630,7 @@ void UITask::msgRead(int msgcount) { if (msgcount == 0) { _room_unread = 0; memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); + ((QuickMsgScreen*)quick_msg)->clearAllChannelUnread(); } } From eb0f4369243452d3ba78f0d46e70f408f71ae4e4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 10:48:42 +0200 Subject: [PATCH 056/183] feat: DM per-contact notification settings and mark-as-read Long-press ENTER in the DM contact list opens a context menu with: - "Mark as read": clears unread counter for that contact - "Notif: default/OFF/ON": cycles notification state (same as channels) Notification state is persisted in NodePrefs (dm_notif[16] table, keyed by 4-byte pub_key prefix). When muted, the buzzer is silenced for that contact's messages; when force-on, buzzer plays even in quiet mode. Default follows the global buzzer setting. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/NodePrefs.h | 3 + examples/companion_radio/ui-new/UITask.cpp | 122 ++++++++++++++++++++- examples/companion_radio/ui-new/UITask.h | 4 + 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 2d719396..75380404 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -264,6 +264,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); if (file.available()) { file.read((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + if (file.available()) { + file.read((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); + } } } } @@ -334,6 +337,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch)); file.write((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); + file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index a670be14..5fc8abc9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -884,6 +884,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.bot_reply_dm[0] = '\0'; _prefs.bot_reply_ch[0] = '\0'; _prefs.dm_show_all = 1; // show all contacts by default + memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif)); _prefs.auto_off_secs = 15; // 15 seconds auto-off by default _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4a8d0bcb..2e67665f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -57,4 +57,7 @@ struct NodePrefs { // persisted to file char bot_reply_ch[140]; // auto-reply text for channel uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected + struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on + static const int DM_NOTIF_TABLE_MAX = 16; + DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b2eb75a5..6defb362 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1065,6 +1065,38 @@ class QuickMsgScreen : public UIScreen { } } + uint8_t dmNotifState(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) + return p->dm_notif[i].state; + return 0; + } + + void setDmNotifState(const uint8_t* pub_key, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { + if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } + else p->dm_notif[i].state = state; + return; + } + } + if (state == 0) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state == 0) { + memcpy(p->dm_notif[i].prefix, pub_key, 4); + p->dm_notif[i].state = state; + return; + } + } + // table full — overwrite slot 0 + memcpy(p->dm_notif[0].prefix, pub_key, 4); + p->dm_notif[0].state = state; + } + public: QuickMsgScreen(UITask* task) : _task(task), _phase(MODE_SELECT), _mode_sel(0), @@ -1236,6 +1268,39 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _contact_scroll, _num_contacts); + // Context menu overlay (long-press ENTER), DM mode only + if (_ctx_open && _num_contacts > 0 && !_room_mode) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + uint8_t nstate = dmNotifState(ci.id.pub_key); + char notif_item[22]; + snprintf(notif_item, sizeof(notif_item), "Notif: %s", NOTIF_LABELS[nstate]); + const char* items[] = { "Mark as read", notif_item }; + const int CTX_COUNT = 2; + + display.setColor(DisplayDriver::DARK); + display.fillRect(15, 14, 98, 34); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(15, 14, 98, 34); + display.setCursor(19, 15); + display.print("Contact options"); + display.fillRect(15, 24, 98, 1); + for (int i = 0; i < CTX_COUNT; i++) { + int py = 27 + i * 10; + if (i == _ctx_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(16, py - 1, 96, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(19, py); + display.print(items[i]); + } + display.setColor(DisplayDriver::LIGHT); + } + } else if (_phase == CHANNEL_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); display.fillRect(0, 10, display.width(), 1); @@ -1651,6 +1716,29 @@ public: } } else if (_phase == CONTACT_PICK) { + // Context menu consumes all input while open + if (_ctx_open) { + if (c == KEY_CANCEL) { + if (_ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } + _ctx_open = false; + return true; + } + if (c == KEY_UP && _ctx_sel > 0) { _ctx_sel--; return true; } + if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } + if (c == KEY_ENTER && _num_contacts > 0) { + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + if (_ctx_sel == 0) { + _task->clearDMUnread(ci.id.pub_key); + } else { + uint8_t nstate = dmNotifState(ci.id.pub_key); + setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); + _ctx_dirty = true; + } + return true; + } + return true; + } if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } if (c == KEY_UP && _contact_sel > 0) { _contact_sel--; @@ -1671,6 +1759,12 @@ public: } return true; } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { + _ctx_sel = 0; + _ctx_dirty = false; + _ctx_open = true; + return true; + } } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open @@ -2580,10 +2674,30 @@ void UITask::showAlert(const char* text, int duration_millis) { void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ - case UIEventType::contactMessage: - // gemini's pick - buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + case UIEventType::contactMessage: { + bool play = false; + bool force = false; + if (_last_notif_dm_valid && _node_prefs) { + uint8_t state = 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (_node_prefs->dm_notif[i].state && + memcmp(_node_prefs->dm_notif[i].prefix, _last_notif_dm_prefix, 4) == 0) { + state = _node_prefs->dm_notif[i].state; break; + } + } + if (state == 2) { play = true; force = true; } // force-on + else if (state == 1) { /* muted */ } + else { play = !buzzer.isQuiet(); } // default: follow global + } else { + play = !buzzer.isQuiet(); + } + _last_notif_dm_valid = false; + if (play) { + if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + } break; + } case UIEventType::channelMessage: { bool play = false; bool force = false; @@ -2638,6 +2752,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i _msgcount = msgcount; if (contact_type == ADV_TYPE_ROOM && _room_unread < _msgcount) _room_unread++; if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) { + memcpy(_last_notif_dm_prefix, pub_key, 4); + _last_notif_dm_valid = true; int slot = -1, empty_slot = -1; for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) { if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; } diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index f538d742..af95bce1 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -38,6 +38,8 @@ class UITask : public AbstractUITask { int _msgcount; int _room_unread; int _last_notif_ch_idx; + uint8_t _last_notif_dm_prefix[4]; + bool _last_notif_dm_valid; struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; }; static const int DM_UNREAD_TABLE_SIZE = 16; DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE]; @@ -81,6 +83,8 @@ public: _batt_mv = 0; _msgcount = _room_unread = 0; _last_notif_ch_idx = -1; + _last_notif_dm_valid = false; + memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); curr = NULL; } From 60867a4c9e37298d41ff2ec9b98f837ca4b77253 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 10:54:36 +0200 Subject: [PATCH 057/183] fix: check getContactByIdx return value in DM context menu handler Prevents using uninitialized ContactInfo if the lookup fails. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6defb362..9bc426c0 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1727,13 +1727,14 @@ public: if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } if (c == KEY_ENTER && _num_contacts > 0) { ContactInfo ci; - the_mesh.getContactByIdx(_sorted[_contact_sel], ci); - if (_ctx_sel == 0) { - _task->clearDMUnread(ci.id.pub_key); - } else { - uint8_t nstate = dmNotifState(ci.id.pub_key); - setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); - _ctx_dirty = true; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { + if (_ctx_sel == 0) { + _task->clearDMUnread(ci.id.pub_key); + } else { + uint8_t nstate = dmNotifState(ci.id.pub_key); + setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); + _ctx_dirty = true; + } } return true; } From 07bd5f1167edb5c8b24bea325c74291217eeaeab Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 18:22:41 +0200 Subject: [PATCH 058/183] refactor: extract on-screen keyboard into reusable KeyboardWidget module All three keyboard implementations (SettingsScreen, QuickMsgScreen, BotScreen) now share a single KeyboardWidget struct defined in KeyboardWidget.h, eliminating ~480 lines of duplicated render/input logic. Also fixes the SettingsScreen bug where navigation keys were checked before the placeholder overlay, causing cursor movement when the overlay was open. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/BotScreen.h | 200 ++------- .../companion_radio/ui-new/KeyboardWidget.h | 205 +++++++++ examples/companion_radio/ui-new/UITask.cpp | 403 ++---------------- 3 files changed, 265 insertions(+), 543 deletions(-) create mode 100644 examples/companion_radio/ui-new/KeyboardWidget.h diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 1603ac48..8966c507 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -1,6 +1,6 @@ #pragma once // Custom screen — not part of upstream UITask.cpp -// Included by UITask.cpp after the global KB_* constants are defined. +// Included by UITask.cpp after KeyboardWidget.h is defined. class BotScreen : public UIScreen { UITask* _task; @@ -15,14 +15,8 @@ class BotScreen : public UIScreen { int _sel; // keyboard state (reused for trigger and reply fields) - int _kb_field; // -1=off, 2=trigger, 3=reply - 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 _kb_field; // -1=off, 2=trigger, 3=reply DM, 4=reply Ch + KeyboardWidget _kb; // channel cache (refreshed on enter) int _num_channels; @@ -59,76 +53,7 @@ public: display.setColor(DisplayDriver::LIGHT); if (_kb_field >= 0) { - const char* label = (_kb_field == 2) ? "Trigger:" : "Reply:"; - 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, KB_SEP_Y, display.width(), 1); - - 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 = ch - 'a' + 'A'; - char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; - int cx = col * KB_CELL_W; - if (sel) { - 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(ch_buf); - } - } - - 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 * 25; - if (sel || active) { - 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 + 1, KB_SPECIAL_Y); - display.print(spec[i]); - display.setColor(DisplayDriver::LIGHT); - } - - 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; + return _kb.render(display); } display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); @@ -182,84 +107,21 @@ public: bool cancel = (c == KEY_CANCEL || c == KEY_CONTEXT_MENU); if (_kb_field >= 0) { - 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) - _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) - _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 (enter) { - 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 = ch - 'a' + 'A'; - _kb_buf[_kb_len++] = ch; - _kb_buf[_kb_len] = '\0'; - } + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::DONE) { + if (_kb_field == 2) { + strncpy(_prefs->bot_trigger, _kb.buf, sizeof(_prefs->bot_trigger) - 1); + _prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0'; + } else if (_kb_field == 3) { + strncpy(_prefs->bot_reply_dm, _kb.buf, sizeof(_prefs->bot_reply_dm) - 1); + _prefs->bot_reply_dm[sizeof(_prefs->bot_reply_dm) - 1] = '\0'; } else { - 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 3: - _kb_ph_mode = true; - _kb_ph_sel = 0; - break; - case 4: - if (_kb_field == 2) { - strncpy(_prefs->bot_trigger, _kb_buf, sizeof(_prefs->bot_trigger) - 1); - _prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0'; - } else if (_kb_field == 3) { - strncpy(_prefs->bot_reply_dm, _kb_buf, sizeof(_prefs->bot_reply_dm) - 1); - _prefs->bot_reply_dm[sizeof(_prefs->bot_reply_dm) - 1] = '\0'; - } else { - strncpy(_prefs->bot_reply_ch, _kb_buf, sizeof(_prefs->bot_reply_ch) - 1); - _prefs->bot_reply_ch[sizeof(_prefs->bot_reply_ch) - 1] = '\0'; - } - _kb_field = -1; - break; - } + strncpy(_prefs->bot_reply_ch, _kb.buf, sizeof(_prefs->bot_reply_ch) - 1); + _prefs->bot_reply_ch[sizeof(_prefs->bot_reply_ch) - 1] = '\0'; } - return true; + _kb_field = -1; + } else if (res == KeyboardWidget::CANCELLED) { + _kb_field = -1; } return true; } @@ -299,24 +161,24 @@ public: } } if ((_sel == 2 || _sel == 3 || _sel == 4) && enter) { - _kb_field = _sel; - _kb_row = 0; - _kb_col = 0; - _kb_caps = false; - _kb_ph_mode = false; - _kb_ph_sel = 0; + _kb_field = _sel; + const char* initial; + int max; + const char* lbl; if (_sel == 2) { - strncpy(_kb_buf, _prefs->bot_trigger, sizeof(_kb_buf) - 1); - _kb_maxlen = sizeof(_prefs->bot_trigger) - 1; + initial = _prefs->bot_trigger; + max = sizeof(_prefs->bot_trigger) - 1; + lbl = "Trigger:"; } else if (_sel == 3) { - strncpy(_kb_buf, _prefs->bot_reply_dm, sizeof(_kb_buf) - 1); - _kb_maxlen = sizeof(_prefs->bot_reply_dm) - 1; + initial = _prefs->bot_reply_dm; + max = sizeof(_prefs->bot_reply_dm) - 1; + lbl = "Reply:"; } else { - strncpy(_kb_buf, _prefs->bot_reply_ch, sizeof(_kb_buf) - 1); - _kb_maxlen = sizeof(_prefs->bot_reply_ch) - 1; + initial = _prefs->bot_reply_ch; + max = sizeof(_prefs->bot_reply_ch) - 1; + lbl = "Reply:"; } - _kb_buf[sizeof(_kb_buf) - 1] = '\0'; - _kb_len = strlen(_kb_buf); + _kb.begin(initial, max, lbl); return true; } return false; diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h new file mode 100644 index 00000000..56803a7d --- /dev/null +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -0,0 +1,205 @@ +#pragma once + +#include +#include + +// Layout constants shared by all keyboard users +static const char 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'}, +}; +static const int KB_ROWS_CHAR = 4; +static const int KB_COLS_CHAR = 10; +static const int KB_SPECIAL = 5; // [^] [Sp] [Del] [{}] [OK] +static const char* KB_PH_LIST[] = { "{loc}", "{time}" }; +static const int KB_PH_COUNT = 2; +static const int KB_MAX_LEN = 139; +static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display +static const int KB_CELL_H = 9; +static const int KB_TEXT_Y = 0; +static const int KB_SEP_Y = 9; +static const int KB_CHARS_Y = 11; +static const int KB_SPECIAL_Y = 48; + +struct KeyboardWidget { + char buf[KB_MAX_LEN + 1]; + int len; + int max_len; + char label[16]; + int row, col; + bool caps; + bool ph_mode; + int ph_sel; + + enum Result { NONE, DONE, CANCELLED }; + + void begin(const char* initial = "", int max = KB_MAX_LEN, const char* lbl = "") { + strncpy(label, lbl, sizeof(label) - 1); + label[sizeof(label) - 1] = '\0'; + max_len = (max > KB_MAX_LEN) ? KB_MAX_LEN : max; + strncpy(buf, initial, max_len); + buf[max_len] = '\0'; + len = strlen(buf); + row = col = 0; + caps = ph_mode = false; + ph_sel = 0; + } + + int render(DisplayDriver& display) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + // text preview: scroll so cursor is always visible (last 20 chars) + const char* disp_start = buf; + int disp_len = len; + if (disp_len > 20) { disp_start = buf + (disp_len - 20); disp_len = 20; } + char preview[32]; + snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start); + display.setCursor(0, KB_TEXT_Y); + display.print(preview); + display.fillRect(0, KB_SEP_Y, display.width(), 1); + + // character grid + for (int r = 0; r < KB_ROWS_CHAR; r++) { + int y = KB_CHARS_Y + r * KB_CELL_H; + for (int c = 0; c < KB_COLS_CHAR; c++) { + bool sel = (row == r && col == c); + char ch = KB_CHARS[r][c]; + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; + int cx = c * KB_CELL_W; + if (sel) { + 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(ch_buf); + } + } + + // special row: [^] [Sp] [Del] [{}] [OK] + const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" }; + for (int i = 0; i < KB_SPECIAL; i++) { + bool sel = (row == KB_ROWS_CHAR && col == i); + bool active = (i == 0 && caps); + int sx = i * 25; + if (sel || active) { + 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 + 1, KB_SPECIAL_Y); + display.print(spec[i]); + display.setColor(DisplayDriver::LIGHT); + } + + // placeholder picker overlay + if (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 == 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; + } + + Result handleInput(char c) { + // placeholder overlay consumes all input + if (ph_mode) { + if (c == KEY_UP && ph_sel > 0) { ph_sel--; return NONE; } + if (c == KEY_DOWN && ph_sel < KB_PH_COUNT - 1){ ph_sel++; return NONE; } + if (c == KEY_ENTER) { + const char* ph = KB_PH_LIST[ph_sel]; + int ph_len = strlen(ph); + if (len + ph_len <= max_len) { + memcpy(buf + len, ph, ph_len); + len += ph_len; + buf[len] = '\0'; + } + ph_mode = false; + return NONE; + } + ph_mode = false; // CANCEL or any other key closes the overlay + return NONE; + } + + if (c == KEY_CANCEL) return CANCELLED; + + if (c == KEY_UP) { + if (row > 0) { + row--; + if (row == KB_ROWS_CHAR - 1) // leaving special row upward + col = col * KB_COLS_CHAR / KB_SPECIAL; + } + return NONE; + } + if (c == KEY_DOWN) { + if (row < KB_ROWS_CHAR) { + row++; + if (row == KB_ROWS_CHAR) // entering special row + col = col * KB_SPECIAL / KB_COLS_CHAR; + } + return NONE; + } + if (c == KEY_LEFT) { + if (col > 0) col--; + return NONE; + } + if (c == KEY_RIGHT) { + int max_col = (row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; + if (col < max_col) col++; + return NONE; + } + if (c == KEY_ENTER) { + if (row < KB_ROWS_CHAR) { + if (len < max_len) { + char ch = KB_CHARS[row][col]; + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + buf[len++] = ch; + buf[len] = '\0'; + } + } else { + switch (col) { + case 0: caps = !caps; break; + case 1: + if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; } + break; + case 2: + if (len > 0) buf[--len] = '\0'; + break; + case 3: + ph_mode = true; + ph_sel = 0; + break; + case 4: + return DONE; + } + } + } + return NONE; + } +}; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9bc426c0..1fe60456 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -97,25 +97,7 @@ static const uint16_t HP_TOOLS = 1 << 7; static const uint16_t HP_SHUTDOWN = 1 << 8; static const uint16_t HP_ALL = 0x01FF; -// On-screen keyboard layout (4 rows × 10 cols) -static const char 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'}, -}; -static const int KB_ROWS_CHAR = 4; -static const int KB_COLS_CHAR = 10; -static const int KB_SPECIAL = 5; // [^] [Sp] [Del] [{}] [OK] -static const char* KB_PH_LIST[] = { "{loc}", "{time}" }; -static const int KB_PH_COUNT = 2; -static const int KB_MAX_LEN = 139; -static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display -static const int KB_CELL_H = 9; -static const int KB_TEXT_Y = 0; -static const int KB_SEP_Y = 9; -static const int KB_CHARS_Y = 11; // first char row -static const int KB_SPECIAL_Y = 48; +#include "KeyboardWidget.h" class SettingsScreen : public UIScreen { UITask* _task; @@ -425,21 +407,13 @@ class SettingsScreen : public UIScreen { } // Keyboard state for editing message slots - int _edit_slot; // -1 = not editing, 0..9 = slot being edited - char _edit_buf[KB_MAX_LEN + 1]; - int _edit_len; - int _edit_kb_row, _edit_kb_col; - bool _edit_caps; - bool _edit_ph_mode; - int _edit_ph_sel; + int _edit_slot; // -1 = not editing, 0..9 = slot being edited + KeyboardWidget _kb; public: SettingsScreen(UITask* task) - : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), - _edit_slot(-1), _edit_len(0), _edit_kb_row(0), _edit_kb_col(0), - _edit_caps(false), _edit_ph_mode(false), _edit_ph_sel(0) { - _edit_buf[0] = '\0'; - } + : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} + void markClean() { _dirty = false; } @@ -447,83 +421,7 @@ public: display.setTextSize(1); if (_edit_slot >= 0) { - // Keyboard mode for editing a message slot - display.setColor(DisplayDriver::LIGHT); - // preview line with cursor - const char* disp_start = _edit_buf; - int disp_len = _edit_len; - if (disp_len > 20) { disp_start = _edit_buf + (disp_len - 20); disp_len = 20; } - char preview[26]; - snprintf(preview, sizeof(preview), "M%d:%.*s_", _edit_slot + 1, disp_len, disp_start); - display.setCursor(0, KB_TEXT_Y); - display.print(preview); - display.fillRect(0, KB_SEP_Y, display.width(), 1); - - // 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 = (_edit_kb_row == row && _edit_kb_col == col); - char ch = KB_CHARS[row][col]; - if (_edit_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; - char ch_buf[2] = { ch, '\0' }; - if (ch_buf[0] == ' ') ch_buf[0] = '_'; - int cx = col * KB_CELL_W; - if (sel) { - 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(ch_buf); - } - } - - // 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 = (_edit_kb_row == KB_ROWS_CHAR && _edit_kb_col == i); - bool active = (i == 0 && _edit_caps); - int sx = i * 25; - if (sel || active) { - 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 + 1, KB_SPECIAL_Y); - display.print(spec[i]); - display.setColor(DisplayDriver::LIGHT); - } - - // placeholder picker overlay - if (_edit_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 == _edit_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; + return _kb.render(display); } display.setColor(DisplayDriver::LIGHT); @@ -554,88 +452,16 @@ public: // Keyboard editing mode for message slots if (_edit_slot >= 0) { - if (c == KEY_UP) { - if (_edit_kb_row > 0) { - _edit_kb_row--; - if (_edit_kb_row == KB_ROWS_CHAR - 1) // leaving special row upward - _edit_kb_col = _edit_kb_col * KB_COLS_CHAR / KB_SPECIAL; + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::DONE) { + if (p) { + strncpy(p->custom_msgs[_edit_slot], _kb.buf, sizeof(p->custom_msgs[0]) - 1); + p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; + _dirty = true; } - return true; - } - if (c == KEY_DOWN) { - if (_edit_kb_row < KB_ROWS_CHAR) { - _edit_kb_row++; - if (_edit_kb_row == KB_ROWS_CHAR) // entering special row - _edit_kb_col = _edit_kb_col * KB_SPECIAL / KB_COLS_CHAR; - } - return true; - } - if (c == KEY_LEFT) { - if (_edit_kb_col > 0) _edit_kb_col--; - return true; - } - if (c == KEY_RIGHT) { - int max_col = (_edit_kb_row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; - if (_edit_kb_col < max_col) _edit_kb_col++; - return true; - } - if (_edit_ph_mode) { - if (c == KEY_UP && _edit_ph_sel > 0) { _edit_ph_sel--; return true; } - if (c == KEY_DOWN && _edit_ph_sel < KB_PH_COUNT - 1) { _edit_ph_sel++; return true; } - if (c == KEY_ENTER) { - const char* ph = KB_PH_LIST[_edit_ph_sel]; - int ph_len = strlen(ph); - if (_edit_len + ph_len <= KB_MAX_LEN) { - memcpy(_edit_buf + _edit_len, ph, ph_len); - _edit_len += ph_len; - _edit_buf[_edit_len] = '\0'; - } - _edit_ph_mode = false; - return true; - } - if (c == KEY_CANCEL) { _edit_ph_mode = false; return true; } - return true; - } - if (c == KEY_ENTER) { - if (_edit_kb_row < KB_ROWS_CHAR) { - if (_edit_len < KB_MAX_LEN) { - char ch = KB_CHARS[_edit_kb_row][_edit_kb_col]; - if (_edit_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; - _edit_buf[_edit_len++] = ch; - _edit_buf[_edit_len] = '\0'; - } - } else { - if (_edit_kb_col == 0) { - // Caps Lock toggle - _edit_caps = !_edit_caps; - } else if (_edit_kb_col == 1) { - // Space - if (_edit_len < KB_MAX_LEN) { - _edit_buf[_edit_len++] = ' '; - _edit_buf[_edit_len] = '\0'; - } - } else if (_edit_kb_col == 2) { - // Del - if (_edit_len > 0) _edit_buf[--_edit_len] = '\0'; - } else if (_edit_kb_col == 3) { - // Placeholder picker - _edit_ph_mode = true; - _edit_ph_sel = 0; - } else { - // OK — commit to prefs, save deferred to settings exit - if (p) { - strncpy(p->custom_msgs[_edit_slot], _edit_buf, sizeof(p->custom_msgs[0]) - 1); - p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; - _dirty = true; - } - _edit_slot = -1; - } - } - return true; - } - if (c == KEY_CANCEL) { _edit_slot = -1; - return true; + } else if (res == KeyboardWidget::CANCELLED) { + _edit_slot = -1; } return true; } @@ -747,20 +573,9 @@ public: if (isMsgSlot(_selected) && enter) { int slot = msgSlotIndex(_selected); _edit_slot = slot; - _edit_len = 0; - _edit_buf[0] = '\0'; - // Pre-fill with existing text - if (p && p->custom_msgs[slot][0]) { - int existing = strlen(p->custom_msgs[slot]); - if (existing > KB_MAX_LEN) existing = KB_MAX_LEN; - memcpy(_edit_buf, p->custom_msgs[slot], existing); - _edit_buf[existing] = '\0'; - _edit_len = existing; - } - _edit_kb_row = 0; - _edit_kb_col = 0; - _edit_caps = false; - _edit_ph_mode = false; + char lbl[6]; + snprintf(lbl, sizeof(lbl), "M%d:", slot + 1); + _kb.begin(p ? p->custom_msgs[slot] : "", KB_MAX_LEN, lbl); return true; } return false; @@ -841,12 +656,7 @@ class QuickMsgScreen : public UIScreen { } // KEYBOARD - int _kb_row, _kb_col; - char _kb_text[KB_MAX_LEN + 1]; - int _kb_len; - bool _kb_caps; - bool _kb_ph_mode; - int _kb_ph_sel; + KeyboardWidget _kb; // Context menu (opened by long-press ENTER in CHANNEL_PICK) bool _ctx_open; @@ -1110,10 +920,7 @@ public: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), - _kb_row(0), _kb_col(0), _kb_len(0), - _kb_caps(false), _kb_ph_mode(false), _kb_ph_sel(0), _ctx_open(false), _ctx_dirty(false), _ctx_sel(0) { - _kb_text[0] = '\0'; memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -1170,9 +977,6 @@ public: _msg_sel = _msg_scroll = 0; _channel_sel = _channel_scroll = 0; _sending_to_channel = false; - _kb_row = _kb_col = _kb_len = 0; - _kb_caps = false; _kb_ph_mode = false; _kb_ph_sel = 0; - _kb_text[0] = '\0'; _room_mode = false; buildContactList(); @@ -1575,83 +1379,7 @@ public: display.setColor(DisplayDriver::LIGHT); } else if (_phase == KEYBOARD) { - // text preview with cursor - display.setColor(DisplayDriver::LIGHT); - const char* disp_start = _kb_text; - int disp_len = _kb_len; - if (disp_len > 20) { disp_start = _kb_text + (disp_len - 20); disp_len = 20; } - char preview[24]; - snprintf(preview, sizeof(preview), "%.*s_", disp_len, disp_start); - display.setCursor(0, KB_TEXT_Y); - display.print(preview); - display.fillRect(0, KB_SEP_Y, display.width(), 1); - - // 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 = ch - 'a' + 'A'; - char ch_buf[2] = { ch, '\0' }; - if (ch_buf[0] == ' ') ch_buf[0] = '_'; - int cx = col * KB_CELL_W; - if (sel) { - 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(ch_buf); - } - } - - // 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); // caps indicator - int sx = i * 25; - if (sel || active) { - 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 + 1, KB_SPECIAL_Y); - display.print(spec[i]); - display.setColor(DisplayDriver::LIGHT); - } - - // placeholder picker overlay - if (_kb_ph_mode) { - // Black box with white border - 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); // separator - for (int i = 0; i < KB_PH_COUNT; i++) { - int py = 33 + i * 10; - if (i == _kb_ph_sel) { - // Selected: white fill + black text - display.setColor(DisplayDriver::LIGHT); - display.fillRect(21, py - 1, 86, 10); - display.setColor(DisplayDriver::DARK); - } else { - // Unselected: white text on black - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(24, py); - display.print(KB_PH_LIST[i]); - } - display.setColor(DisplayDriver::LIGHT); - } + return _kb.render(display); } else { // MSG_PICK char title[24]; @@ -1894,89 +1622,18 @@ public: } } else if (_phase == KEYBOARD) { - 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_MAX_LEN) { - memcpy(_kb_text + _kb_len, ph, ph_len); - _kb_len += ph_len; - _kb_text[_kb_len] = '\0'; - } - _kb_ph_mode = false; - return true; - } - if (c == KEY_CANCEL) { _kb_ph_mode = false; return true; } - return true; - } - if (c == KEY_CANCEL) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::CANCELLED) { _phase = MSG_PICK; - return true; - } - if (c == KEY_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; + } else if (res == KeyboardWidget::DONE) { + if (_kb.len > 0) { + char expanded[KB_MAX_LEN + 1]; + expandMsg(_kb.buf, expanded, sizeof(expanded)); + bool ok = sendText(expanded); + afterSend(ok, expanded); } - return true; - } - if (c == KEY_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 (c == KEY_LEFT) { - if (_kb_col > 0) _kb_col--; - return true; - } - if (c == KEY_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 (c == KEY_ENTER) { - if (_kb_row < KB_ROWS_CHAR) { - if (_kb_len < KB_MAX_LEN) { - char ch = KB_CHARS[_kb_row][_kb_col]; - if (_kb_caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; - _kb_text[_kb_len++] = ch; - _kb_text[_kb_len] = '\0'; - } - } else { - if (_kb_col == 0) { - // Caps Lock toggle - _kb_caps = !_kb_caps; - } else if (_kb_col == 1) { - // Space - if (_kb_len < KB_MAX_LEN) { - _kb_text[_kb_len++] = ' '; - _kb_text[_kb_len] = '\0'; - } - } else if (_kb_col == 2) { - // Delete - if (_kb_len > 0) _kb_text[--_kb_len] = '\0'; - } else if (_kb_col == 3) { - // Placeholder picker - _kb_ph_mode = true; - _kb_ph_sel = 0; - } else { - // OK — send (expand placeholders first) - if (_kb_len > 0) { - char expanded[KB_MAX_LEN + 1]; - expandMsg(_kb_text, expanded, sizeof(expanded)); - bool ok = sendText(expanded); - afterSend(ok, expanded); - } - } - } - return true; } + return true; } else { // MSG_PICK int total_msg_items = 1 + _active_msg_count; @@ -1996,9 +1653,7 @@ public: } if (c == KEY_ENTER) { if (_msg_sel == 0) { - _kb_row = _kb_col = _kb_len = 0; - _kb_caps = false; _kb_ph_mode = false; _kb_ph_sel = 0; - _kb_text[0] = '\0'; + _kb.begin(); _phase = KEYBOARD; return true; } From 5fecafd4f91c072b655768012328b09943dc354a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 18:28:04 +0200 Subject: [PATCH 059/183] fix: KeyboardWidget preview buffer size and KEY_CONTEXT_MENU handling - preview[] increased from 32 to 40 bytes to safely fit max label (15 chars) + 20 chars text + cursor + null without snprintf truncation - KEY_CONTEXT_MENU (long-press ENTER) now treated as CANCEL in keyboard input handler, restoring BotScreen behavior lost in the refactor Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/KeyboardWidget.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 56803a7d..44733404 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -55,7 +55,7 @@ struct KeyboardWidget { const char* disp_start = buf; int disp_len = len; if (disp_len > 20) { disp_start = buf + (disp_len - 20); disp_len = 20; } - char preview[32]; + char preview[40]; snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start); display.setCursor(0, KB_TEXT_Y); display.print(preview); @@ -147,7 +147,7 @@ struct KeyboardWidget { return NONE; } - if (c == KEY_CANCEL) return CANCELLED; + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) return CANCELLED; if (c == KEY_UP) { if (row > 0) { From 464f5cd7a22bc332ab2030d17a4e848d266393cf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 18:34:33 +0200 Subject: [PATCH 060/183] fix: remove label prefix from keyboard preview for visual consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three keyboard instances (Settings, QuickMsg, Bot) now show plain text + cursor without any field-name prefix. Removes the label field from KeyboardWidget entirely — the screen title already indicates context. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/BotScreen.h | 6 +----- examples/companion_radio/ui-new/KeyboardWidget.h | 11 ++++------- examples/companion_radio/ui-new/UITask.cpp | 4 +--- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 8966c507..f1b9747d 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -164,21 +164,17 @@ public: _kb_field = _sel; const char* initial; int max; - const char* lbl; if (_sel == 2) { initial = _prefs->bot_trigger; max = sizeof(_prefs->bot_trigger) - 1; - lbl = "Trigger:"; } else if (_sel == 3) { initial = _prefs->bot_reply_dm; max = sizeof(_prefs->bot_reply_dm) - 1; - lbl = "Reply:"; } else { initial = _prefs->bot_reply_ch; max = sizeof(_prefs->bot_reply_ch) - 1; - lbl = "Reply:"; } - _kb.begin(initial, max, lbl); + _kb.begin(initial, max); return true; } return false; diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 44733404..524598f9 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -27,7 +27,6 @@ struct KeyboardWidget { char buf[KB_MAX_LEN + 1]; int len; int max_len; - char label[16]; int row, col; bool caps; bool ph_mode; @@ -35,9 +34,7 @@ struct KeyboardWidget { enum Result { NONE, DONE, CANCELLED }; - void begin(const char* initial = "", int max = KB_MAX_LEN, const char* lbl = "") { - strncpy(label, lbl, sizeof(label) - 1); - label[sizeof(label) - 1] = '\0'; + void begin(const char* initial = "", int max = KB_MAX_LEN) { max_len = (max > KB_MAX_LEN) ? KB_MAX_LEN : max; strncpy(buf, initial, max_len); buf[max_len] = '\0'; @@ -51,12 +48,12 @@ struct KeyboardWidget { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - // text preview: scroll so cursor is always visible (last 20 chars) + // text preview: last 20 chars + cursor const char* disp_start = buf; int disp_len = len; if (disp_len > 20) { disp_start = buf + (disp_len - 20); disp_len = 20; } - char preview[40]; - snprintf(preview, sizeof(preview), "%s%.*s_", label, disp_len, disp_start); + char preview[24]; + snprintf(preview, sizeof(preview), "%.*s_", disp_len, disp_start); display.setCursor(0, KB_TEXT_Y); display.print(preview); display.fillRect(0, KB_SEP_Y, display.width(), 1); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 1fe60456..bcf694b1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -573,9 +573,7 @@ public: if (isMsgSlot(_selected) && enter) { int slot = msgSlotIndex(_selected); _edit_slot = slot; - char lbl[6]; - snprintf(lbl, sizeof(lbl), "M%d:", slot + 1); - _kb.begin(p ? p->custom_msgs[slot] : "", KB_MAX_LEN, lbl); + _kb.begin(p ? p->custom_msgs[slot] : ""); return true; } return false; From 4063c2d2961674ddb80a5ae168300a62f3f775a8 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 21:09:37 +0200 Subject: [PATCH 061/183] style: shorten Del label to [Dl] to fit 25px special row cell Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/KeyboardWidget.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 524598f9..1fa91c23 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -79,8 +79,8 @@ struct KeyboardWidget { } } - // special row: [^] [Sp] [Del] [{}] [OK] - const char* spec[] = { "[^]", "[Sp]", "[Del]", "[{}]", "[OK]" }; + // special row: [^] [Sp] [Dl] [{}] [OK] + const char* spec[] = { "[^]", "[Sp]", "[Dl]", "[{}]", "[OK]" }; for (int i = 0; i < KB_SPECIAL; i++) { bool sel = (row == KB_ROWS_CHAR && col == i); bool active = (i == 0 && caps); From c7887a940d96bfae64d4df36cae24146434cd74c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 23:40:15 +0200 Subject: [PATCH 062/183] feat: DM history fullscreen message view on ENTER Pressing ENTER on a selected DM message now opens a fullscreen view (matching channel history behavior) instead of going directly to compose. Fullscreen supports UP/DOWN scrolling for long messages, LEFT/RIGHT to navigate between messages, and ENTER/CANCEL to return to the list. Compose is still accessible via [+ send] (ENTER when nothing selected). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 64 ++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bcf694b1..ad43c10a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -671,6 +671,8 @@ class QuickMsgScreen : public UIScreen { DmHistEntry _dm_hist[DM_HIST_MAX]; int _dm_hist_head, _dm_hist_count; int _dm_hist_sel, _dm_hist_scroll; + bool _dm_hist_fullscreen; + int _dm_hist_fs_scroll; static const int VISIBLE = 4; static const int ITEM_H = 12; @@ -918,6 +920,7 @@ public: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), + _dm_hist_fullscreen(false), _dm_hist_fs_scroll(0), _ctx_open(false), _ctx_dirty(false), _ctx_sel(0) { memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -1178,11 +1181,42 @@ public: } } else if (_phase == DM_HIST) { - char title[24]; display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); char filtered_name[sizeof(_sel_contact.name)]; display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); + + if (_dm_hist_fullscreen && _dm_hist_sel >= 0) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0) { + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), 10); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, sender); + display.setColor(DisplayDriver::LIGHT); + char lines[12][FS_CHARS + 1]; + int lcount = wrapLines(e.text, lines, 12); + int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; + if (_dm_hist_fs_scroll > max_scroll) _dm_hist_fs_scroll = max_scroll; + for (int i = 0; i < FS_VISIBLE && (_dm_hist_fs_scroll + i) < lcount; i++) { + display.setCursor(0, FS_START_Y + i * FS_LINE_H); + display.print(lines[_dm_hist_fs_scroll + i]); + } + if (_dm_hist_fs_scroll > 0) { + display.setCursor(display.width() - 6, FS_START_Y); + display.print("^"); + } + if (_dm_hist_fs_scroll < max_scroll) { + display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); + display.print("v"); + } + } + return 500; + } + + char title[24]; + display.setColor(DisplayDriver::LIGHT); snprintf(title, sizeof(title), "%.23s", filtered_name); display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 9, display.width(), 1); @@ -1482,6 +1516,7 @@ public: _task->clearDMUnread(_sel_contact.id.pub_key); _dm_hist_sel = -1; _dm_hist_scroll = 0; + _dm_hist_fullscreen = false; _phase = DM_HIST; } return true; @@ -1547,6 +1582,20 @@ public: } else if (_phase == DM_HIST) { int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + if (_dm_hist_fullscreen) { + if (c == KEY_UP) { if (_dm_hist_fs_scroll > 0) _dm_hist_fs_scroll--; return true; } + if (c == KEY_DOWN) { _dm_hist_fs_scroll++; return true; } + if (c == KEY_LEFT) { + if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_hist_fs_scroll = 0; } + return true; + } + if (c == KEY_RIGHT) { + if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_hist_fs_scroll = 0; } + return true; + } + if (c == KEY_ENTER || c == KEY_CANCEL) { _dm_hist_fullscreen = false; return true; } + return true; + } if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } if (c == KEY_UP) { if (_dm_hist_sel > 0) { @@ -1567,9 +1616,14 @@ public: return true; } if (c == KEY_ENTER) { - _sending_to_channel = false; - setupMsgPick(); - _phase = MSG_PICK; + if (_dm_hist_sel >= 0) { + _dm_hist_fullscreen = true; + _dm_hist_fs_scroll = 0; + } else { + _sending_to_channel = false; + setupMsgPick(); + _phase = MSG_PICK; + } return true; } From 26d3d7ab00dc79bce9df67beec2bc6db946c1aa0 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 13 May 2026 23:54:26 +0200 Subject: [PATCH 063/183] refactor: extract fullscreen message view into reusable FullscreenMsgView module Shared struct handles rendering and input for both DM and channel fullscreen views, replacing ~80 lines of duplicated inline code. Fixes navigation arrow indicators being swapped in channel fullscreen. Co-Authored-By: Claude Sonnet 4.6 --- .../ui-new/FullscreenMsgView.h | 87 +++++++++++ examples/companion_radio/ui-new/UITask.cpp | 144 ++++-------------- 2 files changed, 118 insertions(+), 113 deletions(-) create mode 100644 examples/companion_radio/ui-new/FullscreenMsgView.h diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h new file mode 100644 index 00000000..17e8e880 --- /dev/null +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include + +static const int FS_CHARS = 21; +static const int FS_LINE_H = 9; +static const int FS_START_Y = 12; +static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; + +struct FullscreenMsgView { + int scroll; + bool active; + + FullscreenMsgView() : scroll(0), active(false) {} + + enum Result { NONE, PREV, NEXT, CLOSE }; + + void begin() { scroll = 0; active = true; } + + static int wrapLines(const char* text, char out[][FS_CHARS + 1], int max_lines) { + int count = 0; + const char* p = text; + while (*p && count < max_lines) { + int len = strlen(p); + if (len <= FS_CHARS) { + strncpy(out[count++], p, FS_CHARS); + out[count - 1][len] = '\0'; + break; + } + int brk = FS_CHARS; + for (int i = FS_CHARS - 1; i > 0; i--) { + if (p[i] == ' ') { brk = i; break; } + } + strncpy(out[count], p, brk); + out[count++][brk] = '\0'; + p += brk + (p[brk] == ' ' ? 1 : 0); + } + return count; + } + + int render(DisplayDriver& display, const char* sender, const char* text, + bool has_prev, bool has_next) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), 10); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, sender); + display.setColor(DisplayDriver::LIGHT); + + char lines[12][FS_CHARS + 1]; + int lcount = wrapLines(text, lines, 12); + int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; + if (scroll > max_scroll) scroll = max_scroll; + + for (int i = 0; i < FS_VISIBLE && (scroll + i) < lcount; i++) { + display.setCursor(0, FS_START_Y + i * FS_LINE_H); + display.print(lines[scroll + i]); + } + if (scroll > 0) { + display.setCursor(display.width() - 6, FS_START_Y); + display.print("^"); + } + if (scroll < max_scroll) { + display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); + display.print("v"); + } + if (has_prev) { + display.setCursor(0, 56); + display.print("<"); + } + if (has_next) { + display.setCursor(display.width() - 6, 56); + display.print(">"); + } + return 300; + } + + Result handleInput(char c) { + if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } + if (c == KEY_DOWN) { scroll++; return NONE; } + if (c == KEY_LEFT) return PREV; + if (c == KEY_RIGHT) return NEXT; + if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; + return NONE; + } +}; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ad43c10a..ded3d814 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -98,6 +98,7 @@ static const uint16_t HP_SHUTDOWN = 1 << 8; static const uint16_t HP_ALL = 0x01FF; #include "KeyboardWidget.h" +#include "FullscreenMsgView.h" class SettingsScreen : public UIScreen { UITask* _task; @@ -621,38 +622,11 @@ class QuickMsgScreen : public UIScreen { // CHANNEL_HIST int _hist_sel, _hist_scroll; - bool _hist_fullscreen; - int _hist_fs_scroll; + FullscreenMsgView _fs; int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST int _viewing_max_seen; // highest _hist_sel reached in current session static const int CH_HIST_MAX = 32; - static const int FS_CHARS = 21; - static const int FS_LINE_H = 9; - static const int FS_START_Y = 12; - static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; - - int wrapLines(const char* text, char out[][FS_CHARS + 1], int max_lines) const { - int count = 0; - const char* p = text; - while (*p && count < max_lines) { - int len = strlen(p); - if (len <= FS_CHARS) { - strncpy(out[count++], p, FS_CHARS); - out[count-1][len] = '\0'; - break; - } - int brk = FS_CHARS; - for (int i = FS_CHARS - 1; i > 0; i--) { - if (p[i] == ' ') { brk = i; break; } - } - strncpy(out[count], p, brk); - out[count++][brk] = '\0'; - p += brk + (p[brk] == ' ' ? 1 : 0); - } - return count; - } - // KEYBOARD KeyboardWidget _kb; @@ -671,8 +645,7 @@ class QuickMsgScreen : public UIScreen { DmHistEntry _dm_hist[DM_HIST_MAX]; int _dm_hist_head, _dm_hist_count; int _dm_hist_sel, _dm_hist_scroll; - bool _dm_hist_fullscreen; - int _dm_hist_fs_scroll; + FullscreenMsgView _dm_fs; static const int VISIBLE = 4; static const int ITEM_H = 12; @@ -915,12 +888,10 @@ public: _sel_channel_idx(0), _sending_to_channel(false), _msg_sel(0), _msg_scroll(0), _active_msg_count(0), _hist_sel(0), _hist_scroll(0), - _hist_fullscreen(false), _hist_fs_scroll(0), _unread_at_entry(0), _viewing_max_seen(0), _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), - _dm_hist_fullscreen(false), _dm_hist_fs_scroll(0), _ctx_open(false), _ctx_dirty(false), _ctx_sel(0) { memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -1185,32 +1156,15 @@ public: char filtered_name[sizeof(_sel_contact.name)]; display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); - if (_dm_hist_fullscreen && _dm_hist_sel >= 0) { + if (_dm_fs.active && _dm_hist_sel >= 0) { int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0) { const DmHistEntry& e = _dm_hist[ring_pos]; const char* sender = e.outgoing ? "Me" : filtered_name; - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), 10); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(2, 1, display.width() - 4, sender); - display.setColor(DisplayDriver::LIGHT); - char lines[12][FS_CHARS + 1]; - int lcount = wrapLines(e.text, lines, 12); - int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; - if (_dm_hist_fs_scroll > max_scroll) _dm_hist_fs_scroll = max_scroll; - for (int i = 0; i < FS_VISIBLE && (_dm_hist_fs_scroll + i) < lcount; i++) { - display.setCursor(0, FS_START_Y + i * FS_LINE_H); - display.print(lines[_dm_hist_fs_scroll + i]); - } - if (_dm_hist_fs_scroll > 0) { - display.setCursor(display.width() - 6, FS_START_Y); - display.print("^"); - } - if (_dm_hist_fs_scroll < max_scroll) { - display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); - display.print("v"); - } + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + return _dm_fs.render(display, sender, e.text, + _dm_hist_sel < dm_count - 1, + _dm_hist_sel > 0); } return 500; } @@ -1280,7 +1234,7 @@ public: return dm_count > 0 ? 500 : 2000; } else if (_phase == CHANNEL_HIST) { - if (_hist_fullscreen && _hist_sel >= 0) { + if (_fs.active && _hist_sel >= 0) { int fs_hist_count = histCountForChannel(_sel_channel_idx); int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) { @@ -1295,36 +1249,9 @@ public: strncpy(fsender, "?", sizeof(fsender)); strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; } - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), 10); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(2, 1, display.width() - 4, fsender); - display.setColor(DisplayDriver::LIGHT); - - char lines[12][FS_CHARS + 1]; - int lcount = wrapLines(fmsg, lines, 12); - int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; - if (_hist_fs_scroll > max_scroll) _hist_fs_scroll = max_scroll; - for (int i = 0; i < FS_VISIBLE && (_hist_fs_scroll + i) < lcount; i++) { - display.setCursor(0, FS_START_Y + i * FS_LINE_H); - display.print(lines[_hist_fs_scroll + i]); - } - if (_hist_fs_scroll > 0) { - display.setCursor(display.width() - 6, FS_START_Y); - display.print("^"); - } - if (_hist_fs_scroll < max_scroll) { - display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); - display.print("v"); - } - if (_hist_sel > 0) { - display.setCursor(0, 56); - display.print("<"); - } - if (_hist_sel < fs_hist_count - 1) { - display.setCursor(display.width() - 6, 56); - display.print(">"); - } + return _fs.render(display, fsender, fmsg, + _hist_sel < fs_hist_count - 1, + _hist_sel > 0); } return 300; } @@ -1516,7 +1443,7 @@ public: _task->clearDMUnread(_sel_contact.id.pub_key); _dm_hist_sel = -1; _dm_hist_scroll = 0; - _dm_hist_fullscreen = false; + _dm_fs.active = false; _phase = DM_HIST; } return true; @@ -1582,18 +1509,15 @@ public: } else if (_phase == DM_HIST) { int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - if (_dm_hist_fullscreen) { - if (c == KEY_UP) { if (_dm_hist_fs_scroll > 0) _dm_hist_fs_scroll--; return true; } - if (c == KEY_DOWN) { _dm_hist_fs_scroll++; return true; } - if (c == KEY_LEFT) { - if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_hist_fs_scroll = 0; } - return true; + if (_dm_fs.active) { + auto res = _dm_fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::NEXT) { + if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::CLOSE) { + _dm_fs.active = false; } - if (c == KEY_RIGHT) { - if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_hist_fs_scroll = 0; } - return true; - } - if (c == KEY_ENTER || c == KEY_CANCEL) { _dm_hist_fullscreen = false; return true; } return true; } if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } @@ -1617,8 +1541,7 @@ public: } if (c == KEY_ENTER) { if (_dm_hist_sel >= 0) { - _dm_hist_fullscreen = true; - _dm_hist_fs_scroll = 0; + _dm_fs.begin(); } else { _sending_to_channel = false; setupMsgPick(); @@ -1629,19 +1552,15 @@ public: } else if (_phase == CHANNEL_HIST) { int ch_hist_count = histCountForChannel(_sel_channel_idx); - if (_hist_fullscreen) { - if (c == KEY_UP) { if (_hist_fs_scroll > 0) _hist_fs_scroll--; return true; } - if (c == KEY_DOWN) { _hist_fs_scroll++; return true; } - if (c == KEY_LEFT) { - if (_hist_sel > 0) { _hist_sel--; _hist_fs_scroll = 0; updateChannelUnread(); } - return true; + if (_fs.active) { + auto res = _fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::NEXT) { + if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::CLOSE) { + _fs.active = false; } - if (c == KEY_RIGHT) { - int count = histCountForChannel(_sel_channel_idx); - if (_hist_sel < count - 1) { _hist_sel++; _hist_fs_scroll = 0; updateChannelUnread(); } - return true; - } - if (c == KEY_ENTER || c == KEY_CANCEL) { _hist_fullscreen = false; return true; } return true; } if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } @@ -1662,8 +1581,7 @@ public: } if (c == KEY_ENTER) { if (_hist_sel >= 0) { - _hist_fullscreen = true; - _hist_fs_scroll = 0; + _fs.begin(); updateChannelUnread(); } else { _sending_to_channel = true; From 42b51935b139e3c49d767bf15d0aaabb693f8fcf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 00:12:07 +0200 Subject: [PATCH 064/183] feat: sensor placeholders in messages and keyboard picker expandMsg now expands {temp}, {hum}, {pres}, {batt}, {alt}, {lux}, {dist}, {co2} from live sensor readings. The [{}] picker in the on-screen keyboard shows only sensors currently detected by SensorManager and scrolls when more than 3 items fit the overlay. New SensorPlaceholders.h helper keeps sensor-to-keyboard wiring out of UITask.cpp. MsgExpand.h handles all expansion logic centrally; MyMeshBot and QuickMsgScreen pass &sensors and batt_volts to each call. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MsgExpand.h | 118 +++++++++++++++--- examples/companion_radio/MyMeshBot.h | 6 +- examples/companion_radio/ui-new/BotScreen.h | 2 + .../companion_radio/ui-new/KeyboardWidget.h | 68 +++++++--- .../ui-new/SensorPlaceholders.h | 31 +++++ examples/companion_radio/ui-new/UITask.cpp | 7 +- 6 files changed, 200 insertions(+), 32 deletions(-) create mode 100644 examples/companion_radio/ui-new/SensorPlaceholders.h diff --git a/examples/companion_radio/MsgExpand.h b/examples/companion_radio/MsgExpand.h index ef18f28e..b149fbb3 100644 --- a/examples/companion_radio/MsgExpand.h +++ b/examples/companion_radio/MsgExpand.h @@ -3,26 +3,83 @@ #include #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) +// Expands placeholders in tmpl into out (out_len bytes). +// {loc} — GPS coordinates (lat/lon) or "no GPS" +// {time} — local time HH:MM from RTC +// {temp} — temperature in °C (requires sm) +// {hum} — relative humidity % (requires sm) +// {pres} — barometric pressure hPa (requires sm) +// {batt} — battery voltage V (batt_volts >= 0 or LPP_VOLTAGE from sm) +// {alt} — altitude m (requires sm) +// {lux} — luminosity lux (requires sm) +// {dist} — distance m (requires sm) +// {co2} — CO2 concentration ppm (requires sm) 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) { + uint32_t utc_ts, int8_t tz_hours, + SensorManager* sm = nullptr, + float batt_volts = -1.0f) { + // sv indices: 0=temp 1=hum 2=pres 3=batt 4=alt 5=lux 6=dist 7=co2 + float sv[8] = {}; + bool sv_ok[8] = {}; + + if (sm) { + CayenneLPP lpp(100); + sm->querySensors(0xFF, lpp); + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + float tmp; + switch (type) { + case LPP_TEMPERATURE: + r.readTemperature(tmp); + if (!sv_ok[0]) { sv[0] = tmp; sv_ok[0] = true; } break; + case LPP_RELATIVE_HUMIDITY: + r.readRelativeHumidity(tmp); + if (!sv_ok[1]) { sv[1] = tmp; sv_ok[1] = true; } break; + case LPP_BAROMETRIC_PRESSURE: + r.readPressure(tmp); + if (!sv_ok[2]) { sv[2] = tmp; sv_ok[2] = true; } break; + case LPP_VOLTAGE: + r.readVoltage(tmp); + if (!sv_ok[3]) { sv[3] = tmp; sv_ok[3] = true; } break; + case LPP_ALTITUDE: + r.readAltitude(tmp); + if (!sv_ok[4]) { sv[4] = tmp; sv_ok[4] = true; } break; + case LPP_LUMINOSITY: + r.readLuminosity(tmp); + if (!sv_ok[5]) { sv[5] = tmp; sv_ok[5] = true; } break; + case LPP_DISTANCE: + r.readDistance(tmp); + if (!sv_ok[6]) { sv[6] = tmp; sv_ok[6] = true; } break; + case LPP_CONCENTRATION: + r.readConcentration(tmp); + if (!sv_ok[7]) { sv[7] = tmp; sv_ok[7] = true; } break; + default: + r.skipData(type); break; + } + } + } + // board battery takes precedence over INA sensor voltage + if (batt_volts >= 0.0f) { sv[3] = batt_volts; sv_ok[3] = true; } + int oi = 0; const char* p = tmpl; while (*p && oi < out_len - 1) { + // helper macro: append a buffer whose length is already known + #define APPEND(s, slen) do { \ + int _l = (slen); \ + if (oi + _l < out_len - 1) { memcpy(out + oi, (s), _l); oi += _l; } \ + } while(0) + 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; + if (gps_valid) snprintf(lb, sizeof(lb), "%.5f,%.5f", lat, lon); + else strcpy(lb, "no GPS"); + APPEND(lb, strlen(lb)); p += 5; } else if (strncmp(p, "{time}", 6) == 0) { if (utc_ts > 1000000000UL) { uint32_t local_ts = utc_ts + (int32_t)tz_hours * 3600; @@ -30,13 +87,46 @@ inline void expandMsg(const char* tmpl, char* out, int out_len, 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; } + APPEND(tb, strlen(tb)); } p += 6; + } else if (strncmp(p, "{temp}", 6) == 0) { + if (sv_ok[0]) { char b[10]; snprintf(b,sizeof(b),"%.1fC",sv[0]); APPEND(b,strlen(b)); } + else { APPEND("{temp}", 6); } + p += 6; + } else if (strncmp(p, "{hum}", 5) == 0) { + if (sv_ok[1]) { char b[8]; snprintf(b,sizeof(b),"%.0f%%",sv[1]); APPEND(b,strlen(b)); } + else { APPEND("{hum}", 5); } + p += 5; + } else if (strncmp(p, "{pres}", 6) == 0) { + if (sv_ok[2]) { char b[12]; snprintf(b,sizeof(b),"%.0fhPa",sv[2]); APPEND(b,strlen(b)); } + else { APPEND("{pres}", 6); } + p += 6; + } else if (strncmp(p, "{batt}", 6) == 0) { + if (sv_ok[3]) { char b[8]; snprintf(b,sizeof(b),"%.2fV",sv[3]); APPEND(b,strlen(b)); } + else { APPEND("{batt}", 6); } + p += 6; + } else if (strncmp(p, "{alt}", 5) == 0) { + if (sv_ok[4]) { char b[10]; snprintf(b,sizeof(b),"%.0fm",sv[4]); APPEND(b,strlen(b)); } + else { APPEND("{alt}", 5); } + p += 5; + } else if (strncmp(p, "{lux}", 5) == 0) { + if (sv_ok[5]) { char b[10]; snprintf(b,sizeof(b),"%.0flux",sv[5]); APPEND(b,strlen(b)); } + else { APPEND("{lux}", 5); } + p += 5; + } else if (strncmp(p, "{dist}", 6) == 0) { + if (sv_ok[6]) { char b[12]; snprintf(b,sizeof(b),"%.2fm",sv[6]); APPEND(b,strlen(b)); } + else { APPEND("{dist}", 6); } + p += 6; + } else if (strncmp(p, "{co2}", 5) == 0) { + if (sv_ok[7]) { char b[12]; snprintf(b,sizeof(b),"%.0fppm",sv[7]); APPEND(b,strlen(b)); } + else { APPEND("{co2}", 5); } + p += 5; } else { out[oi++] = *p++; } + + #undef APPEND } out[oi] = '\0'; } diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index b5068237..673af4cf 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -23,7 +23,8 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { expandMsg(_prefs.bot_reply_dm, expanded, sizeof(expanded), sensors.node_lat, sensors.node_lon, sensors.node_lat != 0.0 || sensors.node_lon != 0.0, - ts, _prefs.tz_offset_hours); + ts, _prefs.tz_offset_hours, + &sensors, (float)board.getBattMilliVolts() / 1000.0f); uint32_t expected_ack, est_timeout; if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) { _bot_last_reply_ms = millis(); @@ -57,7 +58,8 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { expandMsg(_prefs.bot_reply_ch, expanded, sizeof(expanded), sensors.node_lat, sensors.node_lon, sensors.node_lat != 0.0 || sensors.node_lon != 0.0, - ts, _prefs.tz_offset_hours); + ts, _prefs.tz_offset_hours, + &sensors, (float)board.getBattMilliVolts() / 1000.0f); 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/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index f1b9747d..9edd2f57 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -175,6 +175,8 @@ public: max = sizeof(_prefs->bot_reply_ch) - 1; } _kb.begin(initial, max); + if (_sel != 2) // trigger field doesn't support sensor placeholders + kbAddSensorPlaceholders(_kb, &sensors); return true; } return false; diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 1fa91c23..ab917551 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -12,9 +12,7 @@ static const char KB_CHARS[4][10] = { }; static const int KB_ROWS_CHAR = 4; static const int KB_COLS_CHAR = 10; -static const int KB_SPECIAL = 5; // [^] [Sp] [Del] [{}] [OK] -static const char* KB_PH_LIST[] = { "{loc}", "{time}" }; -static const int KB_PH_COUNT = 2; +static const int KB_SPECIAL = 5; // [^] [Sp] [De] [{}] [OK] static const int KB_MAX_LEN = 139; static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display static const int KB_CELL_H = 9; @@ -23,6 +21,10 @@ static const int KB_SEP_Y = 9; static const int KB_CHARS_Y = 11; static const int KB_SPECIAL_Y = 48; +static const int KB_PH_MAX = 12; // max placeholders in list +static const int KB_PH_LEN = 9; // max placeholder string length incl. null +static const int KB_PH_VISIBLE = 3; // items shown at once in overlay + struct KeyboardWidget { char buf[KB_MAX_LEN + 1]; int len; @@ -31,6 +33,9 @@ struct KeyboardWidget { bool caps; bool ph_mode; int ph_sel; + int ph_scroll; + char _ph_buf[KB_PH_MAX][KB_PH_LEN]; + int _ph_count; enum Result { NONE, DONE, CANCELLED }; @@ -41,7 +46,19 @@ struct KeyboardWidget { len = strlen(buf); row = col = 0; caps = ph_mode = false; - ph_sel = 0; + ph_sel = ph_scroll = 0; + // default placeholders — always available + _ph_count = 0; + addPlaceholder("{loc}"); + addPlaceholder("{time}"); + } + + void addPlaceholder(const char* ph) { + if (_ph_count < KB_PH_MAX) { + strncpy(_ph_buf[_ph_count], ph, KB_PH_LEN - 1); + _ph_buf[_ph_count][KB_PH_LEN - 1] = '\0'; + _ph_count++; + } } int render(DisplayDriver& display) { @@ -99,16 +116,28 @@ struct KeyboardWidget { // placeholder picker overlay if (ph_mode) { + int box_h = 12 + KB_PH_VISIBLE * 10; display.setColor(DisplayDriver::DARK); - display.fillRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + display.fillRect(20, 20, 88, box_h); display.setColor(DisplayDriver::LIGHT); - display.drawRect(20, 20, 88, 12 + KB_PH_COUNT * 10); + display.drawRect(20, 20, 88, box_h); 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 == ph_sel) { + + if (ph_scroll > 0) { + display.setCursor(100, 21); + display.print("^"); + } + if (ph_scroll + KB_PH_VISIBLE < _ph_count) { + display.setCursor(100, 33 + (KB_PH_VISIBLE - 1) * 10); + display.print("v"); + } + + for (int i = 0; i < KB_PH_VISIBLE && (ph_scroll + i) < _ph_count; i++) { + int idx = ph_scroll + i; + int py = 33 + i * 10; + if (idx == ph_sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(21, py - 1, 86, 10); display.setColor(DisplayDriver::DARK); @@ -116,7 +145,7 @@ struct KeyboardWidget { display.setColor(DisplayDriver::LIGHT); } display.setCursor(24, py); - display.print(KB_PH_LIST[i]); + display.print(_ph_buf[idx]); } display.setColor(DisplayDriver::LIGHT); } @@ -127,10 +156,18 @@ struct KeyboardWidget { Result handleInput(char c) { // placeholder overlay consumes all input if (ph_mode) { - if (c == KEY_UP && ph_sel > 0) { ph_sel--; return NONE; } - if (c == KEY_DOWN && ph_sel < KB_PH_COUNT - 1){ ph_sel++; return NONE; } + if (c == KEY_UP && ph_sel > 0) { + ph_sel--; + if (ph_sel < ph_scroll) ph_scroll = ph_sel; + return NONE; + } + if (c == KEY_DOWN && ph_sel < _ph_count - 1) { + ph_sel++; + if (ph_sel >= ph_scroll + KB_PH_VISIBLE) ph_scroll = ph_sel - KB_PH_VISIBLE + 1; + return NONE; + } if (c == KEY_ENTER) { - const char* ph = KB_PH_LIST[ph_sel]; + const char* ph = _ph_buf[ph_sel]; int ph_len = strlen(ph); if (len + ph_len <= max_len) { memcpy(buf + len, ph, ph_len); @@ -189,8 +226,9 @@ struct KeyboardWidget { if (len > 0) buf[--len] = '\0'; break; case 3: - ph_mode = true; - ph_sel = 0; + ph_mode = true; + ph_sel = 0; + ph_scroll = 0; break; case 4: return DONE; diff --git a/examples/companion_radio/ui-new/SensorPlaceholders.h b/examples/companion_radio/ui-new/SensorPlaceholders.h new file mode 100644 index 00000000..e244b236 --- /dev/null +++ b/examples/companion_radio/ui-new/SensorPlaceholders.h @@ -0,0 +1,31 @@ +#pragma once +// Populates a KeyboardWidget's placeholder list with sensor types currently +// detected by SensorManager. Always adds {loc} and {time} via begin(); this +// helper appends the sensor-specific entries on top. + +#include "KeyboardWidget.h" +#include +#include + +inline void kbAddSensorPlaceholders(KeyboardWidget& kb, SensorManager* sm) { + if (!sm) return; + uint8_t types[16]; + int tc = sm->getAvailableLPPTypes(types, 16); + + static const struct { uint8_t t; const char* ph; } MAP[] = { + { LPP_TEMPERATURE, "{temp}" }, + { LPP_RELATIVE_HUMIDITY, "{hum}" }, + { LPP_BAROMETRIC_PRESSURE, "{pres}" }, + { LPP_VOLTAGE, "{batt}" }, + { LPP_ALTITUDE, "{alt}" }, + { LPP_LUMINOSITY, "{lux}" }, + { LPP_DISTANCE, "{dist}" }, + { LPP_CONCENTRATION, "{co2}" }, + }; + + for (const auto& m : MAP) { + for (int i = 0; i < tc; i++) { + if (types[i] == m.t) { kb.addPlaceholder(m.ph); break; } + } + } +} diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ded3d814..a6324b9b 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -99,6 +99,7 @@ static const uint16_t HP_ALL = 0x01FF; #include "KeyboardWidget.h" #include "FullscreenMsgView.h" +#include "SensorPlaceholders.h" class SettingsScreen : public UIScreen { UITask* _task; @@ -575,6 +576,7 @@ public: int slot = msgSlotIndex(_selected); _edit_slot = slot; _kb.begin(p ? p->custom_msgs[slot] : ""); + kbAddSensorPlaceholders(_kb, &sensors); return true; } return false; @@ -667,9 +669,11 @@ class QuickMsgScreen : public UIScreen { } #endif NodePrefs* np = _task->getNodePrefs(); + float batt = (float)board.getBattMilliVolts() / 1000.0f; ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, rtc_clock.getCurrentTime(), - np ? np->tz_offset_hours : 0); + np ? np->tz_offset_hours : 0, + &sensors, batt); } void setupMsgPick() { @@ -1624,6 +1628,7 @@ public: if (c == KEY_ENTER) { if (_msg_sel == 0) { _kb.begin(); + kbAddSensorPlaceholders(_kb, &sensors); _phase = KEYBOARD; return true; } From 2f32683494000f6cbeab10c4274657e6563d2c0e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 08:06:07 +0200 Subject: [PATCH 065/183] refactor: extract popup menu into reusable PopupMenu struct with wrap-around navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PopupMenu.h: generic scrollable overlay with wrap-around nav (up from first wraps to last, down from last wraps to first), closes on ENTER or CANCEL/CONTEXT_MENU. Default constructor ensures active=false on creation. - KeyboardWidget: replace inline placeholder overlay (~50 lines) with PopupMenu - UITask QuickMsgScreen: replace inline context menus in CONTACT_PICK and CHANNEL_PICK with PopupMenu; notif label stored in _ctx_notif_item at open time - BotScreen: clear placeholder list for trigger field (placeholders are expansion tokens that would never match incoming literal text) - MsgExpand: fix APPEND macro off-by-one (oi+_l < out_len-1 → < out_len) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/PopupMenu.h | 90 +++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 examples/companion_radio/ui-new/PopupMenu.h diff --git a/examples/companion_radio/ui-new/PopupMenu.h b/examples/companion_radio/ui-new/PopupMenu.h new file mode 100644 index 00000000..94748870 --- /dev/null +++ b/examples/companion_radio/ui-new/PopupMenu.h @@ -0,0 +1,90 @@ +#pragma once +#include +#include + +// Generic scrollable popup menu overlay. +// Caller owns item string lifetimes — addItem() stores const char* pointers. +// Navigation wraps around: up from first item goes to last, and vice versa. +struct PopupMenu { + static const int PM_MAX_ITEMS = 16; + static const int PM_BX = 12; + static const int PM_BY = 10; + static const int PM_BW = 104; + static const int PM_ITEM_H = 10; + + const char* _items[PM_MAX_ITEMS]; + int _count; + int _sel; + int _scroll; + int _visible; + bool active; + const char* _title; + + enum Result { NONE, SELECTED, CANCELLED }; + + PopupMenu() : _count(0), _sel(0), _scroll(0), _visible(3), active(false), _title(nullptr) {} + + void begin(const char* title, int visible = 3) { + _count = 0; _sel = 0; _scroll = 0; + _visible = visible; active = true; _title = title; + } + + void addItem(const char* item) { + if (_count < PM_MAX_ITEMS) _items[_count++] = item; + } + + int render(DisplayDriver& display) { + int vis = (_count < _visible) ? _count : _visible; + int bh = 12 + vis * PM_ITEM_H; + + display.setColor(DisplayDriver::DARK); + display.fillRect(PM_BX, PM_BY, PM_BW, bh); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(PM_BX, PM_BY, PM_BW, bh); + display.setCursor(PM_BX + 4, PM_BY + 1); + display.print(_title); + display.fillRect(PM_BX, PM_BY + 10, PM_BW, 1); + + if (_scroll > 0) + { display.setCursor(PM_BX + PM_BW - 7, PM_BY + 1); display.print("^"); } + if (_scroll + vis < _count) + { display.setCursor(PM_BX + PM_BW - 7, PM_BY + 2 + vis * PM_ITEM_H); display.print("v"); } + + for (int i = 0; i < vis && (_scroll + i) < _count; i++) { + int idx = _scroll + i; + int py = PM_BY + 12 + i * PM_ITEM_H; + if (idx == _sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(PM_BX + 1, py - 1, PM_BW - 2, PM_ITEM_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(PM_BX + 4, py); + display.print(_items[idx]); + } + display.setColor(DisplayDriver::LIGHT); + return 50; + } + + Result handleInput(char c) { + if (_count == 0) { active = false; return CANCELLED; } + if (c == KEY_UP) { + _sel = (_sel > 0) ? _sel - 1 : _count - 1; + if (_sel < _scroll) _scroll = _sel; + else if (_sel == _count - 1 && _count > _visible) _scroll = _count - _visible; + return NONE; + } + if (c == KEY_DOWN) { + _sel = (_sel < _count - 1) ? _sel + 1 : 0; + if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1; + else if (_sel == 0) _scroll = 0; + return NONE; + } + if (c == KEY_ENTER) { active = false; return SELECTED; } + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { active = false; return CANCELLED; } + return NONE; + } + + int selectedIndex() const { return _sel; } +}; From 7b63af1068fc77147419fb57ab2f7cd96248bdd6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 08:06:26 +0200 Subject: [PATCH 066/183] refactor: use PopupMenu in keyboard and context menus, fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KeyboardWidget: replace inline placeholder overlay with PopupMenu - UITask QuickMsgScreen: replace inline CONTACT_PICK/CHANNEL_PICK context menus with PopupMenu; notif label stored in _ctx_notif_item at open time - BotScreen: clear placeholder list for trigger field (expansion tokens would never match incoming literal text) - MsgExpand: fix APPEND macro off-by-one (oi+_l < out_len-1 → < out_len) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MsgExpand.h | 2 +- examples/companion_radio/ui-new/BotScreen.h | 5 +- .../companion_radio/ui-new/KeyboardWidget.h | 72 ++-------- examples/companion_radio/ui-new/UITask.cpp | 135 +++++------------- 4 files changed, 57 insertions(+), 157 deletions(-) diff --git a/examples/companion_radio/MsgExpand.h b/examples/companion_radio/MsgExpand.h index b149fbb3..24e9d91e 100644 --- a/examples/companion_radio/MsgExpand.h +++ b/examples/companion_radio/MsgExpand.h @@ -72,7 +72,7 @@ inline void expandMsg(const char* tmpl, char* out, int out_len, // helper macro: append a buffer whose length is already known #define APPEND(s, slen) do { \ int _l = (slen); \ - if (oi + _l < out_len - 1) { memcpy(out + oi, (s), _l); oi += _l; } \ + if (oi + _l < out_len) { memcpy(out + oi, (s), _l); oi += _l; } \ } while(0) if (strncmp(p, "{loc}", 5) == 0) { diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 9edd2f57..49ab0585 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -175,8 +175,11 @@ public: max = sizeof(_prefs->bot_reply_ch) - 1; } _kb.begin(initial, max); - if (_sel != 2) // trigger field doesn't support sensor placeholders + if (_sel == 2) { + _kb._ph_count = 0; // trigger is literal text — placeholders never match incoming msgs + } else { kbAddSensorPlaceholders(_kb, &sensors); + } return true; } return false; diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index ab917551..8ea7334b 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -2,6 +2,7 @@ #include #include +#include "PopupMenu.h" // Layout constants shared by all keyboard users static const char KB_CHARS[4][10] = { @@ -31,11 +32,9 @@ struct KeyboardWidget { int max_len; int row, col; bool caps; - bool ph_mode; - int ph_sel; - int ph_scroll; char _ph_buf[KB_PH_MAX][KB_PH_LEN]; int _ph_count; + PopupMenu _ph_menu; enum Result { NONE, DONE, CANCELLED }; @@ -45,8 +44,8 @@ struct KeyboardWidget { buf[max_len] = '\0'; len = strlen(buf); row = col = 0; - caps = ph_mode = false; - ph_sel = ph_scroll = 0; + caps = false; + _ph_menu.active = false; // default placeholders — always available _ph_count = 0; addPlaceholder("{loc}"); @@ -114,70 +113,26 @@ struct KeyboardWidget { display.setColor(DisplayDriver::LIGHT); } - // placeholder picker overlay - if (ph_mode) { - int box_h = 12 + KB_PH_VISIBLE * 10; - display.setColor(DisplayDriver::DARK); - display.fillRect(20, 20, 88, box_h); - display.setColor(DisplayDriver::LIGHT); - display.drawRect(20, 20, 88, box_h); - display.setCursor(24, 21); - display.print("Placeholder:"); - display.fillRect(20, 30, 88, 1); - - if (ph_scroll > 0) { - display.setCursor(100, 21); - display.print("^"); - } - if (ph_scroll + KB_PH_VISIBLE < _ph_count) { - display.setCursor(100, 33 + (KB_PH_VISIBLE - 1) * 10); - display.print("v"); - } - - for (int i = 0; i < KB_PH_VISIBLE && (ph_scroll + i) < _ph_count; i++) { - int idx = ph_scroll + i; - int py = 33 + i * 10; - if (idx == 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(_ph_buf[idx]); - } - display.setColor(DisplayDriver::LIGHT); - } + // placeholder picker overlay (drawn on top of keyboard) + if (_ph_menu.active) _ph_menu.render(display); return 50; } Result handleInput(char c) { // placeholder overlay consumes all input - if (ph_mode) { - if (c == KEY_UP && ph_sel > 0) { - ph_sel--; - if (ph_sel < ph_scroll) ph_scroll = ph_sel; - return NONE; - } - if (c == KEY_DOWN && ph_sel < _ph_count - 1) { - ph_sel++; - if (ph_sel >= ph_scroll + KB_PH_VISIBLE) ph_scroll = ph_sel - KB_PH_VISIBLE + 1; - return NONE; - } - if (c == KEY_ENTER) { - const char* ph = _ph_buf[ph_sel]; + if (_ph_menu.active) { + auto res = _ph_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + int idx = _ph_menu.selectedIndex(); + const char* ph = _ph_buf[idx]; int ph_len = strlen(ph); if (len + ph_len <= max_len) { memcpy(buf + len, ph, ph_len); len += ph_len; buf[len] = '\0'; } - ph_mode = false; - return NONE; } - ph_mode = false; // CANCEL or any other key closes the overlay return NONE; } @@ -226,9 +181,8 @@ struct KeyboardWidget { if (len > 0) buf[--len] = '\0'; break; case 3: - ph_mode = true; - ph_sel = 0; - ph_scroll = 0; + _ph_menu.begin("Placeholder:", KB_PH_VISIBLE); + for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]); break; case 4: return DONE; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a6324b9b..34527069 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -632,10 +632,10 @@ class QuickMsgScreen : public UIScreen { // KEYBOARD KeyboardWidget _kb; - // Context menu (opened by long-press ENTER in CHANNEL_PICK) - bool _ctx_open; - bool _ctx_dirty; - int _ctx_sel; + // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) + PopupMenu _ctx_menu; + bool _ctx_dirty; + char _ctx_notif_item[22]; struct ChHistEntry { uint8_t ch_idx; char text[140]; }; ChHistEntry _hist[CH_HIST_MAX]; @@ -896,7 +896,7 @@ public: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), - _ctx_open(false), _ctx_dirty(false), _ctx_sel(0) { + _ctx_dirty(false) { memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -958,9 +958,8 @@ public: buildContactList(); buildChannelList(); - _ctx_open = false; + _ctx_menu.active = false; _ctx_dirty = false; - _ctx_sel = 0; _unread_at_entry = 0; _viewing_max_seen = 0; } @@ -1048,38 +1047,8 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _contact_scroll, _num_contacts); - // Context menu overlay (long-press ENTER), DM mode only - if (_ctx_open && _num_contacts > 0 && !_room_mode) { - static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; - ContactInfo ci; - the_mesh.getContactByIdx(_sorted[_contact_sel], ci); - uint8_t nstate = dmNotifState(ci.id.pub_key); - char notif_item[22]; - snprintf(notif_item, sizeof(notif_item), "Notif: %s", NOTIF_LABELS[nstate]); - const char* items[] = { "Mark as read", notif_item }; - const int CTX_COUNT = 2; - - display.setColor(DisplayDriver::DARK); - display.fillRect(15, 14, 98, 34); - display.setColor(DisplayDriver::LIGHT); - display.drawRect(15, 14, 98, 34); - display.setCursor(19, 15); - display.print("Contact options"); - display.fillRect(15, 24, 98, 1); - for (int i = 0; i < CTX_COUNT; i++) { - int py = 27 + i * 10; - if (i == _ctx_sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(16, py - 1, 96, 10); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(19, py); - display.print(items[i]); - } - display.setColor(DisplayDriver::LIGHT); - } + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == CHANNEL_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); @@ -1123,37 +1092,8 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _channel_scroll, _num_channels); - // Context menu overlay (long-press ENTER) - if (_ctx_open && _num_channels > 0) { - static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; - uint8_t ch_idx = _channel_indices[_channel_sel]; - uint8_t nstate = chNotifState(ch_idx); - char notif_item[22]; - snprintf(notif_item, sizeof(notif_item), "Notif: %s", NOTIF_LABELS[nstate]); - const char* items[] = { "Mark all read", notif_item }; - const int CTX_COUNT = 2; - - display.setColor(DisplayDriver::DARK); - display.fillRect(15, 14, 98, 34); - display.setColor(DisplayDriver::LIGHT); - display.drawRect(15, 14, 98, 34); - display.setCursor(19, 15); - display.print("Channel options"); - display.fillRect(15, 24, 98, 1); - for (int i = 0; i < CTX_COUNT; i++) { - int py = 27 + i * 10; - if (i == _ctx_sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(16, py - 1, 96, 10); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(19, py); - display.print(items[i]); - } - display.setColor(DisplayDriver::LIGHT); - } + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == DM_HIST) { display.setTextSize(1); @@ -1408,18 +1348,12 @@ public: } else if (_phase == CONTACT_PICK) { // Context menu consumes all input while open - if (_ctx_open) { - if (c == KEY_CANCEL) { - if (_ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } - _ctx_open = false; - return true; - } - if (c == KEY_UP && _ctx_sel > 0) { _ctx_sel--; return true; } - if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } - if (c == KEY_ENTER && _num_contacts > 0) { + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_contacts > 0) { ContactInfo ci; if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { - if (_ctx_sel == 0) { + if (_ctx_menu.selectedIndex() == 0) { _task->clearDMUnread(ci.id.pub_key); } else { uint8_t nstate = dmNotifState(ci.id.pub_key); @@ -1427,7 +1361,9 @@ public: _ctx_dirty = true; } } - return true; + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; } return true; } @@ -1453,32 +1389,34 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { - _ctx_sel = 0; + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); + _ctx_menu.begin("Contact options", 2); + _ctx_menu.addItem("Mark as read"); + _ctx_menu.addItem(_ctx_notif_item); _ctx_dirty = false; - _ctx_open = true; return true; } } else if (_phase == CHANNEL_PICK) { // Context menu consumes all input while open - if (_ctx_open) { - if (c == KEY_CANCEL) { - if (_ctx_dirty) { the_mesh.savePrefs(); _ctx_dirty = false; } - _ctx_open = false; - return true; - } - if (c == KEY_UP && _ctx_sel > 0) { _ctx_sel--; return true; } - if (c == KEY_DOWN && _ctx_sel < 1) { _ctx_sel++; return true; } - if (c == KEY_ENTER && _num_channels > 0) { + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_channels > 0) { uint8_t ch_idx = _channel_indices[_channel_sel]; - if (_ctx_sel == 0) { + if (_ctx_menu.selectedIndex() == 0) { _ch_unread[ch_idx] = 0; } else { uint8_t nstate = chNotifState(ch_idx); setChNotifState(ch_idx, (nstate + 1) % 3); _ctx_dirty = true; } - return true; + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; } return true; } @@ -1505,9 +1443,14 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _num_channels > 0) { - _ctx_sel = 0; + uint8_t ch_idx = _channel_indices[_channel_sel]; + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[chNotifState(ch_idx)]); + _ctx_menu.begin("Channel options", 2); + _ctx_menu.addItem("Mark all read"); + _ctx_menu.addItem(_ctx_notif_item); _ctx_dirty = false; - _ctx_open = true; return true; } From 24474f67fa939fce068a1729521baaff0354e0b3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 11:28:36 +0200 Subject: [PATCH 067/183] feat: NearbyScreen, configurable clock dashboard, memory optimizations - Add NearbyScreen: GPS contact list with haversine distance sort, type filter (ALL/Chat/Rpt/Room/Snsr), detail view with ping (RTT via ACK poll), node scan via advert(), context menu - Add DashboardConfigScreen: configure up to 3 data fields on the clock page (Battery, Temp, Humidity, Pressure, GPS, Altitude, Lux, CO2, Contacts); entered via long-press ENTER on clock page - Rework HP_CLOCK layout: full-screen clock (size 2 at y=0), date at y=19, separator at y=28, three data field rows at y=31/41/51; hide node name/battery indicator on clock page - Persist dashboard_fields[3] in NodePrefs and DataStore - Add isAckPending() to MyMesh for NearbyScreen ping detection - Wire NearbyScreen into ToolsScreen and UITask - Reduce NearbyScreen entry buffer from MAX_CONTACTS(100) to 32, saving ~3.3 KB RAM - Switch haversine/bearing math from double to float (sinf/cosf/atan2f), eliminating double libm and saving ~5-10 KB flash Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/MyMesh.h | 6 + examples/companion_radio/NodePrefs.h | 1 + .../ui-new/DashboardConfigScreen.h | 86 +++++ .../companion_radio/ui-new/NearbyScreen.h | 353 ++++++++++++++++++ examples/companion_radio/ui-new/ToolsScreen.h | 5 +- examples/companion_radio/ui-new/UITask.cpp | 125 ++++++- examples/companion_radio/ui-new/UITask.h | 4 + 8 files changed, 565 insertions(+), 19 deletions(-) create mode 100644 examples/companion_radio/ui-new/DashboardConfigScreen.h create mode 100644 examples/companion_radio/ui-new/NearbyScreen.h diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 75380404..4f5476cf 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -266,6 +266,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); if (file.available()) { file.read((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); + if (file.available()) { + file.read((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); + } } } } @@ -338,6 +341,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds)); file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); + file.write((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); file.close(); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 240fdbcc..5066b4bf 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -168,6 +168,12 @@ public: void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); } void saveRTCTime() { _store->saveRTCTime(); } + bool isAckPending(uint32_t expected_ack) const { + for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; i++) + if (expected_ack_table[i].ack == expected_ack) return true; + return false; + } + #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0"); diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 2e67665f..05fbbd66 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -60,4 +60,5 @@ struct NodePrefs { // persisted to file struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on static const int DM_NOTIF_TABLE_MAX = 16; DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes + uint8_t dashboard_fields[3]; // 0=None,1=Batt,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h new file mode 100644 index 00000000..1cbc007a --- /dev/null +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -0,0 +1,86 @@ +#pragma once +// Configures which data fields appear on the clock home page. +// Included by UITask.cpp after BotScreen.h. + +// Field type constants — used here and in UITask.cpp HP_CLOCK render. +static const uint8_t DASH_NONE = 0; +static const uint8_t DASH_BATT = 1; +static const uint8_t DASH_TEMP = 2; +static const uint8_t DASH_HUM = 3; +static const uint8_t DASH_PRES = 4; +static const uint8_t DASH_GPS = 5; +static const uint8_t DASH_ALT = 6; +static const uint8_t DASH_LUX = 7; +static const uint8_t DASH_CO2 = 8; +static const uint8_t DASH_NODES = 9; +static const uint8_t DASH_COUNT = 10; + +class DashboardConfigScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + + static const int FIELD_SLOTS = 3; + static const int ITEM_H = 13; + static const int START_Y = 13; + static const int VAL_X = 62; + + static const char* OPTION_NAMES[DASH_COUNT]; + + int _sel; + + void cycle(int slot, int dir) { + uint8_t& f = _prefs->dashboard_fields[slot]; + f = (uint8_t)((f + DASH_COUNT + dir) % DASH_COUNT); + } + +public: + DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { _sel = 0; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS"); + display.fillRect(0, 10, display.width(), 1); + + static const char* labels[] = { "Field 1", "Field 2", "Field 3" }; + for (int i = 0; i < FIELD_SLOTS; i++) { + int y = START_Y + i * ITEM_H; + bool sel = (i == _sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(2, y); + display.print(labels[i]); + display.setCursor(VAL_X, y); + uint8_t f = _prefs->dashboard_fields[i]; + display.print(OPTION_NAMES[f < DASH_COUNT ? f : DASH_NONE]); + display.setColor(DisplayDriver::LIGHT); + } + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + the_mesh.savePrefs(); + _task->gotoHomeScreen(); + return true; + } + if (c == KEY_UP && _sel > 0) { _sel--; return true; } + if (c == KEY_DOWN && _sel < FIELD_SLOTS - 1){ _sel++; return true; } + if (c == KEY_LEFT || c == KEY_PREV) { cycle(_sel, -1); return true; } + if (c == KEY_RIGHT || c == KEY_NEXT) { cycle(_sel, 1); return true; } + if (c == KEY_ENTER) { cycle(_sel, 1); return true; } + return false; + } +}; + +const char* DashboardConfigScreen::OPTION_NAMES[DASH_COUNT] = { + "None", "Battery", "Temp", "Humidity", "Pressure", + "GPS", "Altitude", "Lux", "CO2", "Contacts" +}; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h new file mode 100644 index 00000000..3ee57a1e --- /dev/null +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -0,0 +1,353 @@ +#pragma once +#include + +#ifndef M_PI + #define M_PI 3.14159265358979323846 +#endif + +class NearbyScreen : public UIScreen { + UITask* _task; + + static const int VISIBLE = 4; + static const int ITEM_H = 12; + static const int START_Y = 12; + static const int DIST_COL = 86; + + static const int FILTER_COUNT = 5; + static const char* FILTER_LABELS[FILTER_COUNT]; + static const uint8_t FILTER_TYPES[FILTER_COUNT]; + + struct Entry { + char name[32]; + int32_t lat_e6, lon_e6; + float dist_km; + uint8_t type; + int contact_idx; + }; + + static const int MAX_NEARBY = 32; + Entry _entries[MAX_NEARBY]; + int _count; + int _sel; + int _scroll; + bool _detail; + int32_t _own_lat, _own_lon; + bool _own_gps; + uint8_t _filter; + + // scan state + bool _scanning; + unsigned long _scan_started_ms; + static const unsigned long SCAN_DURATION_MS = 4000UL; + + // context menu (list view) + PopupMenu _ctx_menu; + + // ping state (detail view) + enum PingState { PING_IDLE, PING_WAIT, PING_OK, PING_TIMEOUT }; + PingState _ping_state; + uint32_t _ping_expected_ack; + unsigned long _ping_start_ms; + unsigned long _ping_rtt_ms; + static const unsigned long PING_TIMEOUT_MS = 30000UL; + + static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { + static const float DEG2RAD = (float)M_PI / 180.0f; + float la1 = lat1 * (1e-6f * DEG2RAD); + float la2 = lat2 * (1e-6f * DEG2RAD); + float dla = (lat2 - lat1) * (1e-6f * DEG2RAD); + float dlo = (lon2 - lon1) * (1e-6f * DEG2RAD); + float a = sinf(dla/2)*sinf(dla/2) + cosf(la1)*cosf(la2)*sinf(dlo/2)*sinf(dlo/2); + return 6371.0f * 2.0f * asinf(sqrtf(a)); + } + + static int bearingDeg(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { + static const float DEG2RAD = (float)M_PI / 180.0f; + float la1 = lat1 * (1e-6f * DEG2RAD); + float la2 = lat2 * (1e-6f * DEG2RAD); + float dlo = (lon2 - lon1) * (1e-6f * DEG2RAD); + float b = atan2f(sinf(dlo)*cosf(la2), + cosf(la1)*sinf(la2) - sinf(la1)*cosf(la2)*cosf(dlo)) * (180.0f / (float)M_PI); + if (b < 0.0f) b += 360.0f; + return (int)(b + 0.5f) % 360; + } + + static void fmtDist(char* buf, int n, float km) { + if (km < 1.0f) snprintf(buf, n, "%dm", (int)(km * 1000 + 0.5f)); + else if (km < 100.0f) snprintf(buf, n, "%.1fkm", km); + else snprintf(buf, n, "%dkm", (int)(km + 0.5f)); + } + + static const char* typeName(uint8_t t) { + switch (t) { + case ADV_TYPE_CHAT: return "Chat"; + case ADV_TYPE_REPEATER: return "Repeater"; + case ADV_TYPE_ROOM: return "Room"; + case ADV_TYPE_SENSOR: return "Sensor"; + default: return "Unknown"; + } + } + + void startScan() { + the_mesh.advert(); + _scanning = true; + _scan_started_ms = millis(); + } + + void refresh() { + _count = 0; + _own_gps = false; + _own_lat = _own_lon = 0; + +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + _own_lat = loc->getLatitude(); + _own_lon = loc->getLongitude(); + _own_gps = true; + } +#endif + + int nc = the_mesh.getNumContacts(); + for (int i = 0; i < nc && _count < MAX_NEARBY; i++) { + ContactInfo ci; + if (!the_mesh.getContactByIdx(i, ci)) continue; + if (ci.gps_lat == 0 && ci.gps_lon == 0) continue; + if (_filter > 0 && ci.type != FILTER_TYPES[_filter]) continue; + + Entry& e = _entries[_count++]; + strncpy(e.name, ci.name, sizeof(e.name) - 1); + e.name[sizeof(e.name) - 1] = '\0'; + e.lat_e6 = ci.gps_lat; + e.lon_e6 = ci.gps_lon; + e.dist_km = _own_gps ? haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon) : -1.0f; + e.type = ci.type; + e.contact_idx = i; + } + + if (_own_gps) { + for (int i = 0; i < _count - 1; i++) { + int best = i; + for (int j = i + 1; j < _count; j++) + if (_entries[j].dist_km < _entries[best].dist_km) best = j; + if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; } + } + } + + if (_count == 0) { + _sel = _scroll = 0; + } else if (_sel >= _count) { + _sel = _count - 1; + if (_scroll > _sel) _scroll = _sel; + } + } + +public: + NearbyScreen(UITask* task) + : _task(task), _filter(0), _scanning(false), _ping_state(PING_IDLE) {} + + void enter() { + _sel = _scroll = 0; + _detail = false; + _filter = 0; + _scanning = false; + _ctx_menu.active = false; + _ping_state = PING_IDLE; + refresh(); + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + + // poll scan timer — only refresh list when not in detail view + if (_scanning && !_detail && millis() - _scan_started_ms >= SCAN_DURATION_MS) { + _scanning = false; + refresh(); + } + + // poll ping ack + if (_ping_state == PING_WAIT) { + if (!the_mesh.isAckPending(_ping_expected_ack)) { + _ping_rtt_ms = millis() - _ping_start_ms; + _ping_state = PING_OK; + } else if (millis() - _ping_start_ms >= PING_TIMEOUT_MS) { + _ping_state = PING_TIMEOUT; + } + } + + // ── detail view ────────────────────────────────────────────────────────── + if (_detail && _sel < _count) { + const Entry& e = _entries[_sel]; + + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), 10); + display.setColor(DisplayDriver::DARK); + char filtered[32]; + display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered)); + display.drawTextEllipsized(2, 1, display.width() - 4, filtered); + display.setColor(DisplayDriver::LIGHT); + + char buf[32]; + snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); + display.setCursor(2, 13); display.print(buf); + + snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); + display.setCursor(2, 22); display.print(buf); + + if (e.dist_km >= 0.0f) { + char dist[12]; + fmtDist(dist, sizeof(dist), e.dist_km); + snprintf(buf, sizeof(buf), "Dist:%s %d\xb0", dist, bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6)); + display.setCursor(2, 31); display.print(buf); + } else { + display.setCursor(2, 31); display.print("Dist: no own GPS"); + } + + display.setCursor(2, 40); + switch (_ping_state) { + case PING_IDLE: display.print("[OK]=Ping"); break; + case PING_WAIT: display.print("Pinging..."); break; + case PING_OK: snprintf(buf,sizeof(buf),"RTT:%lums",_ping_rtt_ms); + display.print(buf); break; + case PING_TIMEOUT: display.print("Ping timeout"); break; + } + + snprintf(buf, sizeof(buf), "Type:%s", typeName(e.type)); + display.setCursor(2, 49); display.print(buf); + + return (_ping_state == PING_WAIT) ? 100 : 2000; + } + + // ── list view ──────────────────────────────────────────────────────────── + display.setColor(DisplayDriver::LIGHT); + if (_scanning) { + display.drawTextCentered(display.width() / 2, 0, "SCANNING..."); + } else { + char title[22]; + snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); + display.drawTextCentered(display.width() / 2, 0, title); + } + display.fillRect(0, 10, display.width(), 1); + + if (_count == 0) { + display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No GPS contacts"); + display.drawTextCentered(display.width() / 2, 40, "[M]=Scan"); + } else { + for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { + int idx = _scroll + i; + bool sel = (idx == _sel); + int y = START_Y + i * ITEM_H; + const Entry& e = _entries[idx]; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + char filt[32]; + display.translateUTF8ToBlocks(filt, e.name, sizeof(filt)); + display.drawTextEllipsized(2, y, DIST_COL - 4, filt); + + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); + char dist[10]; + if (e.dist_km >= 0.0f) fmtDist(dist, sizeof(dist), e.dist_km); + else strncpy(dist, "?GPS", sizeof(dist)); + display.setCursor(DIST_COL, y); + display.print(dist); + } + + display.setColor(DisplayDriver::LIGHT); + if (_scroll > 0) + { display.setCursor(display.width() - 6, START_Y); display.print("^"); } + if (_scroll + VISIBLE < _count) + { display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); } + } + + // context menu overlay — drawn on top regardless of list state + if (_ctx_menu.active) { + _ctx_menu.render(display); + return 50; + } + + return _scanning ? 100 : (_count == 0 ? 3000 : 2000); + } + + bool handleInput(char c) override { + // ── detail view input ──────────────────────────────────────────────────── + if (_detail) { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + _detail = false; + _ping_state = PING_IDLE; + return true; + } + if (c == KEY_ENTER && _ping_state == PING_IDLE) { + ContactInfo ci; + if (the_mesh.getContactByIdx(_entries[_sel].contact_idx, ci)) { + uint32_t expected_ack, est_timeout; + int res = the_mesh.sendMessage(ci, rtc_clock.getCurrentTime(), 0, "", expected_ack, est_timeout); + if (res != MSG_SEND_FAILED && expected_ack != 0) { + _ping_expected_ack = expected_ack; + _ping_start_ms = millis(); + _ping_state = PING_WAIT; + } + } + return true; + } + return true; + } + + // ── list view — context menu ───────────────────────────────────────────── + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + if (_ctx_menu.selectedIndex() == 0) { + startScan(); + } else { + _task->gotoToolsScreen(); + } + } + return true; + } + + // ── list view — normal input ───────────────────────────────────────────── + if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } + if (c == KEY_CONTEXT_MENU) { + _ctx_menu.begin("Options", 2); + _ctx_menu.addItem("Scan for nodes"); + _ctx_menu.addItem("Back"); + return true; + } + if (c == KEY_UP && _sel > 0) { + _sel--; + if (_sel < _scroll) _scroll = _sel; + return true; + } + if (c == KEY_DOWN && _sel < _count - 1) { + _sel++; + if (_sel >= _scroll + VISIBLE) _scroll = _sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _count > 0) { + _detail = true; + _ping_state = PING_IDLE; + return true; + } + if (c == KEY_LEFT) { + _filter = (_filter + FILTER_COUNT - 1) % FILTER_COUNT; + refresh(); + return true; + } + if (c == KEY_RIGHT) { + _filter = (_filter + 1) % FILTER_COUNT; + refresh(); + return true; + } + return false; + } +}; + +const char* NearbyScreen::FILTER_LABELS[5] = { "ALL", "Chat", "Rpt", "Room", "Snsr" }; +const uint8_t NearbyScreen::FILTER_TYPES[5] = { 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR }; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index d2cf363e..2bb600ee 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -6,7 +6,7 @@ class ToolsScreen : public UIScreen { UITask* _task; int _sel; - static const int ITEM_COUNT = 2; + static const int ITEM_COUNT = 3; static const char* ITEMS[ITEM_COUNT]; public: @@ -44,8 +44,9 @@ public: if (c == KEY_ENTER) { if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } if (_sel == 1) { _task->gotoBotScreen(); return true; } + if (_sel == 2) { _task->gotoNearbyScreen(); return true; } } return false; } }; -const char* ToolsScreen::ITEMS[2] = { "Ringtone Editor", "Auto-Reply Bot" }; +const char* ToolsScreen::ITEMS[3] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 34527069..a93584cb 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1592,6 +1592,8 @@ public: // ── Custom screens (separate files to ease upstream merges) ─────────────────── #include "RingtoneEditorScreen.h" #include "BotScreen.h" +#include "NearbyScreen.h" +#include "DashboardConfigScreen.h" #include "ToolsScreen.h" // ── HomeScreen ──────────────────────────────────────────────────────────────── @@ -1787,16 +1789,16 @@ public: int render(DisplayDriver& display) override { char tmp[80]; - // node name - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - char filtered_name[sizeof(_node_prefs->node_name)]; - display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 0); - display.print(filtered_name); - - // battery voltage - renderBatteryIndicator(display, _task->getBattMilliVolts()); + // node name + battery — hidden on CLOCK page (full screen used for dashboard) + if (_page != CLOCK) { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + char filtered_name[sizeof(_node_prefs->node_name)]; + display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); + display.setCursor(0, 0); + display.print(filtered_name); + renderBatteryIndicator(display, _task->getBattMilliVolts()); + } // ensure current page is visible (e.g. after settings change) if (!isPageVisible(_page)) _page = navPage(_page, +1); @@ -1842,17 +1844,90 @@ public: sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); else sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); - display.drawTextCentered(display.width() / 2, 18, buf); + display.drawTextCentered(display.width() / 2, 0, buf); display.setTextSize(1); - const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; - const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); - display.drawTextCentered(display.width() / 2, 40, buf); + display.drawTextCentered(display.width() / 2, 19, buf); - if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); - else sprintf(buf, "UTC%d", (int)tz); - display.drawTextCentered(display.width() / 2, 54, buf); + display.fillRect(0, 28, display.width(), 1); + + // dashboard data fields + if (_node_prefs) { + refresh_sensors(); + static const int FIELD_Y[] = { 31, 41, 51 }; + for (int fi = 0; fi < 3; fi++) { + uint8_t field = _node_prefs->dashboard_fields[fi]; + if (field == DASH_NONE) continue; + + char label[10], val[20]; + label[0] = '\0'; + val[0] = '\0'; + + if (field == DASH_BATT) { + strcpy(label, "Batt"); + uint16_t mv = _task->getBattMilliVolts(); + if (mv > 0) snprintf(val, sizeof(val), "%u.%02uV", mv/1000, (mv%1000)/10); + else strcpy(val, "--"); + } else if (field == DASH_GPS) { + strcpy(label, "GPS"); +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) + snprintf(val, sizeof(val), "%.3f %.3f", + loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f); + else + strcpy(val, "no fix"); +#else + strcpy(val, "--"); +#endif + } else if (field == DASH_NODES) { + strcpy(label, "Nodes"); + snprintf(val, sizeof(val), "%d", the_mesh.getNumContacts()); + } else { + uint8_t lpp_type = 0; + switch (field) { + case DASH_TEMP: strcpy(label, "Temp"); lpp_type = LPP_TEMPERATURE; break; + case DASH_HUM: strcpy(label, "Hum"); lpp_type = LPP_RELATIVE_HUMIDITY; break; + case DASH_PRES: strcpy(label, "Pres"); lpp_type = LPP_BAROMETRIC_PRESSURE; break; + case DASH_ALT: strcpy(label, "Alt"); lpp_type = LPP_ALTITUDE; break; + case DASH_LUX: strcpy(label, "Lux"); lpp_type = LPP_LUMINOSITY; break; + case DASH_CO2: strcpy(label, "CO2"); lpp_type = LPP_CONCENTRATION; break; + } + if (lpp_type) { + LPPReader r(sensors_lpp.getBuffer(), sensors_lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + if (type == lpp_type) { + float v; + switch (lpp_type) { + case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, sizeof(val), "%.1f\xf8""C", v); break; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, sizeof(val), "%.0f%%", v); break; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, sizeof(val), "%.0fhPa", v); break; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, sizeof(val), "%.0fm", v); break; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, sizeof(val), "%.0flux", v); break; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, sizeof(val), "%.0fppm", v); break; + } + break; + } + r.skipData(type); + } + } + if (!val[0]) strcpy(val, "--"); + } + + if (val[0] && label[0]) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(0, FIELD_Y[fi]); + display.print(label); + int vw = display.getTextWidth(val); + display.setCursor(display.width() - vw - 1, FIELD_Y[fi]); + display.print(val); + } + } + } } } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); @@ -2122,6 +2197,10 @@ public: _shutdown_init = true; // need to wait for button to be released return true; } + if (c == KEY_CONTEXT_MENU && _page == HomePage::CLOCK) { + _task->gotoDashboardConfig(); + return true; + } return false; } }; @@ -2171,6 +2250,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no tools_screen = new ToolsScreen(this); ringtone_edit = new RingtoneEditorScreen(this, node_prefs); bot_screen = new BotScreen(this, node_prefs); + nearby_screen = new NearbyScreen(this); + dashboard_config = new DashboardConfigScreen(this, node_prefs); setCurrScreen(splash); applyBrightness(); @@ -2195,6 +2276,16 @@ void UITask::gotoBotScreen() { setCurrScreen(bot_screen); } +void UITask::gotoNearbyScreen() { + ((NearbyScreen*)nearby_screen)->enter(); + setCurrScreen(nearby_screen); +} + +void UITask::gotoDashboardConfig() { + ((DashboardConfigScreen*)dashboard_config)->enter(); + setCurrScreen(dashboard_config); +} + void UITask::playMelody(const char* melody) { #ifdef PIN_BUZZER buzzer.playForced(melody); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index af95bce1..388500b7 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -63,6 +63,8 @@ class UITask : public AbstractUITask { UIScreen* tools_screen; UIScreen* ringtone_edit; UIScreen* bot_screen; + UIScreen* nearby_screen; + UIScreen* dashboard_config; UIScreen* curr; void userLedHandler(); @@ -98,6 +100,8 @@ public: void gotoToolsScreen(); void gotoRingtoneEditor(); void gotoBotScreen(); + void gotoNearbyScreen(); + void gotoDashboardConfig(); void playMelody(const char* melody); void stopMelody(); bool isMelodyPlaying(); From 9e5c0e79e78e9b1f1574fe380472bacefda5357a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 11:30:43 +0200 Subject: [PATCH 068/183] fix: move EXPECTED_ACK_TABLE_SIZE define before class for isAckPending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #define was inside the private section, after isAckPending() which references it — preprocessor hadn't seen the symbol yet at that point. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 5066b4bf..067406d5 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -85,6 +85,8 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; +#define EXPECTED_ACK_TABLE_SIZE 8 + class MyMesh : public BaseChatMesh, public DataStoreHost { public: MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui=NULL); @@ -253,7 +255,6 @@ private: uint32_t ack; ContactInfo* contact; }; - #define EXPECTED_ACK_TABLE_SIZE 8 AckTableEntry expected_ack_table[EXPECTED_ACK_TABLE_SIZE]; // circular table int next_ack_idx; From 85de0b02be2aeededb0c1b94b55c4d79ffa503f3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 11:36:11 +0200 Subject: [PATCH 069/183] fix: guard savePrefs with dirty flag in BotScreen and DashboardConfigScreen BotScreen and DashboardConfigScreen were calling savePrefs() on every exit regardless of whether any setting changed. Add _dirty flag set only on actual value changes (toggle, channel select, keyboard confirm, field cycle), reset on enter(). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/BotScreen.h | 7 ++++++- examples/companion_radio/ui-new/DashboardConfigScreen.h | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 49ab0585..5a333af6 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -13,6 +13,7 @@ class BotScreen : public UIScreen { static const int VAL_X = 70; int _sel; + bool _dirty; // keyboard state (reused for trigger and reply fields) int _kb_field; // -1=off, 2=trigger, 3=reply DM, 4=reply Ch @@ -45,6 +46,7 @@ public: void enter() { _sel = 0; _kb_field = -1; + _dirty = false; refreshChannels(); } @@ -119,6 +121,7 @@ public: strncpy(_prefs->bot_reply_ch, _kb.buf, sizeof(_prefs->bot_reply_ch) - 1); _prefs->bot_reply_ch[sizeof(_prefs->bot_reply_ch) - 1] = '\0'; } + _dirty = true; _kb_field = -1; } else if (res == KeyboardWidget::CANCELLED) { _kb_field = -1; @@ -127,7 +130,7 @@ public: } if (cancel) { - the_mesh.savePrefs(); + if (_dirty) the_mesh.savePrefs(); _task->gotoToolsScreen(); return true; } @@ -136,6 +139,7 @@ public: if (_sel == 0 && (enter || left || right)) { _prefs->bot_enabled ^= 1; + _dirty = true; return true; } if (_sel == 1) { @@ -157,6 +161,7 @@ public: _prefs->bot_channel_enabled = 1; _prefs->bot_channel_idx = _channel_indices[idx]; } + _dirty = true; return true; } } diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index 1cbc007a..23fa962e 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -26,17 +26,19 @@ class DashboardConfigScreen : public UIScreen { static const char* OPTION_NAMES[DASH_COUNT]; - int _sel; + int _sel; + bool _dirty; void cycle(int slot, int dir) { uint8_t& f = _prefs->dashboard_fields[slot]; f = (uint8_t)((f + DASH_COUNT + dir) % DASH_COUNT); + _dirty = true; } public: DashboardConfigScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} - void enter() { _sel = 0; } + void enter() { _sel = 0; _dirty = false; } int render(DisplayDriver& display) override { display.setTextSize(1); @@ -67,7 +69,7 @@ public: bool handleInput(char c) override { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - the_mesh.savePrefs(); + if (_dirty) the_mesh.savePrefs(); _task->gotoHomeScreen(); return true; } From 10307d3a03af1c12444a18aed1144a437fc00dc2 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 13:28:52 +0200 Subject: [PATCH 070/183] fix: ping RTT 0ms, degree symbol, clock page dots overlap - Ping: add _ping_ack_seen flag; RTT is measured only after ACK entry was first confirmed present in the table, preventing false 0ms when isAckPending() returns false before the entry is registered - NearbyScreen: replace unsupported degree symbol \xb0 with 'd' in bearing format ("Dist:1.5km 45d") - Clock page: hide page indicator dots (already hides node name/battery); eliminates overlap with size-2 clock text drawn at y=0 Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/NearbyScreen.h | 18 ++++++++++++------ examples/companion_radio/ui-new/UITask.cpp | 4 ++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 3ee57a1e..d8251a11 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -49,6 +49,7 @@ class NearbyScreen : public UIScreen { uint32_t _ping_expected_ack; unsigned long _ping_start_ms; unsigned long _ping_rtt_ms; + bool _ping_ack_seen; // true once ACK entry confirmed in table static const unsigned long PING_TIMEOUT_MS = 30000UL; static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { @@ -152,7 +153,8 @@ public: _filter = 0; _scanning = false; _ctx_menu.active = false; - _ping_state = PING_IDLE; + _ping_state = PING_IDLE; + _ping_ack_seen = false; refresh(); } @@ -167,9 +169,12 @@ public: // poll ping ack if (_ping_state == PING_WAIT) { - if (!the_mesh.isAckPending(_ping_expected_ack)) { + bool pending = the_mesh.isAckPending(_ping_expected_ack); + if (pending) { + _ping_ack_seen = true; + } else if (_ping_ack_seen) { _ping_rtt_ms = millis() - _ping_start_ms; - _ping_state = PING_OK; + _ping_state = PING_OK; } else if (millis() - _ping_start_ms >= PING_TIMEOUT_MS) { _ping_state = PING_TIMEOUT; } @@ -197,7 +202,7 @@ public: if (e.dist_km >= 0.0f) { char dist[12]; fmtDist(dist, sizeof(dist), e.dist_km); - snprintf(buf, sizeof(buf), "Dist:%s %d\xb0", dist, bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6)); + snprintf(buf, sizeof(buf), "Dist:%s %dd", dist, bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6)); display.setCursor(2, 31); display.print(buf); } else { display.setCursor(2, 31); display.print("Dist: no own GPS"); @@ -290,8 +295,9 @@ public: int res = the_mesh.sendMessage(ci, rtc_clock.getCurrentTime(), 0, "", expected_ack, est_timeout); if (res != MSG_SEND_FAILED && expected_ack != 0) { _ping_expected_ack = expected_ack; - _ping_start_ms = millis(); - _ping_state = PING_WAIT; + _ping_start_ms = millis(); + _ping_ack_seen = false; + _ping_state = PING_WAIT; } } return true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a93584cb..eb4701b5 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1803,8 +1803,8 @@ public: // ensure current page is visible (e.g. after settings change) if (!isPageVisible(_page)) _page = navPage(_page, +1); - // curr page indicator — only visible pages get a dot - { + // curr page indicator — hidden on CLOCK page (full screen used for dashboard) + if (_page != CLOCK) { int vis_count = 0, curr_vis = 0; for (int i = 0; i < (int)Count; i++) { if (!isPageVisible(i)) continue; From 862714982e34999a21fe64a7edf0f5cdb8bf74cd Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 13:55:35 +0200 Subject: [PATCH 071/183] fix: reset _ping_ack_seen on detail view enter and exit Stale _ping_ack_seen=true from a previous ping attempt could cause an immediate PING_OK (with wrong RTT) on the next ping if the new ACK entry hadn't appeared in the table yet. Reset the flag everywhere _ping_state is set to PING_IDLE. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/NearbyScreen.h | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index d8251a11..72526ba1 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -284,8 +284,9 @@ public: // ── detail view input ──────────────────────────────────────────────────── if (_detail) { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - _detail = false; - _ping_state = PING_IDLE; + _detail = false; + _ping_state = PING_IDLE; + _ping_ack_seen = false; return true; } if (c == KEY_ENTER && _ping_state == PING_IDLE) { @@ -337,8 +338,9 @@ public: return true; } if (c == KEY_ENTER && _count > 0) { - _detail = true; - _ping_state = PING_IDLE; + _detail = true; + _ping_state = PING_IDLE; + _ping_ack_seen = false; return true; } if (c == KEY_LEFT) { From 11cb0ae25e55850eb56ce6b167837f1ffb7d06d4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 15:49:23 +0200 Subject: [PATCH 072/183] fix: NearbyScreen shows all contacts, not only those with GPS - Remove gps_lat/lon == 0 filter: screen now shows all contacts reachable by radio; distance column shows ?GPS when coords unavailable - Guard haversine: only compute distance when both own AND remote GPS are valid (prevents bogus distance to 0 N 0 E) - Fix sort: nodes without GPS (-1) sort to end instead of front - Rename Chat filter label to Comp, type display to Companion - Update empty list message to "No contacts found" Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 72526ba1..cdbbb416 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -81,7 +81,7 @@ class NearbyScreen : public UIScreen { static const char* typeName(uint8_t t) { switch (t) { - case ADV_TYPE_CHAT: return "Chat"; + case ADV_TYPE_CHAT: return "Companion"; case ADV_TYPE_REPEATER: return "Repeater"; case ADV_TYPE_ROOM: return "Room"; case ADV_TYPE_SENSOR: return "Sensor"; @@ -113,26 +113,29 @@ class NearbyScreen : public UIScreen { for (int i = 0; i < nc && _count < MAX_NEARBY; i++) { ContactInfo ci; if (!the_mesh.getContactByIdx(i, ci)) continue; - if (ci.gps_lat == 0 && ci.gps_lon == 0) continue; if (_filter > 0 && ci.type != FILTER_TYPES[_filter]) continue; Entry& e = _entries[_count++]; strncpy(e.name, ci.name, sizeof(e.name) - 1); e.name[sizeof(e.name) - 1] = '\0'; - e.lat_e6 = ci.gps_lat; - e.lon_e6 = ci.gps_lon; - e.dist_km = _own_gps ? haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon) : -1.0f; + e.lat_e6 = ci.gps_lat; + e.lon_e6 = ci.gps_lon; + bool remote_gps = (ci.gps_lat != 0 || ci.gps_lon != 0); + e.dist_km = (_own_gps && remote_gps) + ? haversineKm(_own_lat, _own_lon, ci.gps_lat, ci.gps_lon) + : -1.0f; e.type = ci.type; e.contact_idx = i; } - if (_own_gps) { - for (int i = 0; i < _count - 1; i++) { - int best = i; - for (int j = i + 1; j < _count; j++) - if (_entries[j].dist_km < _entries[best].dist_km) best = j; - if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; } + // sort by distance ascending; nodes without GPS (-1) go to the end + for (int i = 0; i < _count - 1; i++) { + int best = i; + for (int j = i + 1; j < _count; j++) { + float dj = _entries[j].dist_km, db = _entries[best].dist_km; + if (dj >= 0.0f && (db < 0.0f || dj < db)) best = j; } + if (best != i) { Entry tmp = _entries[i]; _entries[i] = _entries[best]; _entries[best] = tmp; } } if (_count == 0) { @@ -235,7 +238,7 @@ public: display.fillRect(0, 10, display.width(), 1); if (_count == 0) { - display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No GPS contacts"); + display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No contacts found"); display.drawTextCentered(display.width() / 2, 40, "[M]=Scan"); } else { for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { @@ -357,5 +360,5 @@ public: } }; -const char* NearbyScreen::FILTER_LABELS[5] = { "ALL", "Chat", "Rpt", "Room", "Snsr" }; +const char* NearbyScreen::FILTER_LABELS[5] = { "ALL", "Comp", "Rpt", "Room", "Snsr" }; const uint8_t NearbyScreen::FILTER_TYPES[5] = { 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR }; From d59687647fb81b9b01c82edb64a41e06fd027397 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 15:57:00 +0200 Subject: [PATCH 073/183] fix: wire NearbyScreen ping through expected_ack_table so RTT is measurable Direct sendMessage() calls bypass the BT serial handler that populates expected_ack_table; add registerExpectedAck() to MyMesh and call it from NearbyScreen so isAckPending() can actually track the outstanding ping. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.h | 8 ++++++++ examples/companion_radio/ui-new/NearbyScreen.h | 1 + 2 files changed, 9 insertions(+) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 067406d5..4b1e73c8 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -176,6 +176,14 @@ public: return false; } + void registerExpectedAck(uint32_t expected_ack) { + if (!expected_ack) return; + expected_ack_table[next_ack_idx].msg_sent = millis(); + expected_ack_table[next_ack_idx].ack = expected_ack; + expected_ack_table[next_ack_idx].contact = nullptr; + next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; + } + #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { sensors.setSettingValue("gps", _prefs.gps_enabled ? "1" : "0"); diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index cdbbb416..bf75a3cb 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -298,6 +298,7 @@ public: uint32_t expected_ack, est_timeout; int res = the_mesh.sendMessage(ci, rtc_clock.getCurrentTime(), 0, "", expected_ack, est_timeout); if (res != MSG_SEND_FAILED && expected_ack != 0) { + the_mesh.registerExpectedAck(expected_ack); _ping_expected_ack = expected_ack; _ping_start_ms = millis(); _ping_ack_seen = false; From 09ac4558062e38a4cbdc781b0f506471454fa1d5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:08:33 +0200 Subject: [PATCH 074/183] feat: add periodic auto-advert with GPS position (Auto-Advert tool) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds configurable periodic 0-hop self-advert that includes GPS coordinates, making the device's position visible in other nodes' Nearby Nodes screen. Options: off / 1min / 2min / 5min / 10min / 30min / 1h. Accessible via Tools → Auto-Advert. Timer runs in MyMesh::loop(). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 ++ examples/companion_radio/MyMesh.cpp | 6 ++ examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/NodePrefs.h | 1 + .../companion_radio/ui-new/AutoAdvertScreen.h | 68 +++++++++++++++++++ examples/companion_radio/ui-new/ToolsScreen.h | 11 +-- examples/companion_radio/ui-new/UITask.cpp | 7 ++ examples/companion_radio/ui-new/UITask.h | 2 + 8 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 examples/companion_radio/ui-new/AutoAdvertScreen.h diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 4f5476cf..027b77a0 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -268,6 +268,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); if (file.available()) { file.read((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); + if (file.available()) { + file.read((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); + } } } } @@ -342,6 +345,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto)); file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); file.write((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); + file.write((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); file.close(); } diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 5fc8abc9..6071dda4 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -854,6 +854,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe offline_queue_len = 0; app_target_ver = 0; _bot_last_reply_ms = 0; + _next_auto_advert_ms = 0; clearPendingReqs(); next_ack_idx = 0; sign_data = NULL; @@ -2204,6 +2205,11 @@ void MyMesh::loop() { dirty_contacts_expiry = 0; } + if (_prefs.advert_auto_interval_sec > 0 && millisHasNowPassed(_next_auto_advert_ms)) { + advert(); + _next_auto_advert_ms = futureMillis(_prefs.advert_auto_interval_sec * 1000UL); + } + #ifdef DISPLAY_CLASS if (_ui) _ui->setHasConnection(_serial->isConnected()); #endif diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4b1e73c8..8362878c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -242,6 +242,7 @@ private: uint32_t sign_data_len; unsigned long dirty_contacts_expiry; unsigned long _bot_last_reply_ms; + unsigned long _next_auto_advert_ms; TransportKey send_scope; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 05fbbd66..94e593c3 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -61,4 +61,5 @@ struct NodePrefs { // persisted to file static const int DM_NOTIF_TABLE_MAX = 16; DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes uint8_t dashboard_fields[3]; // 0=None,1=Batt,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes + uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h new file mode 100644 index 00000000..c4c1dac6 --- /dev/null +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -0,0 +1,68 @@ +#pragma once +// Configures periodic automatic 0-hop advert with GPS position. +// Included by UITask.cpp after DashboardConfigScreen.h. + +class AutoAdvertScreen : public UIScreen { + UITask* _task; + NodePrefs* _prefs; + bool _dirty; + + static const int OPT_COUNT = 7; + static const uint32_t OPTS[OPT_COUNT]; + static const char* OPT_LABELS[OPT_COUNT]; + + int currentIdx() const { + for (int i = 0; i < OPT_COUNT; i++) + if (OPTS[i] == _prefs->advert_auto_interval_sec) return i; + return 0; + } + +public: + AutoAdvertScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + + void enter() { _dirty = false; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT"); + display.fillRect(0, 10, display.width(), 1); + + display.setCursor(2, 14); + display.print("Interval:"); + + int idx = currentIdx(); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 24, display.width(), 12); + display.setColor(DisplayDriver::DARK); + display.drawTextCentered(display.width() / 2, 25, OPT_LABELS[idx]); + display.setColor(DisplayDriver::LIGHT); + + display.setCursor(2, 40); + display.print("< > to change"); + display.setCursor(2, 51); + display.print("[Esc] to save"); + return 500; + } + + bool handleInput(char c) override { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoToolsScreen(); + return true; + } + bool right = (c == KEY_RIGHT || c == KEY_NEXT || c == KEY_ENTER); + bool left = (c == KEY_LEFT || c == KEY_PREV); + if (right || left) { + int idx = currentIdx(); + idx = right ? (idx + 1) % OPT_COUNT : (idx + OPT_COUNT - 1) % OPT_COUNT; + _prefs->advert_auto_interval_sec = OPTS[idx]; + _dirty = true; + return true; + } + return false; + } +}; + +const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 60, 120, 300, 600, 1800, 3600 }; +const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "off", "1min", "2min", "5min", "10min", "30min", "1h" }; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 2bb600ee..f738718a 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -6,7 +6,7 @@ class ToolsScreen : public UIScreen { UITask* _task; int _sel; - static const int ITEM_COUNT = 3; + static const int ITEM_COUNT = 4; static const char* ITEMS[ITEM_COUNT]; public: @@ -42,11 +42,12 @@ public: if (c == KEY_DOWN && _sel < ITEM_COUNT - 1) { _sel++; return true; } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } if (c == KEY_ENTER) { - if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } - if (_sel == 1) { _task->gotoBotScreen(); return true; } - if (_sel == 2) { _task->gotoNearbyScreen(); return true; } + if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } + if (_sel == 1) { _task->gotoBotScreen(); return true; } + if (_sel == 2) { _task->gotoNearbyScreen(); return true; } + if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; } } return false; } }; -const char* ToolsScreen::ITEMS[3] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes" }; +const char* ToolsScreen::ITEMS[4] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index eb4701b5..77b015f2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1594,6 +1594,7 @@ public: #include "BotScreen.h" #include "NearbyScreen.h" #include "DashboardConfigScreen.h" +#include "AutoAdvertScreen.h" #include "ToolsScreen.h" // ── HomeScreen ──────────────────────────────────────────────────────────────── @@ -2252,6 +2253,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no bot_screen = new BotScreen(this, node_prefs); nearby_screen = new NearbyScreen(this); dashboard_config = new DashboardConfigScreen(this, node_prefs); + auto_advert_screen = new AutoAdvertScreen(this, node_prefs); setCurrScreen(splash); applyBrightness(); @@ -2286,6 +2288,11 @@ void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); } +void UITask::gotoAutoAdvertScreen() { + ((AutoAdvertScreen*)auto_advert_screen)->enter(); + setCurrScreen(auto_advert_screen); +} + void UITask::playMelody(const char* melody) { #ifdef PIN_BUZZER buzzer.playForced(melody); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 388500b7..c96711dc 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -65,6 +65,7 @@ class UITask : public AbstractUITask { UIScreen* bot_screen; UIScreen* nearby_screen; UIScreen* dashboard_config; + UIScreen* auto_advert_screen; UIScreen* curr; void userLedHandler(); @@ -102,6 +103,7 @@ public: void gotoBotScreen(); void gotoNearbyScreen(); void gotoDashboardConfig(); + void gotoAutoAdvertScreen(); void playMelody(const char* melody); void stopMelody(); bool isMelodyPlaying(); From 083ddac85c8495f685210446ddf58768b07e0869 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:17:52 +0200 Subject: [PATCH 075/183] refactor: remove ping from NearbyScreen, rely on auto-advert for presence Ping sent empty messages appearing in target's chat history, and RTT measurement never worked for direct sendMessage() calls. Removed all ping state machinery and registerExpectedAck(); detail view now shows node info only (lat/lon/dist/bearing/type). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.h | 7 --- .../companion_radio/ui-new/NearbyScreen.h | 62 ++----------------- 2 files changed, 5 insertions(+), 64 deletions(-) diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 8362878c..ec8a56b1 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -176,13 +176,6 @@ public: return false; } - void registerExpectedAck(uint32_t expected_ack) { - if (!expected_ack) return; - expected_ack_table[next_ack_idx].msg_sent = millis(); - expected_ack_table[next_ack_idx].ack = expected_ack; - expected_ack_table[next_ack_idx].contact = nullptr; - next_ack_idx = (next_ack_idx + 1) % EXPECTED_ACK_TABLE_SIZE; - } #if ENV_INCLUDE_GPS == 1 void applyGpsPrefs() { diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index bf75a3cb..8a7eb21d 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -43,15 +43,6 @@ class NearbyScreen : public UIScreen { // context menu (list view) PopupMenu _ctx_menu; - // ping state (detail view) - enum PingState { PING_IDLE, PING_WAIT, PING_OK, PING_TIMEOUT }; - PingState _ping_state; - uint32_t _ping_expected_ack; - unsigned long _ping_start_ms; - unsigned long _ping_rtt_ms; - bool _ping_ack_seen; // true once ACK entry confirmed in table - static const unsigned long PING_TIMEOUT_MS = 30000UL; - static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { static const float DEG2RAD = (float)M_PI / 180.0f; float la1 = lat1 * (1e-6f * DEG2RAD); @@ -148,7 +139,7 @@ class NearbyScreen : public UIScreen { public: NearbyScreen(UITask* task) - : _task(task), _filter(0), _scanning(false), _ping_state(PING_IDLE) {} + : _task(task), _filter(0), _scanning(false) {} void enter() { _sel = _scroll = 0; @@ -156,8 +147,6 @@ public: _filter = 0; _scanning = false; _ctx_menu.active = false; - _ping_state = PING_IDLE; - _ping_ack_seen = false; refresh(); } @@ -170,19 +159,6 @@ public: refresh(); } - // poll ping ack - if (_ping_state == PING_WAIT) { - bool pending = the_mesh.isAckPending(_ping_expected_ack); - if (pending) { - _ping_ack_seen = true; - } else if (_ping_ack_seen) { - _ping_rtt_ms = millis() - _ping_start_ms; - _ping_state = PING_OK; - } else if (millis() - _ping_start_ms >= PING_TIMEOUT_MS) { - _ping_state = PING_TIMEOUT; - } - } - // ── detail view ────────────────────────────────────────────────────────── if (_detail && _sel < _count) { const Entry& e = _entries[_sel]; @@ -211,19 +187,10 @@ public: display.setCursor(2, 31); display.print("Dist: no own GPS"); } - display.setCursor(2, 40); - switch (_ping_state) { - case PING_IDLE: display.print("[OK]=Ping"); break; - case PING_WAIT: display.print("Pinging..."); break; - case PING_OK: snprintf(buf,sizeof(buf),"RTT:%lums",_ping_rtt_ms); - display.print(buf); break; - case PING_TIMEOUT: display.print("Ping timeout"); break; - } - snprintf(buf, sizeof(buf), "Type:%s", typeName(e.type)); - display.setCursor(2, 49); display.print(buf); + display.setCursor(2, 40); display.print(buf); - return (_ping_state == PING_WAIT) ? 100 : 2000; + return 2000; } // ── list view ──────────────────────────────────────────────────────────── @@ -287,24 +254,7 @@ public: // ── detail view input ──────────────────────────────────────────────────── if (_detail) { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - _detail = false; - _ping_state = PING_IDLE; - _ping_ack_seen = false; - return true; - } - if (c == KEY_ENTER && _ping_state == PING_IDLE) { - ContactInfo ci; - if (the_mesh.getContactByIdx(_entries[_sel].contact_idx, ci)) { - uint32_t expected_ack, est_timeout; - int res = the_mesh.sendMessage(ci, rtc_clock.getCurrentTime(), 0, "", expected_ack, est_timeout); - if (res != MSG_SEND_FAILED && expected_ack != 0) { - the_mesh.registerExpectedAck(expected_ack); - _ping_expected_ack = expected_ack; - _ping_start_ms = millis(); - _ping_ack_seen = false; - _ping_state = PING_WAIT; - } - } + _detail = false; return true; } return true; @@ -342,9 +292,7 @@ public: return true; } if (c == KEY_ENTER && _count > 0) { - _detail = true; - _ping_state = PING_IDLE; - _ping_ack_seen = false; + _detail = true; return true; } if (c == KEY_LEFT) { From 404cb6956ddc2c85c6b7293e00d6fedad80e9830 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:21:11 +0200 Subject: [PATCH 076/183] feat: add Fav filter and clearer bearing display in NearbyScreen - Add "Fav" as first filter category (contacts with flags & 1) - Show distance and bearing on separate lines in detail view - Add cardinal direction to bearing: "Az: 245d (SW)" Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 8a7eb21d..795123a8 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -13,7 +13,7 @@ class NearbyScreen : public UIScreen { static const int START_Y = 12; static const int DIST_COL = 86; - static const int FILTER_COUNT = 5; + static const int FILTER_COUNT = 6; static const char* FILTER_LABELS[FILTER_COUNT]; static const uint8_t FILTER_TYPES[FILTER_COUNT]; @@ -64,6 +64,11 @@ class NearbyScreen : public UIScreen { return (int)(b + 0.5f) % 360; } + static const char* bearingCardinal(int deg) { + static const char* dirs[] = { "N","NE","E","SE","S","SW","W","NW" }; + return dirs[((deg + 22) % 360) / 45]; + } + static void fmtDist(char* buf, int n, float km) { if (km < 1.0f) snprintf(buf, n, "%dm", (int)(km * 1000 + 0.5f)); else if (km < 100.0f) snprintf(buf, n, "%.1fkm", km); @@ -104,7 +109,8 @@ class NearbyScreen : public UIScreen { for (int i = 0; i < nc && _count < MAX_NEARBY; i++) { ContactInfo ci; if (!the_mesh.getContactByIdx(i, ci)) continue; - if (_filter > 0 && ci.type != FILTER_TYPES[_filter]) continue; + if (_filter == 0 && !(ci.flags & 1)) continue; // Fav: only starred + if (_filter >= 2 && ci.type != FILTER_TYPES[_filter]) continue; // type filter Entry& e = _entries[_count++]; strncpy(e.name, ci.name, sizeof(e.name) - 1); @@ -181,14 +187,18 @@ public: if (e.dist_km >= 0.0f) { char dist[12]; fmtDist(dist, sizeof(dist), e.dist_km); - snprintf(buf, sizeof(buf), "Dist:%s %dd", dist, bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6)); + int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); + snprintf(buf, sizeof(buf), "Dist: %s", dist); display.setCursor(2, 31); display.print(buf); + snprintf(buf, sizeof(buf), "Az: %dd (%s)", az, bearingCardinal(az)); + display.setCursor(2, 40); display.print(buf); } else { display.setCursor(2, 31); display.print("Dist: no own GPS"); + display.setCursor(2, 40); display.print("Az: unknown"); } snprintf(buf, sizeof(buf), "Type:%s", typeName(e.type)); - display.setCursor(2, 40); display.print(buf); + display.setCursor(2, 49); display.print(buf); return 2000; } @@ -309,5 +319,5 @@ public: } }; -const char* NearbyScreen::FILTER_LABELS[5] = { "ALL", "Comp", "Rpt", "Room", "Snsr" }; -const uint8_t NearbyScreen::FILTER_TYPES[5] = { 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR }; +const char* NearbyScreen::FILTER_LABELS[6] = { "Fav", "ALL", "Comp", "Rpt", "Room", "Snsr" }; +const uint8_t NearbyScreen::FILTER_TYPES[6] = { 0, 0, ADV_TYPE_CHAT, ADV_TYPE_REPEATER, ADV_TYPE_ROOM, ADV_TYPE_SENSOR }; From f4348527065a69e518bdfaf96c5f7dc024d3042c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:29:04 +0200 Subject: [PATCH 077/183] feat: show last-heard time in detail view, fix disappearing contact edge case - Detail view now shows "Seen: Xs/Xm/Xh ago" using ContactInfo.lastmod - Periodic detail refresh exits back to list view if the contact is no longer in the filtered list (e.g. removed from favourites) - Rearranged detail layout to fit 6 rows within 64px display Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 61 +++++++++++++++---- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 795123a8..264613a6 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -18,11 +18,12 @@ class NearbyScreen : public UIScreen { static const uint8_t FILTER_TYPES[FILTER_COUNT]; struct Entry { - char name[32]; - int32_t lat_e6, lon_e6; - float dist_km; - uint8_t type; - int contact_idx; + char name[32]; + int32_t lat_e6, lon_e6; + float dist_km; + uint8_t type; + int contact_idx; + uint32_t lastmod; // our RTC timestamp of last received packet }; static const int MAX_NEARBY = 32; @@ -40,6 +41,10 @@ class NearbyScreen : public UIScreen { unsigned long _scan_started_ms; static const unsigned long SCAN_DURATION_MS = 4000UL; + // detail view periodic refresh + unsigned long _detail_refresh_ms; + static const unsigned long DETAIL_REFRESH_MS = 10000UL; + // context menu (list view) PopupMenu _ctx_menu; @@ -69,6 +74,16 @@ class NearbyScreen : public UIScreen { return dirs[((deg + 22) % 360) / 45]; } + static void fmtAge(char* buf, int n, uint32_t lastmod) { + uint32_t now = rtc_clock.getCurrentTime(); + if (now < lastmod || lastmod == 0) { snprintf(buf, n, "unknown"); return; } + uint32_t age = now - lastmod; + if (age < 60) snprintf(buf, n, "%us ago", age); + else if (age < 3600) snprintf(buf, n, "%um ago", age / 60); + else if (age < 86400) snprintf(buf, n, "%uh ago", age / 3600); + else snprintf(buf, n, ">1d ago"); + } + static void fmtDist(char* buf, int n, float km) { if (km < 1.0f) snprintf(buf, n, "%dm", (int)(km * 1000 + 0.5f)); else if (km < 100.0f) snprintf(buf, n, "%.1fkm", km); @@ -123,6 +138,7 @@ class NearbyScreen : public UIScreen { : -1.0f; e.type = ci.type; e.contact_idx = i; + e.lastmod = ci.lastmod; } // sort by distance ascending; nodes without GPS (-1) go to the end @@ -165,6 +181,20 @@ public: refresh(); } + // periodic refresh in detail view — preserve selected contact by idx + if (_detail && millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) { + int saved_contact_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1; + refresh(); + bool found = false; + if (saved_contact_idx >= 0) { + for (int i = 0; i < _count; i++) { + if (_entries[i].contact_idx == saved_contact_idx) { _sel = i; found = true; break; } + } + } + if (!found) _detail = false; // contact left the filtered list — return to list view + _detail_refresh_ms = millis(); + } + // ── detail view ────────────────────────────────────────────────────────── if (_detail && _sel < _count) { const Entry& e = _entries[_sel]; @@ -177,28 +207,34 @@ public: display.drawTextEllipsized(2, 1, display.width() - 4, filtered); display.setColor(DisplayDriver::LIGHT); + // 6 rows in 54px (y=10..63): spacing 9px, start at y=11 char buf[32]; snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); - display.setCursor(2, 13); display.print(buf); + display.setCursor(2, 11); display.print(buf); snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); - display.setCursor(2, 22); display.print(buf); + display.setCursor(2, 20); display.print(buf); if (e.dist_km >= 0.0f) { char dist[12]; fmtDist(dist, sizeof(dist), e.dist_km); int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); snprintf(buf, sizeof(buf), "Dist: %s", dist); - display.setCursor(2, 31); display.print(buf); + display.setCursor(2, 29); display.print(buf); snprintf(buf, sizeof(buf), "Az: %dd (%s)", az, bearingCardinal(az)); - display.setCursor(2, 40); display.print(buf); + display.setCursor(2, 38); display.print(buf); } else { - display.setCursor(2, 31); display.print("Dist: no own GPS"); - display.setCursor(2, 40); display.print("Az: unknown"); + display.setCursor(2, 29); display.print("Dist: no own GPS"); + display.setCursor(2, 38); display.print("Az: unknown"); } snprintf(buf, sizeof(buf), "Type:%s", typeName(e.type)); - display.setCursor(2, 49); display.print(buf); + display.setCursor(2, 47); display.print(buf); + + char age[16]; + fmtAge(age, sizeof(age), e.lastmod); + snprintf(buf, sizeof(buf), "Seen:%s", age); + display.setCursor(2, 56); display.print(buf); return 2000; } @@ -303,6 +339,7 @@ public: } if (c == KEY_ENTER && _count > 0) { _detail = true; + _detail_refresh_ms = millis(); return true; } if (c == KEY_LEFT) { From 9c4dc0039a745e68a27c52fe620a5d2543114af3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:36:35 +0200 Subject: [PATCH 078/183] fix: clarify advert button labels in NearbyScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan does not discover others — it announces our own presence. Updated labels: "Scan for nodes"→"Send my advert", "SCANNING..."→"ADVERTISING...", "[M]=Scan"→"[M]=Advert". Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/NearbyScreen.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 264613a6..5cbac566 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -242,7 +242,7 @@ public: // ── list view ──────────────────────────────────────────────────────────── display.setColor(DisplayDriver::LIGHT); if (_scanning) { - display.drawTextCentered(display.width() / 2, 0, "SCANNING..."); + display.drawTextCentered(display.width() / 2, 0, "ADVERTISING..."); } else { char title[22]; snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); @@ -252,7 +252,7 @@ public: if (_count == 0) { display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No contacts found"); - display.drawTextCentered(display.width() / 2, 40, "[M]=Scan"); + display.drawTextCentered(display.width() / 2, 40, "[M]=Advert"); } else { for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { int idx = _scroll + i; @@ -323,7 +323,7 @@ public: if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { _ctx_menu.begin("Options", 2); - _ctx_menu.addItem("Scan for nodes"); + _ctx_menu.addItem("Send my advert"); _ctx_menu.addItem("Back"); return true; } From e43047f5614db1f841a5f6c1228a0330ad7e80f8 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:41:04 +0200 Subject: [PATCH 079/183] fix: show keyboard placeholders only for sensors with actual live data Replace getAvailableLPPTypes() (hardware-detected) with a live querySensors() call so only placeholders for sensors currently returning values appear in the picker. Co-Authored-By: Claude Sonnet 4.6 --- .../ui-new/SensorPlaceholders.h | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/ui-new/SensorPlaceholders.h b/examples/companion_radio/ui-new/SensorPlaceholders.h index e244b236..c1b652a7 100644 --- a/examples/companion_radio/ui-new/SensorPlaceholders.h +++ b/examples/companion_radio/ui-new/SensorPlaceholders.h @@ -1,7 +1,7 @@ #pragma once -// Populates a KeyboardWidget's placeholder list with sensor types currently -// detected by SensorManager. Always adds {loc} and {time} via begin(); this -// helper appends the sensor-specific entries on top. +// Populates a KeyboardWidget's placeholder list based on live sensor data. +// Only adds placeholders for types that actually returned a value right now. +// Always adds {loc} and {time} via begin(); this helper appends the rest. #include "KeyboardWidget.h" #include @@ -9,8 +9,23 @@ inline void kbAddSensorPlaceholders(KeyboardWidget& kb, SensorManager* sm) { if (!sm) return; - uint8_t types[16]; - int tc = sm->getAvailableLPPTypes(types, 16); + + CayenneLPP lpp(100); + sm->querySensors(0xFF, lpp); + + // collect unique LPP types that have actual data + uint8_t present[16]; + int pc = 0; + { + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + bool already = false; + for (int i = 0; i < pc; i++) if (present[i] == type) { already = true; break; } + if (!already && pc < 16) present[pc++] = type; + r.skipData(type); + } + } static const struct { uint8_t t; const char* ph; } MAP[] = { { LPP_TEMPERATURE, "{temp}" }, @@ -24,8 +39,8 @@ inline void kbAddSensorPlaceholders(KeyboardWidget& kb, SensorManager* sm) { }; for (const auto& m : MAP) { - for (int i = 0; i < tc; i++) { - if (types[i] == m.t) { kb.addPlaceholder(m.ph); break; } + for (int i = 0; i < pc; i++) { + if (present[i] == m.t) { kb.addPlaceholder(m.ph); break; } } } } From e38dc87c256e5faba0fd60ac8b4284dc065687d5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 16:51:57 +0200 Subject: [PATCH 080/183] feat: blinking ~ indicator in status bar when auto-advert is active Blinks at 4Hz (250ms toggle) with inverted background so it's clearly visible without being distracting. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 77b015f2..edf435ba 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1798,6 +1798,14 @@ public: display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); display.setCursor(0, 0); display.print(filtered_name); + if (_node_prefs->advert_auto_interval_sec > 0 && (millis() / 250) % 2) { + int ax = display.getTextWidth(filtered_name); + display.fillRect(ax, 0, display.getTextWidth("~") + 1, 9); + display.setColor(DisplayDriver::DARK); + display.setCursor(ax, 0); + display.print("~"); + display.setColor(DisplayDriver::LIGHT); + } renderBatteryIndicator(display, _task->getBattMilliVolts()); } From 8efff38a4f62e04a809cc73d6830a9eeeb6b93e7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 17:25:27 +0200 Subject: [PATCH 081/183] fix: auto-advert indicator - capital A with space, slow blink, proper refresh - Changed ~ to " A" (space + capital A, no inverted background) - Blink: on 500ms / off 1500ms (once per 2s) - Home screen refresh rate drops to 500ms when auto-advert is active so the blink is actually visible Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index edf435ba..ef5f6b45 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1798,13 +1798,8 @@ public: display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); display.setCursor(0, 0); display.print(filtered_name); - if (_node_prefs->advert_auto_interval_sec > 0 && (millis() / 250) % 2) { - int ax = display.getTextWidth(filtered_name); - display.fillRect(ax, 0, display.getTextWidth("~") + 1, 9); - display.setColor(DisplayDriver::DARK); - display.setCursor(ax, 0); - display.print("~"); - display.setColor(DisplayDriver::LIGHT); + if (_node_prefs->advert_auto_interval_sec > 0 && (millis() % 2000) < 500) { + display.print(" A"); } renderBatteryIndicator(display, _task->getBattMilliVolts()); } @@ -2141,11 +2136,12 @@ public: display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); } } + bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - return show_sec ? 1000 : 60000; + return auto_adv ? 500 : (show_sec ? 1000 : 60000); } - return 5000; + return auto_adv ? 500 : 5000; } bool handleInput(char c) override { From 20151cc6093f2a49321f94f499025466d8f84198 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 17:34:57 +0200 Subject: [PATCH 082/183] fix: align status bar icons to y=0, reduce battery icon to 8px height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BT indicator and mute icon were at y=1 (1px below text baseline). Battery icon height reduced 10→8px to match text height. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ef5f6b45..6fc39d72 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1713,7 +1713,7 @@ class HomeScreen : public UIScreen { display.setCursor(battLeftX, 0); display.print(buf); } else { // icon - const int iconWidth = 24, iconHeight = 10; + const int iconWidth = 24, iconHeight = 8; battLeftX = display.width() - iconWidth - 5; display.drawRect(battLeftX, 0, iconWidth, iconHeight); display.fillRect(battLeftX + iconWidth, iconHeight / 4, 3, iconHeight / 2); @@ -1724,7 +1724,7 @@ class HomeScreen : public UIScreen { #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { display.setColor(DisplayDriver::LIGHT); - display.drawXbm(battLeftX - 9, 1, muted_icon, 8, 8); + display.drawXbm(battLeftX - 9, 0, muted_icon, 8, 8); } #endif @@ -1739,12 +1739,12 @@ class HomeScreen : public UIScreen { display.setColor(DisplayDriver::LIGHT); display.fillRect(btX - 1, 0, 8, 9); display.setColor(DisplayDriver::DARK); - display.setCursor(btX, 1); + display.setCursor(btX, 0); display.print("B"); display.setColor(DisplayDriver::LIGHT); } else { display.setColor(DisplayDriver::LIGHT); - display.setCursor(btX, 1); + display.setCursor(btX, 0); display.print("b"); } } From e205f3712d4c2825bac63a9f15ec5af83ce75469 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 17:41:33 +0200 Subject: [PATCH 083/183] fix: auto-advert indicator - inverted A, 50% duty 1s blink, 1s refresh Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6fc39d72..96ad456c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1798,8 +1798,15 @@ public: display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); display.setCursor(0, 0); display.print(filtered_name); - if (_node_prefs->advert_auto_interval_sec > 0 && (millis() % 2000) < 500) { - display.print(" A"); + if (_node_prefs->advert_auto_interval_sec > 0 && (millis() % 1000) < 500) { + int spaceW = display.getTextWidth(" "); + int aW = display.getTextWidth("A"); + int ax = display.getTextWidth(filtered_name) + spaceW; + display.fillRect(ax, 0, aW + 1, 8); + display.setColor(DisplayDriver::DARK); + display.setCursor(ax, 0); + display.print("A"); + display.setColor(DisplayDriver::LIGHT); } renderBatteryIndicator(display, _task->getBattMilliVolts()); } @@ -2139,9 +2146,9 @@ public: bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - return auto_adv ? 500 : (show_sec ? 1000 : 60000); + return (auto_adv || show_sec) ? 1000 : 60000; } - return auto_adv ? 500 : 5000; + return auto_adv ? 1000 : 5000; } bool handleInput(char c) override { From b36c970a2deafebcec72760caccffbf8929bd967 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 17:45:26 +0200 Subject: [PATCH 084/183] fix: auto-advert indicator - 300ms refresh, background shifted 1px left Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 96ad456c..9f7cc468 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1802,7 +1802,7 @@ public: int spaceW = display.getTextWidth(" "); int aW = display.getTextWidth("A"); int ax = display.getTextWidth(filtered_name) + spaceW; - display.fillRect(ax, 0, aW + 1, 8); + display.fillRect(ax - 1, 0, aW + 2, 8); display.setColor(DisplayDriver::DARK); display.setCursor(ax, 0); display.print("A"); @@ -2146,9 +2146,9 @@ public: bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - return (auto_adv || show_sec) ? 1000 : 60000; + return auto_adv ? 300 : (show_sec ? 1000 : 60000); } - return auto_adv ? 1000 : 5000; + return auto_adv ? 300 : 5000; } bool handleInput(char c) override { From 9426c86e79ce5da2dda2748e1dbdc8e9038db1d5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 18:13:18 +0200 Subject: [PATCH 085/183] feat: polish status bar and home screen layout - Move "A" (auto-advert) indicator left of BT "B" in status bar - Ellipsize node name to fit available width instead of overflowing - Shrink A and B background rectangles by 1px right, 2px bottom - Change A blink to 2s on / 2s off at 1000ms refresh rate - Auto-advert sends GPS position directly via createSelfAdvert/sendZeroHop - Align Settings and Tools home page titles to y=22, same as Messages Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 5 ++- examples/companion_radio/ui-new/UITask.cpp | 49 ++++++++++++---------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6071dda4..6976bd5b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2206,7 +2206,10 @@ void MyMesh::loop() { } if (_prefs.advert_auto_interval_sec > 0 && millisHasNowPassed(_next_auto_advert_ms)) { - advert(); + mesh::Packet* pkt = (sensors.node_lat != 0 || sensors.node_lon != 0) + ? createSelfAdvert(_prefs.node_name, sensors.node_lat, sensors.node_lon) + : createSelfAdvert(_prefs.node_name); + if (pkt) sendZeroHop(pkt); _next_auto_advert_ms = futureMillis(_prefs.advert_auto_interval_sec * 1000UL); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9f7cc468..53795963 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1658,7 +1658,7 @@ class HomeScreen : public UIScreen { return from; } - void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { + int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { #ifndef BATT_MIN_MILLIVOLTS #define BATT_MIN_MILLIVOLTS 3200 #endif @@ -1729,6 +1729,7 @@ class HomeScreen : public UIScreen { #endif // BT connection indicator (left of muted/battery icons) + int leftmostX = battLeftX; if (_task->isSerialEnabled()) { #ifdef PIN_BUZZER int btX = battLeftX - 18; @@ -1737,7 +1738,7 @@ class HomeScreen : public UIScreen { #endif if (_task->hasConnection()) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(btX - 1, 0, 8, 9); + display.fillRect(btX - 1, 0, 7, 7); display.setColor(DisplayDriver::DARK); display.setCursor(btX, 0); display.print("B"); @@ -1747,7 +1748,23 @@ class HomeScreen : public UIScreen { display.setCursor(btX, 0); display.print("b"); } + leftmostX = btX - 1; + + // "A" indicator — left of BT, blinks 50% duty at 1s period + if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { + int aX = leftmostX - 8; + if ((millis() % 4000) < 2000) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(aX - 1, 0, 7, 7); + display.setColor(DisplayDriver::DARK); + display.setCursor(aX, 0); + display.print("A"); + display.setColor(DisplayDriver::LIGHT); + } + leftmostX = aX - 1; + } } + return leftmostX; } CayenneLPP sensors_lpp; @@ -1796,19 +1813,9 @@ public: display.setColor(DisplayDriver::LIGHT); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); - display.setCursor(0, 0); - display.print(filtered_name); - if (_node_prefs->advert_auto_interval_sec > 0 && (millis() % 1000) < 500) { - int spaceW = display.getTextWidth(" "); - int aW = display.getTextWidth("A"); - int ax = display.getTextWidth(filtered_name) + spaceW; - display.fillRect(ax - 1, 0, aW + 2, 8); - display.setColor(DisplayDriver::DARK); - display.setCursor(ax, 0); - display.print("A"); - display.setColor(DisplayDriver::LIGHT); - } - renderBatteryIndicator(display, _task->getBattMilliVolts()); + int rightEdge = renderBatteryIndicator(display, _task->getBattMilliVolts()); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(0, 0, rightEdge - 2, filtered_name); } // ensure current page is visible (e.g. after settings change) @@ -2115,13 +2122,13 @@ public: } else if (_page == HomePage::SETTINGS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 30, "Settings"); - display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, 22, "Settings"); + display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); } else if (_page == HomePage::TOOLS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 30, "Tools"); - display.drawTextCentered(display.width() / 2, 46, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, 22, "Tools"); + display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); @@ -2146,9 +2153,9 @@ public: bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - return auto_adv ? 300 : (show_sec ? 1000 : 60000); + return auto_adv ? 1000 : (show_sec ? 1000 : 60000); } - return auto_adv ? 300 : 5000; + return auto_adv ? 1000 : 5000; } bool handleInput(char c) override { From 1fcad7776a8d301026df19732f04c471bba5af91 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 18:20:49 +0200 Subject: [PATCH 086/183] feat: add unread message count field to clock dashboard Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/DashboardConfigScreen.h | 5 +++-- examples/companion_radio/ui-new/UITask.cpp | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index 23fa962e..f587c6d5 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -13,7 +13,8 @@ static const uint8_t DASH_ALT = 6; static const uint8_t DASH_LUX = 7; static const uint8_t DASH_CO2 = 8; static const uint8_t DASH_NODES = 9; -static const uint8_t DASH_COUNT = 10; +static const uint8_t DASH_MSGS = 10; +static const uint8_t DASH_COUNT = 11; class DashboardConfigScreen : public UIScreen { UITask* _task; @@ -84,5 +85,5 @@ public: const char* DashboardConfigScreen::OPTION_NAMES[DASH_COUNT] = { "None", "Battery", "Temp", "Humidity", "Pressure", - "GPS", "Altitude", "Lux", "CO2", "Contacts" + "GPS", "Altitude", "Lux", "CO2", "Contacts", "Messages" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 53795963..edabeaf5 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1904,6 +1904,10 @@ public: } else if (field == DASH_NODES) { strcpy(label, "Nodes"); snprintf(val, sizeof(val), "%d", the_mesh.getNumContacts()); + } else if (field == DASH_MSGS) { + strcpy(label, "Msgs"); + int unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); + snprintf(val, sizeof(val), "%d", unread); } else { uint8_t lpp_type = 0; switch (field) { From c86e6c803e46f668ec250940866e06be63a7fb9f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 14 May 2026 20:21:13 +0200 Subject: [PATCH 087/183] fix: reverse fullscreen message nav order and remove channel # prefix - Swap left/right navigation in fullscreen view: left=newer, right=older - Update arrow indicators to match new direction - Remove # prefix from channel name in message and compose view titles Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/FullscreenMsgView.h | 8 ++++---- examples/companion_radio/ui-new/UITask.cpp | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 17e8e880..d66d759e 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -65,11 +65,11 @@ struct FullscreenMsgView { display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); display.print("v"); } - if (has_prev) { + if (has_next) { display.setCursor(0, 56); display.print("<"); } - if (has_next) { + if (has_prev) { display.setCursor(display.width() - 6, 56); display.print(">"); } @@ -79,8 +79,8 @@ struct FullscreenMsgView { Result handleInput(char c) { if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } if (c == KEY_DOWN) { scroll++; return NONE; } - if (c == KEY_LEFT) return PREV; - if (c == KEY_RIGHT) return NEXT; + if (c == KEY_LEFT) return NEXT; + if (c == KEY_RIGHT) return PREV; if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; return NONE; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index edabeaf5..372c20fd 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1203,7 +1203,7 @@ public: ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); char title[24]; - snprintf(title, sizeof(title), "#%.21s", ch.name); + snprintf(title, sizeof(title), "%.23s", ch.name); display.drawTextCentered(display.width()/2, 0, title); display.fillRect(0, 9, display.width(), 1); @@ -1289,7 +1289,7 @@ public: if (_sending_to_channel) { ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); - snprintf(title, sizeof(title), "#%.21s", ch.name); + snprintf(title, sizeof(title), "%.23s", ch.name); } else { snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); } From 39731bb7fa757d0c648b29367c6c32e1e20adbd5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 09:21:32 +0200 Subject: [PATCH 088/183] docs: update README for v1.15-plus.1.3 Co-Authored-By: Claude Sonnet 4.6 --- README.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 04ca3eca..bbc77f4a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ This branch extends the official MeshCore companion radio firmware for the **See ### Messages Screen -View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are available by default. +View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are always available; additional placeholders (`{temp}`, `{hum}`, `{pres}`, `{batt}`, `{alt}`, `{lux}`, `{co2}`) appear automatically for sensors that are connected and returning data. + +Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). Hold Enter on a message or channel to open a context menu: change per-channel notification settings (overrides the global sound setting) or mark messages as read. @@ -40,8 +42,22 @@ All settings are saved to flash and restored on next boot. A dedicated clock page on the home screen shows the current time and date, synchronized from GPS or via Bluetooth. Timezone offset is applied from Settings. +Up to three configurable data fields are displayed below the clock. Available fields: battery voltage, temperature, humidity, pressure, GPS coordinates, altitude, luminosity, CO₂, contact count, and total unread message count. + +### Nearby Nodes + +Browse nodes that have recently advertised on the mesh. Filter by category (Favourites, All, Companion, Repeater, Room, Sensor). Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. + +Use **Send my advert** (hold Enter → context menu) to broadcast your position and prompt nearby nodes to respond with theirs. + ### Tools Screen +#### Auto-Advert + +Periodically broadcasts a 0-hop advert with your GPS position so nearby nodes can track your location automatically. Configurable interval: off / 1 min / 2 min / 5 min / 10 min / 30 min / 1 h. + +A blinking **A** indicator appears in the status bar while Auto-Advert is active. + #### Ringtone Editor A step sequencer for composing custom ringtones stored on the device. Supports up to 32 notes with adjustable pitch, octave, duration, and BPM. Playback preview is available directly from the editor menu. From b3c6416874b40e30fb53016e58ff4ba751c3992b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 11:43:38 +0200 Subject: [PATCH 089/183] feat: melody assignment per channel/DM, dual ringtone slots, volume cleanup Add second ringtone slot (ringtone2_*) to NodePrefs and DataStore. Add per-channel and per-DM melody assignment (built-in / melody 1 / 2) stored in NodePrefs bitmasks and DmMelodyEntry table. Settings screen exposes DM/CH sound items; RingtoneEditorScreen supports switching between slots. notify() uses buildMelodyFromPrefs() to select the correct RTTTL string per contact. Remove broken nRF52 volume control from buzzer.cpp: tone() uses NRF_PWM2 with DECODER.MODE=Auto so TASKS_NEXTSTEP has no effect and duty cannot be changed before the first DMA read without patching the Arduino core. applyVolume() is now a no-op on nRF52. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 24 +++ examples/companion_radio/NodePrefs.h | 14 ++ .../ui-new/RingtoneEditorScreen.h | 39 ++-- examples/companion_radio/ui-new/UITask.cpp | 175 ++++++++++++++++-- examples/companion_radio/ui-new/UITask.h | 3 +- src/helpers/ui/buzzer.cpp | 5 + 6 files changed, 234 insertions(+), 26 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 027b77a0..7e430003 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -270,6 +270,22 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); if (file.available()) { file.read((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); + if (file.available()) { + file.read((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx)); + file.read((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len)); + file.read((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes)); + if (file.available()) { + file.read((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm)); + file.read((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch)); + if (file.available()) { + file.read((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); + file.read((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); + if (file.available()) { + file.read((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + } + } + } + } } } } @@ -346,6 +362,14 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.dm_notif, sizeof(_prefs.dm_notif)); file.write((uint8_t *)_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields)); file.write((uint8_t *)&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec)); + file.write((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx)); + file.write((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len)); + file.write((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes)); + file.write((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm)); + file.write((uint8_t *)&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch)); + file.write((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); + file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); + file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); file.close(); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 94e593c3..3f70e34e 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -62,4 +62,18 @@ struct NodePrefs { // persisted to file DmNotifEntry dm_notif[DM_NOTIF_TABLE_MAX]; // 16*5 = 80 bytes uint8_t dashboard_fields[3]; // 0=None,1=Batt,2=Temp,3=Hum,4=Pres,5=GPS,6=Alt,7=Lux,8=CO2,9=Nodes uint32_t advert_auto_interval_sec; // periodic 0-hop advert with GPS: 0=off, else seconds + // Second melody slot (same packing as ringtone_*) + uint8_t ringtone2_bpm_idx; + uint8_t ringtone2_len; + uint8_t ringtone2_notes[32]; + // Global melody for notifications: 0=built-in, 1=melody1, 2=melody2 + uint8_t notif_melody_dm; + uint8_t notif_melody_ch; + // Per-channel melody override (2 bitmasks, 1 bit per channel) + uint64_t ch_notif_melody_set; // bit i = channel i has explicit melody + uint64_t ch_notif_melody_2; // bit i = use melody 2 (else melody 1, when set bit is set) + // Per-DM melody table + struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 + static const int DM_MELODY_TABLE_MAX = 16; + DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index ab2b9d79..84cb09f4 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -21,6 +21,7 @@ class RingtoneEditorScreen : public UIScreen { uint8_t _notes[MAX_NOTES]; uint8_t _len; uint8_t _bpm_idx; + int _slot; // 0=melody1, 1=melody2 int _cursor; int _scroll; bool _menu_open; @@ -29,7 +30,7 @@ class RingtoneEditorScreen : public UIScreen { char _play_buf[220]; // persistent RTTTL buffer — library holds a pointer into it enum MenuOpt { - M_PLAY_STOP, M_DURATION, M_BPM_UP, M_BPM_DOWN, + M_PLAY_STOP, M_SWITCH, M_DURATION, M_BPM_UP, M_BPM_DOWN, M_INSERT, M_DELETE, M_SAVE, M_DISCARD, M_COUNT }; static const char* MENU_LABELS[M_COUNT]; @@ -71,12 +72,16 @@ class RingtoneEditorScreen : public UIScreen { } public: - RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs) {} + RingtoneEditorScreen(UITask* task, NodePrefs* prefs) : _task(task), _prefs(prefs), _slot(0) {} - void enter() { - _bpm_idx = (_prefs && _prefs->ringtone_bpm_idx < 5) ? _prefs->ringtone_bpm_idx : 2; - _len = (_prefs && _prefs->ringtone_len <= (uint8_t)MAX_NOTES) ? _prefs->ringtone_len : 0; - if (_prefs) memcpy(_notes, _prefs->ringtone_notes, sizeof(_notes)); + void enter(int slot = 0) { + _slot = (slot == 1) ? 1 : 0; + bool s2 = (_slot == 1); + _bpm_idx = (_prefs && (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) < 5) + ? (s2 ? _prefs->ringtone2_bpm_idx : _prefs->ringtone_bpm_idx) : 2; + uint8_t rlen = _prefs ? (s2 ? _prefs->ringtone2_len : _prefs->ringtone_len) : 0; + _len = (rlen <= (uint8_t)MAX_NOTES) ? rlen : 0; + if (_prefs) memcpy(_notes, s2 ? _prefs->ringtone2_notes : _prefs->ringtone_notes, sizeof(_notes)); _cursor = 0; _scroll = 0; _menu_open = false; @@ -89,7 +94,7 @@ public: display.setColor(DisplayDriver::LIGHT); char hdr[32]; - snprintf(hdr, sizeof(hdr), "BPM:%u %d/%d", BPM_OPTS[_bpm_idx], _len, MAX_NOTES); + snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES); display.setCursor(0, 0); display.print(hdr); display.fillRect(0, 10, display.width(), 1); @@ -171,6 +176,8 @@ public: display.setCursor(mx + 4, iy); if (item == M_PLAY_STOP) display.print(_task->isMelodyPlaying() ? "Stop" : "Play"); + else if (item == M_SWITCH) + display.print(_slot == 0 ? "Edit Melody 2" : "Edit Melody 1"); else display.print(MENU_LABELS[item]); display.setColor(DisplayDriver::LIGHT); @@ -215,6 +222,10 @@ public: _task->playMelody(_play_buf); } break; + case M_SWITCH: + _task->stopMelody(); + this->enter(1 - _slot); + break; case M_DURATION: if (_cursor < _len) { uint8_t p = notePitch(_notes[_cursor]); @@ -246,9 +257,15 @@ public: break; case M_SAVE: if (_prefs) { - _prefs->ringtone_bpm_idx = _bpm_idx; - _prefs->ringtone_len = _len; - memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + if (_slot == 1) { + _prefs->ringtone2_bpm_idx = _bpm_idx; + _prefs->ringtone2_len = _len; + memcpy(_prefs->ringtone2_notes, _notes, sizeof(_notes)); + } else { + _prefs->ringtone_bpm_idx = _bpm_idx; + _prefs->ringtone_len = _len; + memcpy(_prefs->ringtone_notes, _notes, sizeof(_notes)); + } the_mesh.savePrefs(); } _task->stopMelody(); @@ -313,5 +330,5 @@ const uint8_t RingtoneEditorScreen::DUR_VALS[4] = { 4, 8, 16, 32 }; const char* RingtoneEditorScreen::DUR_LABELS[4] = { "1/4", "1/8", "1/16", "1/32" }; const char RingtoneEditorScreen::PITCH_NAMES[8] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; const char* RingtoneEditorScreen::MENU_LABELS[RingtoneEditorScreen::M_COUNT] = { - "Play/Stop", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" + "Play/Stop", "Switch Melody", "Duration", "BPM+", "BPM-", "Insert", "Delete", "Save & Exit", "Discard" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 372c20fd..525a1bda 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -115,6 +115,8 @@ class SettingsScreen : public UIScreen { SECTION_SOUND, BUZZER, BUZZER_VOLUME, + DM_MELODY, + CH_MELODY, // Home pages section SECTION_HOME_PAGES, HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, @@ -349,6 +351,18 @@ class SettingsScreen : public UIScreen { display.setCursor(VAL_X, y); display.print("N/A"); #endif + } else if (item == DM_MELODY) { + display.print("DM sound"); + display.setCursor(VAL_X, y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_dm : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (item == CH_MELODY) { + display.print("Ch sound"); + display.setCursor(VAL_X, y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_ch : 0; + display.print(L[v < 3 ? v : 0]); } } else if (isHomePage(item)) { display.print(homePageLabel(item)); display.setCursor(VAL_X, y); @@ -513,6 +527,14 @@ public: #endif return right || left; } + if (_selected == DM_MELODY && p && (left || right || enter)) { + p->notif_melody_dm = (p->notif_melody_dm + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (_selected == CH_MELODY && p && (left || right || enter)) { + p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } if (isHomePage(_selected) && p && (left || right || enter)) { p->home_pages_mask ^= homePageBit(_selected); _dirty = true; @@ -636,6 +658,7 @@ class QuickMsgScreen : public UIScreen { PopupMenu _ctx_menu; bool _ctx_dirty; char _ctx_notif_item[22]; + char _ctx_melody_item[20]; struct ChHistEntry { uint8_t ch_idx; char text[140]; }; ChHistEntry _hist[CH_HIST_MAX]; @@ -884,6 +907,61 @@ class QuickMsgScreen : public UIScreen { p->dm_notif[0].state = state; } + uint8_t chNotifMelody(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_melody_set & mask)) return 0; + return (p->ch_notif_melody_2 & mask) ? 2 : 1; + } + + void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (slot == 0) { + p->ch_notif_melody_set &= ~mask; + p->ch_notif_melody_2 &= ~mask; + } else if (slot == 1) { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 &= ~mask; + } else { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 |= mask; + } + } + + uint8_t dmMelodySlot(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) + return p->dm_melody[i].slot; + return 0; + } + + void setDmMelody(const uint8_t* pub_key, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { + if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); + else p->dm_melody[i].slot = slot; + return; + } + } + if (slot == 0) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot == 0) { + memcpy(p->dm_melody[i].prefix, pub_key, 4); + p->dm_melody[i].slot = slot; + return; + } + } + memcpy(p->dm_melody[0].prefix, pub_key, 4); + p->dm_melody[0].slot = slot; + } + public: QuickMsgScreen(UITask* task) : _task(task), _phase(MODE_SELECT), _mode_sel(0), @@ -1355,10 +1433,14 @@ public: if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { if (_ctx_menu.selectedIndex() == 0) { _task->clearDMUnread(ci.id.pub_key); - } else { + } else if (_ctx_menu.selectedIndex() == 1) { uint8_t nstate = dmNotifState(ci.id.pub_key); setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); _ctx_dirty = true; + } else { + uint8_t slot = dmMelodySlot(ci.id.pub_key); + setDmMelody(ci.id.pub_key, (slot + 1) % 3); + _ctx_dirty = true; } } } @@ -1394,9 +1476,13 @@ public: the_mesh.getContactByIdx(_sorted[_contact_sel], ci); snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); - _ctx_menu.begin("Contact options", 2); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[dmMelodySlot(ci.id.pub_key)]); } + _ctx_menu.begin("Contact options", 3); _ctx_menu.addItem("Mark as read"); _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); _ctx_dirty = false; return true; } @@ -1409,10 +1495,14 @@ public: uint8_t ch_idx = _channel_indices[_channel_sel]; if (_ctx_menu.selectedIndex() == 0) { _ch_unread[ch_idx] = 0; - } else { + } else if (_ctx_menu.selectedIndex() == 1) { uint8_t nstate = chNotifState(ch_idx); setChNotifState(ch_idx, (nstate + 1) % 3); _ctx_dirty = true; + } else { + uint8_t slot = chNotifMelody(ch_idx); + setChNotifMelody(ch_idx, (slot + 1) % 3); + _ctx_dirty = true; } } if (res != PopupMenu::NONE && _ctx_dirty) { @@ -1447,9 +1537,13 @@ public: static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", NOTIF_LABELS[chNotifState(ch_idx)]); - _ctx_menu.begin("Channel options", 2); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[chNotifMelody(ch_idx)]); } + _ctx_menu.begin("Channel options", 3); _ctx_menu.addItem("Mark all read"); _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); _ctx_dirty = false; return true; } @@ -2290,8 +2384,8 @@ void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); } -void UITask::gotoRingtoneEditor() { - ((RingtoneEditorScreen*)ringtone_edit)->enter(); +void UITask::gotoRingtoneEditor(int slot) { + ((RingtoneEditorScreen*)ringtone_edit)->enter(slot); setCurrScreen(ringtone_edit); } @@ -2365,6 +2459,27 @@ void UITask::showAlert(const char* text, int duration_millis) { _alert_expiry = millis() + duration_millis; } +static void buildMelodyFromPrefs(const NodePrefs* p, int slot, char* buf, int size) { + static const uint16_t BPM_OPTS[] = { 60, 90, 120, 150, 180 }; + static const uint8_t DUR_VALS[] = { 4, 8, 16, 32 }; + static const char PITCHES[] = { 'p', 'c', 'd', 'e', 'f', 'g', 'a', 'b' }; + const uint8_t* notes = (slot == 2) ? p->ringtone2_notes : p->ringtone_notes; + uint8_t len = (slot == 2) ? p->ringtone2_len : p->ringtone_len; + uint8_t bpm_i = (slot == 2) ? p->ringtone2_bpm_idx : p->ringtone_bpm_idx; + if (len == 0) { buf[0] = '\0'; return; } + uint16_t bpm = BPM_OPTS[bpm_i < 5 ? bpm_i : 2]; + int pos = snprintf(buf, size, "Ring:d=8,o=5,b=%u:", bpm); + for (int i = 0; i < len && pos < size - 8; i++) { + if (i > 0 && pos < size - 1) buf[pos++] = ','; + uint8_t pitch = notes[i] & 0x07; + uint8_t octave = ((notes[i] >> 3) & 0x03) + 4; + uint8_t dur_val = DUR_VALS[(notes[i] >> 5) & 0x03]; + if (pitch == 0) pos += snprintf(buf + pos, size - pos, "%dp", dur_val); + else pos += snprintf(buf + pos, size - pos, "%d%c%d", dur_val, PITCHES[pitch], octave); + } + if (pos < size) buf[pos] = '\0'; +} + void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) switch(t){ @@ -2387,8 +2502,25 @@ switch(t){ } _last_notif_dm_valid = false; if (play) { - if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); - else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + int slot = _node_prefs ? (int)_node_prefs->notif_melody_dm : 0; + if (_node_prefs) { + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) + if (_node_prefs->dm_melody[i].slot && + memcmp(_node_prefs->dm_melody[i].prefix, _last_notif_dm_prefix, 4) == 0) + { slot = _node_prefs->dm_melody[i].slot; break; } + } + bool custom_played = false; + if (slot > 0 && _node_prefs) { + buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf)); + if (_notif_mel_buf[0]) { + if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf); + custom_played = true; + } + } + if (!custom_played) { + if (force) buzzer.playForced("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + else buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); + } } break; } @@ -2398,19 +2530,34 @@ switch(t){ if (_last_notif_ch_idx >= 0 && _node_prefs) { uint64_t mask = 1ULL << _last_notif_ch_idx; if (_node_prefs->ch_notif_override & mask) { - if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } // state 2: force-on - // state 1: muted — play stays false + if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } } else { - play = !buzzer.isQuiet(); // state 0: follow global + play = !buzzer.isQuiet(); } } else { play = !buzzer.isQuiet(); } - _last_notif_ch_idx = -1; if (play) { - if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#"); - else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + int slot = _node_prefs ? (int)_node_prefs->notif_melody_ch : 0; + if (_last_notif_ch_idx >= 0 && _node_prefs) { + uint64_t mask = 1ULL << _last_notif_ch_idx; + if (_node_prefs->ch_notif_melody_set & mask) + slot = (_node_prefs->ch_notif_melody_2 & mask) ? 2 : 1; + } + bool custom_played = false; + if (slot > 0 && _node_prefs) { + buildMelodyFromPrefs(_node_prefs, slot, _notif_mel_buf, sizeof(_notif_mel_buf)); + if (_notif_mel_buf[0]) { + if (force) buzzer.playForced(_notif_mel_buf); else buzzer.play(_notif_mel_buf); + custom_played = true; + } + } + if (!custom_played) { + if (force) buzzer.playForced("kerplop:d=16,o=6,b=120:32g#,32c#"); + else buzzer.play("kerplop:d=16,o=6,b=120:32g#,32c#"); + } } + _last_notif_ch_idx = -1; break; } case UIEventType::ack: diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index c96711dc..af8f87ad 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -34,6 +34,7 @@ class UITask : public AbstractUITask { unsigned long _next_refresh, _auto_off; NodePrefs* _node_prefs; char _alert[80]; + char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; int _msgcount; int _room_unread; @@ -99,7 +100,7 @@ public: void gotoSettingsScreen(); void gotoQuickMsgScreen(); void gotoToolsScreen(); - void gotoRingtoneEditor(); + void gotoRingtoneEditor(int slot = 0); void gotoBotScreen(); void gotoNearbyScreen(); void gotoDashboardConfig(); diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index 4411a407..bca45ec9 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -32,11 +32,16 @@ bool genericBuzzer::isPlaying() { } void genericBuzzer::applyVolume() { +#if !defined(NRF52_PLATFORM) // After tone() sets 50% duty, analogWrite overrides duty cycle on the same PWM channel. // Level 4 = 50% (leave tone as-is), lower levels reduce duty = quieter output. static const uint8_t duty[] = { 6, 20, 50, 90, 128 }; uint8_t d = duty[_volume_level < 5 ? _volume_level : 4]; if (d < 128) analogWrite(PIN_BUZZER, d); +#endif + // On nRF52, tone() uses NRF_PWM2 with DECODER.MODE=Auto. The DMA reads duty at SEQSTART + // before software can write, and TASKS_NEXTSTEP only works in NextStep mode — so duty + // cannot be changed mid-note without patching the Arduino core. Volume has no effect. } void genericBuzzer::setVolume(uint8_t level) { From d02c1cf098de244ab015601c0a0291ae9a4c1317 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 11:46:48 +0200 Subject: [PATCH 090/183] feat: play preview tone when buzzer volume changes in settings Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 525a1bda..6b1d4ce9 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2945,6 +2945,7 @@ void UITask::setBuzzerVolumeLevel(uint8_t level) { if (level > 4) level = 4; _node_prefs->buzzer_volume = level; buzzer.setVolume(level); + buzzer.playForced("Vol:d=8,o=5,b=120:c"); _next_refresh = 0; #endif } From bf05a13549a032b84cc345b0ea8a9d5d650f63c1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 13:09:38 +0200 Subject: [PATCH 091/183] fix: implement nRF52 RTTTL player using direct NRF_PWM2 control for working volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tone() dynamically computes seq_refresh so the DMA repeats 50% duty for ~13-30ms per note before re-reading its buffer — volume cannot take effect until that delay passes, producing a staircase artifact. Replace tone()/NonBlockingRtttl with a custom RTTTL parser that drives NRF_PWM2 directly using REFRESH=0. DMA now re-reads _duty_buf every PWM period so: - Duty is set before SEQSTART fires → no initial full-volume burst - applyVolume() writes to _duty_buf and takes effect within one period (<2.3ms at A4) - setVolume() during playback adjusts loudness immediately and smoothly Non-nRF52 platforms continue to use NonBlockingRtttl + analogWrite. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/buzzer.cpp | 252 ++++++++++++++++++++++++++++++-------- src/helpers/ui/buzzer.h | 67 ++++++---- 2 files changed, 244 insertions(+), 75 deletions(-) diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index bca45ec9..d35a3428 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -2,57 +2,227 @@ #include "buzzer.h" void genericBuzzer::begin() { -// Serial.print("DBG: Setting up buzzer on pin "); -// Serial.println(PIN_BUZZER); #ifdef PIN_BUZZER_EN pinMode(PIN_BUZZER_EN, OUTPUT); digitalWrite(PIN_BUZZER_EN, HIGH); #endif - pinMode(PIN_BUZZER, OUTPUT); - digitalWrite(PIN_BUZZER, LOW); // need to pull low by default to avoid extreme power draw + digitalWrite(PIN_BUZZER, LOW); startup(); } -void genericBuzzer::play(const char *melody) { - if (isPlaying()) rtttl::stop(); - if (_is_quiet) return; - rtttl::begin(PIN_BUZZER, melody); -// Serial.print("DBG: Playing melody - isQuiet: "); -// Serial.println(isQuiet()); +void genericBuzzer::quiet(bool buzzer_state) { + _is_quiet = buzzer_state; +#ifdef PIN_BUZZER_EN + digitalWrite(PIN_BUZZER_EN, _is_quiet ? LOW : HIGH); +#endif } -void genericBuzzer::playForced(const char *melody) { - if (isPlaying()) rtttl::stop(); - rtttl::begin(PIN_BUZZER, melody); +bool genericBuzzer::isQuiet() { return _is_quiet; } + +void genericBuzzer::startup() { play(startup_song); } +void genericBuzzer::shutdown() { play(shutdown_song); } + +// --------------------------------------------------------------------------- +// nRF52 path — direct NRF_PWM2 control, bypasses tone() +// --------------------------------------------------------------------------- +#if defined(NRF52_PLATFORM) + +// Chromatic frequencies for octave 4 (Hz): C C# D D# E F F# G G# A A# B +static const uint16_t CHROM4[12] = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494 }; + +// Map 'a'-'g' → chromatic index within octave +static const uint8_t NOTE_IDX[7] = { 9, 11, 0, 2, 4, 5, 7 }; // a b c d e f g + +uint16_t genericBuzzer::_noteFreq(char letter, bool sharp, uint8_t octave) { + if (letter == 'p') return 0; + if (letter < 'a' || letter > 'g') return 0; + uint8_t idx = NOTE_IDX[letter - 'a']; + if (sharp) { if (++idx >= 12) { idx = 0; octave++; } } + if (octave < 4) octave = 4; + if (octave > 7) octave = 7; + uint32_t f = (uint32_t)CHROM4[idx] << (octave - 4); + return (uint16_t)(f > 25000 ? 25000 : f); } -bool genericBuzzer::isPlaying() { - return rtttl::isPlaying(); +void genericBuzzer::_parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct, + uint16_t& bpm, const char*& notes) { + def_dur = 4; def_oct = 5; bpm = 120; + const char* p = melody; + while (*p && *p != ':') p++; + if (*p == ':') p++; + while (*p && *p != ':') { + while (*p == ' ' || *p == ',') p++; + if (p[0]=='d' && p[1]=='=') { p+=2; def_dur=(uint8_t)atoi(p); while(*p&&*p!=','&&*p!=':')p++; } + else if (p[0]=='o' && p[1]=='=') { p+=2; def_oct=(uint8_t)atoi(p); while(*p&&*p!=','&&*p!=':')p++; } + else if (p[0]=='b' && p[1]=='=') { p+=2; bpm=(uint16_t)atoi(p); while(*p&&*p!=','&&*p!=':')p++; } + else { while(*p&&*p!=','&&*p!=':')p++; } + } + if (*p == ':') p++; + notes = p; +} + +bool genericBuzzer::_parseNext(const char*& p, uint8_t def_dur, uint8_t def_oct, uint16_t bpm, + uint16_t& freq, uint32_t& dur_ms) { + while (*p == ' ' || *p == ',') p++; + if (*p == '\0') return false; + + uint8_t dur = def_dur; + if (*p >= '0' && *p <= '9') { dur=(uint8_t)atoi(p); while(*p>='0'&&*p<='9')p++; } + if (dur == 0) dur = 4; + + if (*p == '\0') return false; + char note = *p++; + bool sharp = (*p == '#') ? (p++, true) : false; + uint8_t oct = def_oct; + if (*p >= '4' && *p <= '8') oct = (uint8_t)(*p++ - '0'); + bool dot = (*p == '.') ? (p++, true) : false; + + dur_ms = (60000UL * 4UL) / ((uint32_t)bpm * dur); + if (dot) dur_ms = dur_ms * 3 / 2; + + freq = _noteFreq(note, sharp, oct); + return true; +} + +uint8_t genericBuzzer::_dutyPct() const { + static const uint8_t PCT[5] = { 2, 8, 20, 35, 50 }; + return PCT[_volume_level < 5 ? _volume_level : 4]; +} + +void genericBuzzer::_nrfStartPwm(uint16_t freq) { + if (freq < 20 || freq > 25000) { _nrfStopPwm(); return; } + + uint32_t nrf_pin = g_ADigitalPinMap[PIN_BUZZER]; + uint16_t top = 125000 / freq; + uint16_t cmp = (uint16_t)(((uint32_t)top * _dutyPct()) / 100); + + // Write duty BEFORE SEQSTART so DMA reads our value on the very first period + _duty_buf = 0x8000U | cmp; + __DMB(); + + NRF_PWM2->TASKS_STOP = 1; + // Wait for STOPPED (short busy-wait, typically < 1 PWM period) + uint32_t t = millis(); + while (!(NRF_PWM2->EVENTS_STOPPED) && (millis() - t) < 2) {} + NRF_PWM2->EVENTS_STOPPED = 0; + + NRF_PWM2->PSEL.OUT[0] = nrf_pin; + NRF_PWM2->PSEL.OUT[1] = 0xFFFFFFFFUL; + NRF_PWM2->PSEL.OUT[2] = 0xFFFFFFFFUL; + NRF_PWM2->PSEL.OUT[3] = 0xFFFFFFFFUL; + NRF_PWM2->ENABLE = PWM_ENABLE_ENABLE_Enabled << PWM_ENABLE_ENABLE_Pos; + NRF_PWM2->MODE = PWM_MODE_UPDOWN_Up << PWM_MODE_UPDOWN_Pos; + // DIV_128 on 16 MHz = 125 kHz — same clock as tone(), so same frequency math + NRF_PWM2->PRESCALER = PWM_PRESCALER_PRESCALER_DIV_128 << PWM_PRESCALER_PRESCALER_Pos; + NRF_PWM2->COUNTERTOP = top; + NRF_PWM2->DECODER = (PWM_DECODER_LOAD_Common << PWM_DECODER_LOAD_Pos) | + (PWM_DECODER_MODE_RefreshCount << PWM_DECODER_MODE_Pos); + NRF_PWM2->SHORTS = PWM_SHORTS_LOOPSDONE_SEQSTART0_Msk; + NRF_PWM2->LOOP = 0xFFFFUL << PWM_LOOP_CNT_Pos; + + // Both SEQ0 and SEQ1 point to the same buffer; REFRESH=0 means DMA re-reads every period + NRF_PWM2->SEQ[0].PTR = (uint32_t)&_duty_buf; + NRF_PWM2->SEQ[0].CNT = 1; + NRF_PWM2->SEQ[0].REFRESH = 0; + NRF_PWM2->SEQ[0].ENDDELAY = 0; + NRF_PWM2->SEQ[1].PTR = (uint32_t)&_duty_buf; + NRF_PWM2->SEQ[1].CNT = 1; + NRF_PWM2->SEQ[1].REFRESH = 0; + NRF_PWM2->SEQ[1].ENDDELAY = 0; + + NRF_PWM2->TASKS_SEQSTART[0] = 1; + _pwm_on = true; +} + +void genericBuzzer::_nrfStopPwm() { + NRF_PWM2->TASKS_STOP = 1; + NRF_PWM2->PSEL.OUT[0] = 0xFFFFFFFFUL; + NRF_PWM2->ENABLE = 0; + digitalWrite(PIN_BUZZER, LOW); + _pwm_on = false; +} + +void genericBuzzer::_nrfBegin(const char* melody) { + _nrfStopPwm(); + if (!melody || !*melody) { _rtttl_done = true; return; } + const char* notes; + _parseHeader(melody, _def_dur, _def_oct, _def_bpm, notes); + _rtttl_pos = notes; + _rtttl_done = false; + _nrfAdvance(); +} + +void genericBuzzer::_nrfAdvance() { + uint16_t freq; uint32_t dur_ms; + if (_parseNext(_rtttl_pos, _def_dur, _def_oct, _def_bpm, freq, dur_ms)) { + _note_end_ms = millis() + dur_ms; + if (freq > 0) _nrfStartPwm(freq); else _nrfStopPwm(); + } else { + _nrfStopPwm(); + _rtttl_done = true; + } } void genericBuzzer::applyVolume() { -#if !defined(NRF52_PLATFORM) - // After tone() sets 50% duty, analogWrite overrides duty cycle on the same PWM channel. - // Level 4 = 50% (leave tone as-is), lower levels reduce duty = quieter output. - static const uint8_t duty[] = { 6, 20, 50, 90, 128 }; - uint8_t d = duty[_volume_level < 5 ? _volume_level : 4]; - if (d < 128) analogWrite(PIN_BUZZER, d); -#endif - // On nRF52, tone() uses NRF_PWM2 with DECODER.MODE=Auto. The DMA reads duty at SEQSTART - // before software can write, and TASKS_NEXTSTEP only works in NextStep mode — so duty - // cannot be changed mid-note without patching the Arduino core. Volume has no effect. + if (!_pwm_on) return; + uint16_t top = (uint16_t)NRF_PWM2->COUNTERTOP; + uint16_t cmp = (uint16_t)(((uint32_t)top * _dutyPct()) / 100); + _duty_buf = 0x8000U | cmp; // DMA picks this up within one period (< 2.3 ms at A4) +} + +void genericBuzzer::play(const char* melody) { + if (_is_quiet) return; + _nrfBegin(melody); +} + +void genericBuzzer::playForced(const char* melody) { + _nrfBegin(melody); +} + +bool genericBuzzer::isPlaying() { return !_rtttl_done; } + +void genericBuzzer::stop() { + _nrfStopPwm(); + _rtttl_done = true; +} + +void genericBuzzer::loop() { + if (!_rtttl_done && (millis() >= _note_end_ms)) _nrfAdvance(); } void genericBuzzer::setVolume(uint8_t level) { _volume_level = level < 5 ? level : 4; - if (isPlaying()) applyVolume(); + applyVolume(); } -void genericBuzzer::stop() { - rtttl::stop(); +// --------------------------------------------------------------------------- +// Non-nRF52 path — NonBlockingRtttl + analogWrite for volume +// --------------------------------------------------------------------------- +#else + +void genericBuzzer::applyVolume() { + // After tone() sets 50% duty, analogWrite overrides duty on the same PWM channel. + static const uint8_t duty[5] = { 6, 20, 50, 90, 128 }; + uint8_t d = duty[_volume_level < 5 ? _volume_level : 4]; + if (d < 128) analogWrite(PIN_BUZZER, d); } +void genericBuzzer::play(const char* melody) { + if (isPlaying()) rtttl::stop(); + if (_is_quiet) return; + rtttl::begin(PIN_BUZZER, melody); +} + +void genericBuzzer::playForced(const char* melody) { + if (isPlaying()) rtttl::stop(); + rtttl::begin(PIN_BUZZER, melody); +} + +bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); } + +void genericBuzzer::stop() { rtttl::stop(); } + void genericBuzzer::loop() { if (!rtttl::done()) { rtttl::play(); @@ -60,27 +230,11 @@ void genericBuzzer::loop() { } } -void genericBuzzer::startup() { - play(startup_song); +void genericBuzzer::setVolume(uint8_t level) { + _volume_level = level < 5 ? level : 4; + if (isPlaying()) applyVolume(); } -void genericBuzzer::shutdown() { - play(shutdown_song); -} +#endif // NRF52_PLATFORM -void genericBuzzer::quiet(bool buzzer_state) { - _is_quiet = buzzer_state; -#ifdef PIN_BUZZER_EN - if (_is_quiet) { - digitalWrite(PIN_BUZZER_EN, LOW); - } else { - digitalWrite(PIN_BUZZER_EN, HIGH); - } -#endif -} - -bool genericBuzzer::isQuiet() { - return _is_quiet; -} - -#endif // ifdef PIN_BUZZER \ No newline at end of file +#endif // PIN_BUZZER diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 46185871..4ed795b1 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -1,43 +1,58 @@ #pragma once #include -#include -/* class abstracts underlying RTTTL library - - Just a simple imlementation to start. At the moment use same - melody for message and discovery - Suggest enum type for different sounds - - on message - - on discovery - - TODO - - make message ring tone configurable - -*/ +#if !defined(NRF52_PLATFORM) + #include +#endif class genericBuzzer { public: - void begin(); // set up buzzer port + void begin(); void play(const char *melody); - void playForced(const char *melody); // play regardless of quiet state - void loop(); // loop driven-nonblocking - void startup(); // play startup sound - void shutdown(); // play shutdown sound - bool isPlaying(); // returns true if a sound is still playing else false - void quiet(bool buzzer_state); // enables or disables the buzzer - bool isQuiet(); // get buzzer state on/off - void setVolume(uint8_t level); // 0=min..4=max + void playForced(const char *melody); + void loop(); + void startup(); + void shutdown(); + bool isPlaying(); + void quiet(bool buzzer_state); + bool isQuiet(); + void setVolume(uint8_t level); uint8_t getVolume() const { return _volume_level; } - void stop(); // stop any playing melody + void stop(); private: uint8_t _volume_level = 4; void applyVolume(); - // gemini's picks: - const char *startup_song = "Startup:d=4,o=5,b=160:16c6,16e6,8g6"; + const char *startup_song = "Startup:d=4,o=5,b=160:16c6,16e6,8g6"; const char *shutdown_song = "Shutdown:d=4,o=5,b=100:8g5,16e5,16c5"; - bool _is_quiet = true; + +#if defined(NRF52_PLATFORM) + // Own RTTTL player — bypasses tone() to allow volume control from note start. + // tone() pre-computes seq_refresh so the DMA repeats 50% duty for ~30ms before + // re-reading its buffer; we cannot beat that timing. By owning NRF_PWM2 directly + // and setting REFRESH=0, DMA re-reads _duty_buf every period so duty takes effect + // immediately at SEQSTART. + volatile uint16_t _duty_buf = 0; // DMA source — must stay in RAM + const char* _rtttl_pos = nullptr; + bool _rtttl_done = true; + bool _pwm_on = false; + unsigned long _note_end_ms = 0; + uint8_t _def_dur = 4; + uint8_t _def_oct = 5; + uint16_t _def_bpm = 120; + + void _nrfBegin(const char* melody); + void _nrfAdvance(); + void _nrfStartPwm(uint16_t freq); + void _nrfStopPwm(); + uint8_t _dutyPct() const; + static uint16_t _noteFreq(char letter, bool sharp, uint8_t octave); + static bool _parseNext(const char*& pos, uint8_t def_dur, uint8_t def_oct, + uint16_t bpm, uint16_t& freq_hz, uint32_t& dur_ms); + static void _parseHeader(const char* melody, uint8_t& def_dur, uint8_t& def_oct, + uint16_t& bpm, const char*& notes_start); +#endif }; From a819b05ed0d7106509959008f96a62a3ecb3ff91 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 15:24:54 +0200 Subject: [PATCH 092/183] fix: correct UF2 artifact name for wio-tracker tags with hyphenated versions ##*- strips to the last hyphen, so wio-tracker-v1.15-plus.1.3 yielded "plus.1.3" instead of "v1.15-plus.1.3". Switch to #*-v (shortest prefix ending with -v) which preserves hyphens inside the version string. Also remove the -Plus suffix appended in build_wio_tracker_l1_firmwares() since the version scheme already encodes "plus" in the tag/version string, making the suffix redundant and producing doubled output like "plus.1.3-Plus". Co-Authored-By: Claude Sonnet 4.6 --- .github/actions/setup-build-environment/action.yml | 4 +++- build.sh | 3 --- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/actions/setup-build-environment/action.yml b/.github/actions/setup-build-environment/action.yml index 2ba7617e..7703c1ef 100644 --- a/.github/actions/setup-build-environment/action.yml +++ b/.github/actions/setup-build-environment/action.yml @@ -22,8 +22,10 @@ runs: pip install --upgrade platformio # a git tag of "room-server-v1.2.3" should set "v1.2.3" as GIT_TAG_VERSION + # a git tag of "wio-tracker-v1.15-plus.1.3" should set "v1.15-plus.1.3" as GIT_TAG_VERSION + # Strip the shortest prefix ending with "-v" so hyphens in the version are preserved. - name: Extract Version from Git Tag shell: bash run: | GIT_TAG_NAME="${GITHUB_REF#refs/tags/}" - echo "GIT_TAG_VERSION=${GIT_TAG_NAME##*-}" >> $GITHUB_ENV + echo "GIT_TAG_VERSION=v${GIT_TAG_NAME#*-v}" >> $GITHUB_ENV diff --git a/build.sh b/build.sh index 8fc513d2..6e2e8cfb 100755 --- a/build.sh +++ b/build.sh @@ -231,11 +231,8 @@ build_companion_firmwares() { } build_wio_tracker_l1_firmwares() { - local _saved_version="$FIRMWARE_VERSION" - export FIRMWARE_VERSION="${FIRMWARE_VERSION}-Plus" build_firmware "WioTrackerL1_companion_radio_usb_settings" build_firmware "WioTrackerL1_companion_radio_ble_settings" - export FIRMWARE_VERSION="$_saved_version" } build_room_server_firmwares() { From 484f0891b1b8925ad4e311392f33c92c4ee43ac7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 15:34:10 +0200 Subject: [PATCH 093/183] fix: three bugs found in code review - DataStore: validate ringtone2_len <= 32 after loading from flash (same guard as ringtone_len on line 252; we introduced this field but forgot the validation) - UITask: fix shutdown buzzer timeout to conventional unsigned form (millis() - start) < timeout to avoid unsigned underflow if called within first 2.5s of boot - UITask: guard _last_notif_ch_idx < 64 before 1ULL shift in notify() (defensive; MAX_GROUP_CHANNELS=40 so currently unreachable, but prevents UB if the value is ever unexpectedly out of range) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 1 + examples/companion_radio/ui-new/UITask.cpp | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 7e430003..e3e8657c 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -273,6 +273,7 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (file.available()) { file.read((uint8_t *)&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx)); file.read((uint8_t *)&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len)); + if (_prefs.ringtone2_len > 32) _prefs.ringtone2_len = 0; file.read((uint8_t *)_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes)); if (file.available()) { file.read((uint8_t *)&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm)); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6b1d4ce9..ff1eaf0e 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2527,7 +2527,7 @@ switch(t){ case UIEventType::channelMessage: { bool play = false; bool force = false; - if (_last_notif_ch_idx >= 0 && _node_prefs) { + if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) { uint64_t mask = 1ULL << _last_notif_ch_idx; if (_node_prefs->ch_notif_override & mask) { if (!(_node_prefs->ch_notif_muted & mask)) { play = true; force = true; } @@ -2539,7 +2539,7 @@ switch(t){ } if (play) { int slot = _node_prefs ? (int)_node_prefs->notif_melody_ch : 0; - if (_last_notif_ch_idx >= 0 && _node_prefs) { + if (_last_notif_ch_idx >= 0 && _last_notif_ch_idx < 64 && _node_prefs) { uint64_t mask = 1ULL << _last_notif_ch_idx; if (_node_prefs->ch_notif_melody_set & mask) slot = (_node_prefs->ch_notif_melody_2 & mask) ? 2 : 1; @@ -2664,7 +2664,7 @@ void UITask::shutdown(bool restart){ */ buzzer.shutdown(); uint32_t buzzer_timer = millis(); // fail-safe shutdown - while (buzzer.isPlaying() && (millis() - 2500) < buzzer_timer) + while (buzzer.isPlaying() && (millis() - buzzer_timer) < 2500) buzzer.loop(); #endif // PIN_BUZZER From ec601ef11825a9f417789aa9b37ab0835f471d8c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 15:48:05 +0200 Subject: [PATCH 094/183] fix: renderBar always shows slot outlines regardless of value Previously empty slots used drawRect and filled slots used fillRect, so at max value (e.g. volume=4) no drawRect was called and no slot borders were visible when selected (inverted colors). Brightness at default (2/5) always had visible outlines, making the two bars look inconsistent. Now drawRect is called for every slot, with fillRect(+1,+1,w-2,h-2) on top for filled ones. All 5 slots are always visually distinct. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ff1eaf0e..b14b2e67 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -183,8 +183,9 @@ class SettingsScreen : public UIScreen { const int box_w = 7, box_h = 7, gap = 2; for (int i = 0; i < max_val; i++) { int bx = x + i * (box_w + gap); - if (i < value) display.fillRect(bx, y, box_w, box_h); - else display.drawRect(bx, y, box_w, box_h); + display.drawRect(bx, y, box_w, box_h); + if (i < value) + display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2); } } From 1d04930c1ecf771f92db87efca38d0ca971929cd Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 15 May 2026 16:33:19 +0200 Subject: [PATCH 095/183] quick fix --- examples/companion_radio/MyMesh.cpp | 3 ++- examples/companion_radio/ui-new/UITask.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6976bd5b..79f52deb 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -875,8 +875,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.gps_interval = 0; // No automatic GPS updates by default _prefs.display_brightness = 2; // medium brightness by default _prefs.buzzer_volume = 4; // max volume by default - _prefs.ringtone_bpm_idx = 2; // 120 bpm default + _prefs.ringtone_bpm_idx = 2; // 120 bpm default _prefs.ringtone_len = 0; // no custom ringtone by default + _prefs.ringtone2_bpm_idx = 2; // 120 bpm default _prefs.home_pages_mask = 0x01FF; // all pages visible _prefs.bot_enabled = 0; _prefs.bot_channel_enabled = 0; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b14b2e67..da12184c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2946,7 +2946,7 @@ void UITask::setBuzzerVolumeLevel(uint8_t level) { if (level > 4) level = 4; _node_prefs->buzzer_volume = level; buzzer.setVolume(level); - buzzer.playForced("Vol:d=8,o=5,b=120:c"); + if (level > 0) buzzer.playForced("Vol:d=16,o=5,b=120:c"); _next_refresh = 0; #endif } From d4c99dec65331b95ee3fe628112153cf1af75701 Mon Sep 17 00:00:00 2001 From: Rastislav Vysoky Date: Fri, 15 May 2026 16:42:29 +0200 Subject: [PATCH 096/183] Change MeshCore intro video link to The Comms Channel's MC intro playlist --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8b9e5e0..d5f2a16f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ MeshCore provides the ability to create wireless mesh networks, similar to Mesht ## 🚀 How to Get Started -- Watch the [MeshCore Intro Video](https://www.youtube.com/watch?v=t1qne8uJBAc) by Andy Kirby. +- Watch the [MeshCore QuickStart Playlist](https://www.youtube.com/watch?v=iaFltojJrAc&list=PLshzThxhw4O4WU_iZo3NmNZOv6KMrUuF9) by The Comms Channel - Watch the [MeshCore Technical Presentation](https://www.youtube.com/watch?v=OwmkVkZQTf4) by Liam Cottle. - Read through our [Frequently Asked Questions](./docs/faq.md) and [Documentation](https://docs.meshcore.io). - Flash the MeshCore firmware on a supported device. From 9c40a253e255bb1459af28ce3e559261e75c4088 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 00:09:18 +0200 Subject: [PATCH 097/183] update README and fix quick message / melody naming collision - Expand README: ringtone editor now documents two melody slots, full note spec, and melody assignment to DM/channel notifications; settings section lists DM Melody and Channel Melody options; context menu description updated with melody override detail - Rename quick reply template labels from M1-M10 to Q1-Q10 to avoid ambiguity with melody slot identifiers M1/M2 - Increase DM message history buffer from 32 to 64 entries Co-Authored-By: Claude Sonnet 4.6 --- README.md | 12 +++++++----- examples/companion_radio/ui-new/UITask.cpp | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bbc77f4a..2d7709de 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ View and send messages using the on-screen keyboard or predefined quick replies. Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). -Hold Enter on a message or channel to open a context menu: change per-channel notification settings (overrides the global sound setting) or mark messages as read. +Hold Enter on a message or channel to open a context menu: change per-channel notification settings (mute, follow global, or force-on) and per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. ### Settings Screen @@ -23,7 +23,9 @@ All settings are saved to flash and restored on next boot. - Clock seconds (show/hide — hiding reduces display refresh from 1 s to 60 s) - **Sound** - Buzzer: On / Off / **Auto** — Auto mode silences the device while connected via Bluetooth, and re-enables sound when the connection drops - - Volume + - Volume (1–5; preview tone plays on each change) + - DM Melody — notification sound for incoming private messages: built-in, Melody 1, or Melody 2 + - Channel Melody — notification sound for incoming channel messages: built-in, Melody 1, or Melody 2 - **Home Pages** — toggle visibility of individual home screen pages - **Radio** - TX power @@ -36,7 +38,7 @@ All settings are saved to flash and restored on next boot. - Show all DMs or favourites only - Show all room servers or favourites only - **Messages** - - Edit up to 10 quick reply templates + - Edit up to 10 quick reply templates (Q1–Q10) ### Clock Screen @@ -60,9 +62,9 @@ A blinking **A** indicator appears in the status bar while Auto-Advert is active #### Ringtone Editor -A step sequencer for composing custom ringtones stored on the device. Supports up to 32 notes with adjustable pitch, octave, duration, and BPM. Playback preview is available directly from the editor menu. +A step sequencer for composing custom notification melodies stored on the device. Two independent melody slots are available — **Melody 1** and **Melody 2** — switchable from within the editor. Each melody supports up to 32 notes with adjustable pitch (C–B + pause), octave (4–7), duration (whole / half / quarter / eighth), and BPM (60 / 90 / 120 / 150 / 180). Playback preview is available directly from the editor. -> Custom ringtones as per-channel or per-contact notification sounds are planned for a future update. +Melodies can be assigned as notification sounds per message type (DM / channel) in Settings, and individually overridden per channel or per contact from the message screen context menu. #### Auto-Reply Bot diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index da12184c..18080f12 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -416,7 +416,7 @@ class SettingsScreen : public UIScreen { } else if (isMsgSlot(item)) { int slot = msgSlotIndex(item); char label[5]; - snprintf(label, sizeof(label), "M%d:", slot + 1); + snprintf(label, sizeof(label), "Q%d:", slot + 1); display.print(label); const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); @@ -667,7 +667,7 @@ class QuickMsgScreen : public UIScreen { // DM_HIST struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; - static const int DM_HIST_MAX = 32; + static const int DM_HIST_MAX = 64; DmHistEntry _dm_hist[DM_HIST_MAX]; int _dm_hist_head, _dm_hist_count; int _dm_hist_sel, _dm_hist_scroll; From fbe636a05a7c392fc2652907bb18623c401e7a34 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 00:33:13 +0200 Subject: [PATCH 098/183] feat: show plus version number on splash screen Splash screen bar changes from "Plus for Wio" to "Plus 1.4 for Wio" by extracting the suffix after "plus." from FIRMWARE_VERSION. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 18080f12..7a34fdb2 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -36,11 +36,11 @@ class SplashScreen : public UIScreen { UITask* _task; unsigned long dismiss_after; char _version_info[12]; + char _plus_ver[12]; public: SplashScreen(UITask* task) : _task(task) { - // strip off dash and commit hash by changing dash to null terminator - // e.g: v1.2.3-abcdef -> v1.2.3 + // strip off dash and commit hash: v1.2.3-abcdef -> v1.2.3 const char *ver = FIRMWARE_VERSION; const char *dash = strchr(ver, '-'); @@ -49,6 +49,18 @@ public: memcpy(_version_info, ver, len); _version_info[len] = 0; + // extract plus version: v1.15-plus.1.4-SHA -> "1.4" + _plus_ver[0] = '\0'; + const char *plus = strstr(ver, "plus."); + if (plus) { + plus += 5; // skip "plus." + const char *end = strchr(plus, '-'); + int plen = end ? end - plus : strlen(plus); + if (plen >= (int)sizeof(_plus_ver)) plen = sizeof(_plus_ver) - 1; + memcpy(_plus_ver, plus, plen); + _plus_ver[plen] = '\0'; + } + dismiss_after = millis() + BOOT_SCREEN_MILLIS; } @@ -68,7 +80,12 @@ public: #ifdef FIRMWARE_PLUS_BUILD display.fillRect(0, 53, display.width(), 10); display.setColor(DisplayDriver::DARK); - display.drawTextCentered(display.width()/2, 54, "Plus for Wio"); + char plus_label[24]; + if (_plus_ver[0]) + snprintf(plus_label, sizeof(plus_label), "Plus %s for Wio", _plus_ver); + else + snprintf(plus_label, sizeof(plus_label), "Plus for Wio"); + display.drawTextCentered(display.width()/2, 54, plus_label); display.setColor(DisplayDriver::LIGHT); #endif From 8d3b955da1a8975256a7aecc5df45a94dc7cee87 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 14:46:53 +0200 Subject: [PATCH 099/183] feat: transliterate accented/diacritic characters to ASCII in translateUTF8ToBlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the block-character fallback with proper transliteration for Polish (ą→a, ć→c, ę→e, ł→l, ń→n, ó→o, ś→s, ź→z, ż→z), German (ä ö ü ß), and common French/Spanish/Portuguese accents. Unknown codepoints fall back to '?' instead of █. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 65 +++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 7242091a..c15b0462 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -42,17 +42,64 @@ public: print(str); } - // convert UTF-8 characters to displayable block characters for compatibility + static char transliterateCodepoint(uint32_t cp) { + switch (cp) { + // Polish + case 0x0105: return 'a'; case 0x0104: return 'A'; // ą Ą + case 0x0107: return 'c'; case 0x0106: return 'C'; // ć Ć + case 0x0119: return 'e'; case 0x0118: return 'E'; // ę Ę + case 0x0142: return 'l'; case 0x0141: return 'L'; // ł Ł + case 0x0144: return 'n'; case 0x0143: return 'N'; // ń Ń + case 0x00F3: return 'o'; case 0x00D3: return 'O'; // ó Ó + case 0x015B: return 's'; case 0x015A: return 'S'; // ś Ś + case 0x017A: return 'z'; case 0x0179: return 'Z'; // ź Ź + case 0x017C: return 'z'; case 0x017B: return 'Z'; // ż Ż + // German + case 0x00E4: return 'a'; case 0x00C4: return 'A'; // ä Ä + case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö + case 0x00FC: return 'u'; case 0x00DC: return 'U'; // ü Ü + case 0x00DF: return 's'; // ß + // French/Spanish/Portuguese common accents + case 0x00E0: case 0x00E1: case 0x00E2: case 0x00E3: return 'a'; + case 0x00C0: case 0x00C1: case 0x00C2: case 0x00C3: return 'A'; + case 0x00E8: case 0x00E9: case 0x00EA: case 0x00EB: return 'e'; + case 0x00C8: case 0x00C9: case 0x00CA: case 0x00CB: return 'E'; + case 0x00EC: case 0x00ED: case 0x00EE: case 0x00EF: return 'i'; + case 0x00CC: case 0x00CD: case 0x00CE: case 0x00CF: return 'I'; + case 0x00F2: case 0x00F4: case 0x00F5: return 'o'; + case 0x00D2: case 0x00D4: case 0x00D5: return 'O'; + case 0x00F9: case 0x00FA: case 0x00FB: return 'u'; + case 0x00D9: case 0x00DA: case 0x00DB: return 'U'; + case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç + case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ + case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý + default: return '?'; + } + } + + // convert UTF-8 to ASCII, transliterating accented/diacritic characters virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { size_t j = 0; - for (size_t i = 0; src[i] != 0 && j < dest_size - 1; i++) { - unsigned char c = (unsigned char)src[i]; - if (c >= 32 && c <= 126) { - dest[j++] = c; // ASCII printable - } else if (c >= 0x80) { - dest[j++] = '\xDB'; // CP437 full block █ - while (src[i+1] && (src[i+1] & 0xC0) == 0x80) - i++; // skip UTF-8 continuation bytes + const uint8_t* p = (const uint8_t*)src; + while (*p && j < dest_size - 1) { + uint8_t c = *p++; + if (c < 0x80) { + if (c >= 32) dest[j++] = c; + } else { + uint32_t cp = c; + if ((c & 0xE0) == 0xC0) { + cp = c & 0x1F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + } else if ((c & 0xF0) == 0xE0) { + cp = c & 0x0F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + } else { + while (*p && (*p & 0xC0) == 0x80) p++; + dest[j++] = '?'; + continue; + } + dest[j++] = transliterateCodepoint(cp); } } dest[j] = 0; From f6b13c5cabb593b625516a63e58b8c9b50b2e981 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 15:05:53 +0200 Subject: [PATCH 100/183] =?UTF-8?q?fix:=20use=20block=20char=20=E2=96=88?= =?UTF-8?q?=20fallback=20for=20unknown=20codepoints=20in=20transliteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index c15b0462..1a512381 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -73,7 +73,7 @@ public: case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý - default: return '?'; + default: return '\xDB'; // CP437 full block █ } } @@ -96,7 +96,7 @@ public: if (*p) cp = (cp << 6) | (*p++ & 0x3F); } else { while (*p && (*p & 0xC0) == 0x80) p++; - dest[j++] = '?'; + dest[j++] = '\xDB'; continue; } dest[j++] = transliterateCodepoint(cp); From 31962726ec8476189d68c499a55e800219d3e2ec Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 15:11:12 +0200 Subject: [PATCH 101/183] fix: apply UTF-8 transliteration in drawTextEllipsized Names printed via drawTextEllipsized (contacts, senders) bypassed translateUTF8ToBlocks, causing raw UTF-8 bytes to render as garbage on the Adafruit font. Now transliteration happens inside the method. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 1a512381..229a907a 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -108,10 +108,7 @@ public: // draw text with ellipsis if it exceeds max_width virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) { char temp_str[256]; // reasonable buffer size - size_t len = strlen(str); - if (len >= sizeof(temp_str)) len = sizeof(temp_str) - 1; - memcpy(temp_str, str, len); - temp_str[len] = 0; + translateUTF8ToBlocks(temp_str, str, sizeof(temp_str)); if (getTextWidth(temp_str) <= max_width) { setCursor(x, y); From 6a7f8b7ca7f89ea12d0d6028f425eadc7186699b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 15:24:36 +0200 Subject: [PATCH 102/183] fix: transliterate message text in FullscreenMsgView before wrapping Raw UTF-8 message body was passed directly to display.print(), rendering Polish and other accented chars as garbage on the Adafruit font. Transliterating before wrapLines also fixes byte-count wrapping for multi-byte UTF-8 characters. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/FullscreenMsgView.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index d66d759e..5945a728 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -48,8 +48,10 @@ struct FullscreenMsgView { display.drawTextEllipsized(2, 1, display.width() - 4, sender); display.setColor(DisplayDriver::LIGHT); + char trans_text[512]; + display.translateUTF8ToBlocks(trans_text, text, sizeof(trans_text)); char lines[12][FS_CHARS + 1]; - int lcount = wrapLines(text, lines, 12); + int lcount = wrapLines(trans_text, lines, 12); int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; if (scroll > max_scroll) scroll = max_scroll; From b91afa05b9d3815ebb525f450c8368a96d3e6386 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 17:47:22 +0200 Subject: [PATCH 103/183] fix: reduce low battery shutdown text size to fit 128px OLED Size 2 rendered text wider than display width. Switched to size 1 and adjusted y-positions to keep both lines visible. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7a34fdb2..c3316809 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2844,9 +2844,9 @@ void UITask::loop() { if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv) { if (_display != NULL) { _display->startFrame(); - _display->setTextSize(2); + _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); - _display->drawTextCentered(_display->width() / 2, 16, "Low Battery"); + _display->drawTextCentered(_display->width() / 2, 24, "Low Battery"); _display->drawTextCentered(_display->width() / 2, 36, "Shutting down"); _display->endFrame(); delay(2000); From 5bc250888f0b24fe11cddfe3a3c739b877db5031 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:15:29 +0200 Subject: [PATCH 104/183] fix: apply saved display brightness before UI task starts Loading... screen was always shown at full brightness because prefs are not yet loaded at that point. Apply setBrightness() in the first common code path after the_mesh.begin() loads prefs, so at least the tail of the loading screen and the entire splash screen respect the saved brightness level. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 751de330..879d04be 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -219,6 +219,10 @@ void setup() { #endif #ifdef DISPLAY_CLASS + // Apply saved brightness as soon as prefs are available so the tail of + // the loading screen is not stuck at full brightness. + if (disp && the_mesh.getNodePrefs()) + disp->setBrightness(the_mesh.getNodePrefs()->display_brightness); ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved #endif From 1b9dc42250f34b5f919e419ca8ae995a2dd2f13e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:19:12 +0200 Subject: [PATCH 105/183] fix: bot trigger matched against message body only, not sender name Channel messages have the format "sender_name: body". The trigger was being searched across the whole string, so a nick containing the trigger word would fire a reply even with an unrelated message. Now the match skips past the ": " separator and only checks the message body. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 673af4cf..0742bcd1 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -40,10 +40,15 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { millis() - _bot_last_reply_ms > 10000UL)) return; + // Skip "sender_name: " prefix so the trigger is only matched against the message body. + const char* msg = text; + const char* sep = strstr(text, ": "); + if (sep) msg = sep + 2; + const char* tr = _prefs.bot_trigger; int tlen = strlen(tr); bool matched = false; - for (const char* t = text; *t && !matched; t++) { + for (const char* t = msg; *t && !matched; t++) { int i = 0; while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; if (i == tlen) matched = true; From 44b2519df4ffccbfdee91f39c866b7028f705222 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:23:20 +0200 Subject: [PATCH 106/183] fix: with_sender buffer too small and FS_LINE_H mismatch - with_sender[160] could hold only ~160 chars but node_name(32) + expanded(200) needs up to 234; snprintf would silently truncate the UI display entry. Increased to 240. - FS_LINE_H was 9 but Adafruit 5x7 font line height is 8, wasting 1px per line and showing 5 lines instead of 6. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 2 +- examples/companion_radio/ui-new/FullscreenMsgView.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 0742bcd1..701ffeab 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -70,7 +70,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { _bot_last_reply_ms = millis(); #ifdef DISPLAY_CLASS if (_ui) { - char with_sender[160]; + char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, expanded); _ui->addChannelMsg(channel_idx, with_sender); } diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 5945a728..1fa7bd0f 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -4,7 +4,7 @@ #include static const int FS_CHARS = 21; -static const int FS_LINE_H = 9; +static const int FS_LINE_H = 8; static const int FS_START_Y = 12; static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; From 5d41812784f5a13b90ba5f2fce4583528b863c45 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:26:31 +0200 Subject: [PATCH 107/183] fix: separate DM and channel bot reply cooldown timers Single shared _bot_last_reply_ms caused a DM reply to block the channel bot for 10s and vice versa. Split into _bot_last_dm_reply_ms and _bot_last_ch_reply_ms so each cooldown is independent. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 3 ++- examples/companion_radio/MyMesh.h | 3 ++- examples/companion_radio/MyMeshBot.h | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 79f52deb..1002fa5e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -853,7 +853,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _cli_rescue = false; offline_queue_len = 0; app_target_ver = 0; - _bot_last_reply_ms = 0; + _bot_last_dm_reply_ms = 0; + _bot_last_ch_reply_ms = 0; _next_auto_advert_ms = 0; clearPendingReqs(); next_ack_idx = 0; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index ec8a56b1..e6160a16 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -234,7 +234,8 @@ private: uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; - unsigned long _bot_last_reply_ms; + unsigned long _bot_last_dm_reply_ms; + unsigned long _bot_last_ch_reply_ms; unsigned long _next_auto_advert_ms; TransportKey send_scope; diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 701ffeab..5c892164 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -5,7 +5,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { if (from.type != ADV_TYPE_CHAT) return; if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_dm[0] && - millis() - _bot_last_reply_ms > 10000UL)) + millis() - _bot_last_dm_reply_ms > 10000UL)) return; const char* tr = _prefs.bot_trigger; @@ -27,7 +27,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { &sensors, (float)board.getBattMilliVolts() / 1000.0f); uint32_t expected_ack, est_timeout; if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) { - _bot_last_reply_ms = millis(); + _bot_last_dm_reply_ms = millis(); #ifdef DISPLAY_CLASS if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded); #endif @@ -37,7 +37,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { if (!(_prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && channel_idx == _prefs.bot_channel_idx && - millis() - _bot_last_reply_ms > 10000UL)) + millis() - _bot_last_ch_reply_ms > 10000UL)) return; // Skip "sender_name: " prefix so the trigger is only matched against the message body. @@ -67,7 +67,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { &sensors, (float)board.getBattMilliVolts() / 1000.0f); int rlen = strlen(expanded); if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) { - _bot_last_reply_ms = millis(); + _bot_last_ch_reply_ms = millis(); #ifdef DISPLAY_CLASS if (_ui) { char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin From c4b152fd6aa3f440b9d90c5e8ffd03271716f9ef Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:28:18 +0200 Subject: [PATCH 108/183] fix: clear arrow background in FullscreenMsgView to prevent text overlap Text lines fill the full display width, causing the last chars to bleed into the scroll arrow area. Draw a DARK fillRect behind each arrow before rendering it, so the arrow appears on a clean black background without truncating the text width. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/FullscreenMsgView.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 1fa7bd0f..99b3317c 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -60,10 +60,16 @@ struct FullscreenMsgView { display.print(lines[scroll + i]); } if (scroll > 0) { + display.setColor(DisplayDriver::DARK); + display.fillRect(display.width() - 6, FS_START_Y, 6, FS_LINE_H); + display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, FS_START_Y); display.print("^"); } if (scroll < max_scroll) { + display.setColor(DisplayDriver::DARK); + display.fillRect(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H, 6, FS_LINE_H); + display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); display.print("v"); } From a60ec376d7a5ecd8c370532e20058c86a7d3b47c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:42:52 +0200 Subject: [PATCH 109/183] perf: increase render interval on static screens from 300ms to 2000ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static screens (SettingsScreen, BotScreen, FullscreenMsgView, message list, contact picker) were re-rendering every 300ms with no visible benefit. New messages still trigger immediate refresh via newMsg() → _next_refresh=100, so responsiveness is unchanged. Reduces unnecessary SPI transfers and CPU wakeups ~6x on idle static screens. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/BotScreen.h | 2 +- examples/companion_radio/ui-new/FullscreenMsgView.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 5a333af6..cf0b0ec8 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -97,7 +97,7 @@ public: } display.setColor(DisplayDriver::LIGHT); } - return 300; + return 2000; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 99b3317c..a5bfe99f 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -81,7 +81,7 @@ struct FullscreenMsgView { display.setCursor(display.width() - 6, 56); display.print(">"); } - return 300; + return 2000; } Result handleInput(char c) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c3316809..ed17df45 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -478,7 +478,7 @@ public: display.print("v"); } - return 300; + return 2000; } bool handleInput(char c) override { @@ -1293,7 +1293,7 @@ public: _hist_sel < fs_hist_count - 1, _hist_sel > 0); } - return 300; + return 2000; } ChannelDetails ch; @@ -1421,7 +1421,7 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _msg_scroll, total_msg_items); } - return 300; + return 2000; } bool handleInput(char c) override { From b068318bc5d6e5ec7610ee4442275f8691bb51bf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 00:12:40 +0200 Subject: [PATCH 110/183] refactor: extract SettingsScreen and QuickMsgScreen to separate header files Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/QuickMsgScreen.h | 1071 +++++++++++ .../companion_radio/ui-new/SettingsScreen.h | 518 ++++++ examples/companion_radio/ui-new/UITask.cpp | 1585 +---------------- 3 files changed, 1591 insertions(+), 1583 deletions(-) create mode 100644 examples/companion_radio/ui-new/QuickMsgScreen.h create mode 100644 examples/companion_radio/ui-new/SettingsScreen.h diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h new file mode 100644 index 00000000..a5b4b2ba --- /dev/null +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -0,0 +1,1071 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after SettingsScreen.h is defined. + +class QuickMsgScreen : public UIScreen { + UITask* _task; + + enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; + Phase _phase; + + // MODE_SELECT + int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers + + // CONTACT_PICK + int _contact_sel, _contact_scroll; + int _num_contacts; + uint16_t _sorted[MAX_CONTACTS]; + ContactInfo _sel_contact; + bool _room_mode; // true = picking a room server, false = picking a DM contact + + // CHANNEL_PICK + int _channel_sel, _channel_scroll; + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; + int _sel_channel_idx; + bool _sending_to_channel; + + // MSG_PICK (shared) + int _msg_sel, _msg_scroll; + int _active_msgs[QUICK_MSGS_MAX]; + int _active_msg_count; + + // CHANNEL_HIST + int _hist_sel, _hist_scroll; + FullscreenMsgView _fs; + int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _viewing_max_seen; // highest _hist_sel reached in current session + static const int CH_HIST_MAX = 32; + + // KEYBOARD + KeyboardWidget _kb; + + // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) + PopupMenu _ctx_menu; + bool _ctx_dirty; + char _ctx_notif_item[22]; + char _ctx_melody_item[20]; + + struct ChHistEntry { uint8_t ch_idx; char text[140]; }; + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + + // DM_HIST + struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; + static const int DM_HIST_MAX = 64; + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; + int _dm_hist_sel, _dm_hist_scroll; + FullscreenMsgView _dm_fs; + + static const int VISIBLE = 4; + static const int ITEM_H = 12; + static const int START_Y = 12; + static const int HIST_VISIBLE = 2; // 2-line boxed history entries + static const int HIST_ITEM_H = 21; // box(19) + gap(2) + static const int HIST_BOX_H = 19; + static const int HIST_START_Y = 11; + + void expandMsg(const char* tmpl, char* out, int out_len) const { + double lat = 0, lon = 0; + bool gps_valid = false; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + lat = loc->getLatitude() / 1000000.0; + lon = loc->getLongitude() / 1000000.0; + gps_valid = true; + } +#endif + NodePrefs* np = _task->getNodePrefs(); + float batt = (float)board.getBattMilliVolts() / 1000.0f; + ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, + rtc_clock.getCurrentTime(), + np ? np->tz_offset_hours : 0, + &sensors, batt); + } + + void setupMsgPick() { + _msg_sel = _msg_scroll = 0; + _active_msg_count = 0; + NodePrefs* p = _task->getNodePrefs(); + if (p) { + for (int i = 0; i < QUICK_MSGS_MAX; i++) { + if (p->custom_msgs[i][0] != '\0') + _active_msgs[_active_msg_count++] = i; + } + } + } + + void renderScrollHints(DisplayDriver& display, int scroll, int count) { + display.setColor(DisplayDriver::LIGHT); + if (scroll > 0) { + display.setCursor(display.width() - 6, START_Y); + display.print("^"); + } + if (scroll + VISIBLE < count) { + display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); + display.print("v"); + } + } + + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void afterSend(bool ok, const char* msg) { + if (ok && _sending_to_channel) { + _hist_sel = 0; + _hist_scroll = 0; + _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump + char entry[sizeof(ChHistEntry::text)]; + snprintf(entry, sizeof(entry), "Me: %s", msg); + addChannelMsg(_sel_channel_idx, entry); + // After inserting sent msg at index 0, the unread index range is stale. + // User is active in this channel — treat as fully read. + _ch_unread[_sel_channel_idx] = 0; + _unread_at_entry = 0; + _viewing_max_seen = 0; + _task->showAlert("Sent!", 600); + } else if (ok) { + storeDMMsg(_sel_contact.id.pub_key, true, msg); + _dm_hist_sel = 0; + _dm_hist_scroll = 0; + _phase = DM_HIST; + _task->showAlert("Sent!", 600); + } else { + _task->showAlert("Send failed", 1500); + _task->gotoHomeScreen(); + } + } + + bool sendText(const char* msg) { + if (_sending_to_channel) { + ChannelDetails ch; + if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; + return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, + the_mesh.getNodeName(), msg, strlen(msg)); + } else { + uint32_t expected_ack = 0, est_timeout = 0; + return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, + msg, expected_ack, est_timeout) > 0; + } + } + + void buildContactList() { + NodePrefs* p = _task->getNodePrefs(); + ContactInfo c; + int total = the_mesh.getNumContacts(); + _num_contacts = 0; + if (_room_mode) { + bool fav_only = (p && p->room_fav_only); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; + if (fav_only && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; + } + } else { + bool show_all = (p && p->dm_show_all); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; + if (!show_all && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; + } + } + } + + void buildChannelList() { + _num_channels = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + } + + // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on + uint8_t chNotifState(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_override & mask)) return 0; + return (p->ch_notif_muted & mask) ? 1 : 2; + } + + void setChNotifState(uint8_t ch_idx, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (state == 0) { + p->ch_notif_override &= ~mask; + p->ch_notif_muted &= ~mask; + } else if (state == 1) { + p->ch_notif_override |= mask; + p->ch_notif_muted |= mask; + } else { + p->ch_notif_override |= mask; + p->ch_notif_muted &= ~mask; + } + } + + uint8_t dmNotifState(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) + return p->dm_notif[i].state; + return 0; + } + + void setDmNotifState(const uint8_t* pub_key, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { + if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } + else p->dm_notif[i].state = state; + return; + } + } + if (state == 0) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state == 0) { + memcpy(p->dm_notif[i].prefix, pub_key, 4); + p->dm_notif[i].state = state; + return; + } + } + // table full — overwrite slot 0 + memcpy(p->dm_notif[0].prefix, pub_key, 4); + p->dm_notif[0].state = state; + } + + uint8_t chNotifMelody(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_melody_set & mask)) return 0; + return (p->ch_notif_melody_2 & mask) ? 2 : 1; + } + + void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (slot == 0) { + p->ch_notif_melody_set &= ~mask; + p->ch_notif_melody_2 &= ~mask; + } else if (slot == 1) { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 &= ~mask; + } else { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 |= mask; + } + } + + uint8_t dmMelodySlot(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) + return p->dm_melody[i].slot; + return 0; + } + + void setDmMelody(const uint8_t* pub_key, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { + if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); + else p->dm_melody[i].slot = slot; + return; + } + } + if (slot == 0) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot == 0) { + memcpy(p->dm_melody[i].prefix, pub_key, 4); + p->dm_melody[i].slot = slot; + return; + } + } + memcpy(p->dm_melody[0].prefix, pub_key, 4); + p->dm_melody[0].slot = slot; + } + +public: + QuickMsgScreen(UITask* task) + : _task(task), _phase(MODE_SELECT), _mode_sel(0), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), + _channel_sel(0), _channel_scroll(0), _num_channels(0), + _sel_channel_idx(0), _sending_to_channel(false), + _msg_sel(0), _msg_scroll(0), _active_msg_count(0), + _hist_sel(0), _hist_scroll(0), + _unread_at_entry(0), _viewing_max_seen(0), + _hist_head(0), _hist_count(0), + _dm_hist_head(0), _dm_hist_count(0), + _dm_hist_sel(-1), _dm_hist_scroll(0), + _ctx_dirty(false) { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + void addChannelMsg(uint8_t ch_idx, const char* text) { + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + + if (ch_idx < MAX_GROUP_CHANNELS) { + bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + } + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + storeDMMsg(pub_key, outgoing, text); + } + + int getDMUnreadTotal() const { + return _task->getDMUnreadTotal(); + } + + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + + void clearAllChannelUnread() { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + void updateChannelUnread() { + if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; + if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; + // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. + // Each step down from 0 sees one more message; seen count = max_seen + 1. + int remaining = _unread_at_entry - (_viewing_max_seen + 1); + _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + } + + void reset() { + _phase = MODE_SELECT; + _mode_sel = 0; + _contact_sel = _contact_scroll = 0; + _msg_sel = _msg_scroll = 0; + _channel_sel = _channel_scroll = 0; + _sending_to_channel = false; + + _room_mode = false; + buildContactList(); + buildChannelList(); + + _ctx_menu.active = false; + _ctx_dirty = false; + _unread_at_entry = 0; + _viewing_max_seen = 0; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + if (_phase == MODE_SELECT) { + display.drawTextCentered(display.width()/2, 0, "MESSAGE"); + display.fillRect(0, 10, display.width(), 1); + const char* opts[] = { "Direct message", "Channels", "Room Servers" }; + int badges[3] = { + getDMUnreadTotal(), + _task->getChannelUnreadCount(), + _task->getRoomUnreadCount() + }; + for (int i = 0; i < 3; i++) { + int y = START_Y + i * ITEM_H; + bool sel = (i == _mode_sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + display.print(opts[i]); + if (badges[i] > 0) { + char badge[5]; + snprintf(badge, sizeof(badge), "%d", badges[i]); + int bw = display.getTextWidth(badge) + 2; + display.setCursor(display.width() - bw, y); + display.print(badge); + } + } + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == CONTACT_PICK) { + display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); + display.fillRect(0, 10, display.width(), 1); + + if (_num_contacts == 0) { + display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); + return 5000; + } + + for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { + int list_idx = _contact_scroll + i; + int mesh_idx = _sorted[list_idx]; + bool sel = (list_idx == _contact_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + ContactInfo c; + if (the_mesh.getContactByIdx(mesh_idx, c)) { + display.setCursor(0, y); + display.print(sel ? ">" : " "); + char filtered[sizeof(c.name)]; + display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); + uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); + char badge[5] = ""; + int bw = 0; + if (dm_unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)dm_unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); + if (dm_unread > 0) { + display.setCursor(display.width() - bw + 1, y); + display.print(badge); + } + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _contact_scroll, _num_contacts); + + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == CHANNEL_PICK) { + display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); + display.fillRect(0, 10, display.width(), 1); + + if (_num_channels == 0) { + display.drawTextCentered(display.width()/2, 32, "No channels"); + return 5000; + } + + for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { + int list_idx = _channel_scroll + i; + bool sel = (list_idx == _channel_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + ChannelDetails ch; + if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { + uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + char badge[5] = ""; + int bw = 0; + if (unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); + if (unread > 0) { + display.setCursor(display.width() - bw, y); + display.print(badge); + } + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _channel_scroll, _num_channels); + + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == DM_HIST) { + display.setTextSize(1); + char filtered_name[sizeof(_sel_contact.name)]; + display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); + + if (_dm_fs.active && _dm_hist_sel >= 0) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0) { + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + return _dm_fs.render(display, sender, e.text, + _dm_hist_sel < dm_count - 1, + _dm_hist_sel > 0); + } + return 500; + } + + char title[24]; + display.setColor(DisplayDriver::LIGHT); + snprintf(title, sizeof(title), "%.23s", filtered_name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 9, display.width(), 1); + + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + + for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { + int item = _dm_hist_scroll + i; + bool sel = (item == _dm_hist_sel); + int y = HIST_START_Y + i * HIST_ITEM_H; + + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + if (ring_pos < 0) continue; + + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), HIST_BOX_H); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + } + } + + if (dm_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 32, "No messages yet"); + } + + display.setColor(DisplayDriver::LIGHT); + if (_dm_hist_scroll > 0) { + display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.print("^"); + } + if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { + display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + display.print("v"); + } + + bool compose_sel = (_dm_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1, cby = 55; + if (compose_sel) { + display.fillRect(cbx, cby - 1, ctw + 4, 9); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(cbx + 2, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + return dm_count > 0 ? 500 : 2000; + + } else if (_phase == CHANNEL_HIST) { + if (_fs.active && _hist_sel >= 0) { + int fs_hist_count = histCountForChannel(_sel_channel_idx); + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0) { + const char* ftext = _hist[ring_pos].text; + const char* fsep = strstr(ftext, ": "); + char fsender[22], fmsg[140]; + if (fsep) { + int nl = fsep - ftext; if (nl > 21) nl = 21; + strncpy(fsender, ftext, nl); fsender[nl] = '\0'; + strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } else { + strncpy(fsender, "?", sizeof(fsender)); + strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } + return _fs.render(display, fsender, fmsg, + _hist_sel < fs_hist_count - 1, + _hist_sel > 0); + } + return 2000; + } + + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + char title[24]; + snprintf(title, sizeof(title), "%.23s", ch.name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 9, display.width(), 1); + + int ch_hist_count = histCountForChannel(_sel_channel_idx); + + for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { + int item = _hist_scroll + i; + bool sel = (item == _hist_sel); + int y = HIST_START_Y + i * HIST_ITEM_H; + + int ring_pos = histEntryForChannel(_sel_channel_idx, item); + if (ring_pos < 0) continue; + + const char* text = _hist[ring_pos].text; + const char* sep = strstr(text, ": "); + char sender[22], msg_part[79]; + if (sep) { + int nl = sep - text; if (nl > 21) nl = 21; + strncpy(sender, text, nl); sender[nl] = '\0'; + strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } else { + strncpy(sender, "?", sizeof(sender)); + strncpy(msg_part, text, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), HIST_BOX_H); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + } + } + + if (ch_hist_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 32, "No messages yet"); + } + + // scroll hints + display.setColor(DisplayDriver::LIGHT); + if (_hist_scroll > 0) { + display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.print("^"); + } + if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { + display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + display.print("v"); + } + + // small compose button (bottom-left, always bordered, inverted when selected) + bool compose_sel = (_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1, cby = 55; + if (compose_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); + } + display.setCursor(cbx + 1, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == KEYBOARD) { + return _kb.render(display); + + } else { // MSG_PICK + char title[24]; + if (_sending_to_channel) { + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + snprintf(title, sizeof(title), "%.23s", ch.name); + } else { + snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); + } + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 10, display.width(), 1); + + int total_msg_items = 1 + _active_msg_count; + for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { + int idx = _msg_scroll + i; + bool sel = (idx == _msg_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + + if (idx == 0) { + display.setCursor(8, y); + display.print("Custom message..."); + } else { + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[idx - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : ""; + display.drawTextEllipsized(8, y, display.width() - 10, tmpl); + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _msg_scroll, total_msg_items); + } + return 2000; + } + + bool handleInput(char c) override { + if (_phase == MODE_SELECT) { + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } + if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } + if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } + if (c == KEY_ENTER) { + if (_mode_sel == 1) { + _phase = CHANNEL_PICK; + } else { + _room_mode = (_mode_sel == 2); + if (_room_mode) _task->clearRoomUnread(); + buildContactList(); + _contact_sel = _contact_scroll = 0; + _phase = CONTACT_PICK; + } + return true; + } + + } else if (_phase == CONTACT_PICK) { + // Context menu consumes all input while open + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_contacts > 0) { + ContactInfo ci; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { + if (_ctx_menu.selectedIndex() == 0) { + _task->clearDMUnread(ci.id.pub_key); + } else if (_ctx_menu.selectedIndex() == 1) { + uint8_t nstate = dmNotifState(ci.id.pub_key); + setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); + _ctx_dirty = true; + } else { + uint8_t slot = dmMelodySlot(ci.id.pub_key); + setDmMelody(ci.id.pub_key, (slot + 1) % 3); + _ctx_dirty = true; + } + } + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; + } + return true; + } + if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _contact_sel > 0) { + _contact_sel--; + if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; + return true; + } + if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { + _contact_sel++; + if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _num_contacts > 0) { + if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { + _task->clearDMUnread(_sel_contact.id.pub_key); + _dm_hist_sel = -1; + _dm_hist_scroll = 0; + _dm_fs.active = false; + _phase = DM_HIST; + } + return true; + } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[dmMelodySlot(ci.id.pub_key)]); } + _ctx_menu.begin("Contact options", 3); + _ctx_menu.addItem("Mark as read"); + _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); + _ctx_dirty = false; + return true; + } + + } else if (_phase == CHANNEL_PICK) { + // Context menu consumes all input while open + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + if (_ctx_menu.selectedIndex() == 0) { + _ch_unread[ch_idx] = 0; + } else if (_ctx_menu.selectedIndex() == 1) { + uint8_t nstate = chNotifState(ch_idx); + setChNotifState(ch_idx, (nstate + 1) % 3); + _ctx_dirty = true; + } else { + uint8_t slot = chNotifMelody(ch_idx); + setChNotifMelody(ch_idx, (slot + 1) % 3); + _ctx_dirty = true; + } + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _channel_sel > 0) { + _channel_sel--; + if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; + return true; + } + if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { + _channel_sel++; + if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _num_channels > 0) { + _sel_channel_idx = _channel_indices[_channel_sel]; + int hc = histCountForChannel(_sel_channel_idx); + _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; + _hist_scroll = 0; + _hist_sel = hc > 0 ? 0 : -1; + _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; + _phase = CHANNEL_HIST; + updateChannelUnread(); + return true; + } + if (c == KEY_CONTEXT_MENU && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[chNotifState(ch_idx)]); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[chNotifMelody(ch_idx)]); } + _ctx_menu.begin("Channel options", 3); + _ctx_menu.addItem("Mark all read"); + _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); + _ctx_dirty = false; + return true; + } + + } else if (_phase == DM_HIST) { + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + if (_dm_fs.active) { + auto res = _dm_fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::NEXT) { + if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::CLOSE) { + _dm_fs.active = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } + if (c == KEY_UP) { + if (_dm_hist_sel > 0) { + _dm_hist_sel--; + if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; + } else if (_dm_hist_sel == 0) { + _dm_hist_sel = -1; + } + return true; + } + if (c == KEY_DOWN) { + if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } + else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { + _dm_hist_sel++; + if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) + _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; + } + return true; + } + if (c == KEY_ENTER) { + if (_dm_hist_sel >= 0) { + _dm_fs.begin(); + } else { + _sending_to_channel = false; + setupMsgPick(); + _phase = MSG_PICK; + } + return true; + } + + } else if (_phase == CHANNEL_HIST) { + int ch_hist_count = histCountForChannel(_sel_channel_idx); + if (_fs.active) { + auto res = _fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::NEXT) { + if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::CLOSE) { + _fs.active = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } + if (c == KEY_UP) { + if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } + else if (_hist_sel == 0) _hist_sel = -1; + updateChannelUnread(); + return true; + } + if (c == KEY_DOWN) { + if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } + else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { + _hist_sel++; + if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; + } + updateChannelUnread(); + return true; + } + if (c == KEY_ENTER) { + if (_hist_sel >= 0) { + _fs.begin(); + updateChannelUnread(); + } else { + _sending_to_channel = true; + setupMsgPick(); + _phase = MSG_PICK; + } + return true; + } + + } else if (_phase == KEYBOARD) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::CANCELLED) { + _phase = MSG_PICK; + } else if (res == KeyboardWidget::DONE) { + if (_kb.len > 0) { + char expanded[KB_MAX_LEN + 1]; + expandMsg(_kb.buf, expanded, sizeof(expanded)); + bool ok = sendText(expanded); + afterSend(ok, expanded); + } + } + return true; + + } else { // MSG_PICK + int total_msg_items = 1 + _active_msg_count; + if (c == KEY_CANCEL) { + _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; + return true; + } + if (c == KEY_UP && _msg_sel > 0) { + _msg_sel--; + if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; + return true; + } + if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { + _msg_sel++; + if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER) { + if (_msg_sel == 0) { + _kb.begin(); + kbAddSensorPlaceholders(_kb, &sensors); + _phase = KEYBOARD; + return true; + } + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[_msg_sel - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : "OK"; + char msg[80]; + expandMsg(tmpl, msg, sizeof(msg)); + bool ok = sendText(msg); + afterSend(ok, msg); + return true; + } + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h new file mode 100644 index 00000000..e5e8c6ed --- /dev/null +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -0,0 +1,518 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after SensorPlaceholders.h is defined. + +class SettingsScreen : public UIScreen { + UITask* _task; + + enum SettingItem { + // Display section + SECTION_DISPLAY, + BRIGHTNESS, + AUTO_OFF, + BATT_DISPLAY, + CLOCK_SECONDS, + // Sound section + SECTION_SOUND, + BUZZER, + BUZZER_VOLUME, + DM_MELODY, + CH_MELODY, + // Home pages section + SECTION_HOME_PAGES, + HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, +#if ENV_INCLUDE_GPS == 1 + HOME_GPS, +#endif +#if UI_SENSORS_PAGE == 1 + HOME_SENSORS, +#endif + HOME_TOOLS, HOME_SHUTDOWN, + // Radio section + SECTION_RADIO, + TX_POWER, + // System section + SECTION_SYSTEM, + TIMEZONE, + LOW_BAT, +#if ENV_INCLUDE_GPS == 1 + // GPS section + SECTION_GPS, + GPS_INTERVAL, +#endif + // Contacts section + SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, + // Messages section + SECTION_MESSAGES, + MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, + MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, + Count + }; + + static const int VISIBLE_ITEMS = 4; + static const int ITEM_H = 11; + static const int START_Y = 12; + static const int VAL_X = 80; + + int _selected; + int _scroll; + bool _dirty; + + static const uint16_t AUTO_OFF_OPTS[5]; + static const char* AUTO_OFF_LABELS[5]; + static const int AUTO_OFF_COUNT = 5; +#if ENV_INCLUDE_GPS == 1 + static const uint32_t GPS_INTERVAL_OPTS[6]; + static const char* GPS_INTERVAL_LABELS[6]; + static const int GPS_INTERVAL_COUNT = 6; +#endif + static const uint16_t LOW_BAT_OPTS[7]; + static const char* LOW_BAT_LABELS[7]; + static const int LOW_BAT_COUNT = 7; + static const char* BATT_DISPLAY_LABELS[3]; + static const int BATT_DISPLAY_COUNT = 3; + + int lowBatIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < LOW_BAT_COUNT; i++) + if (LOW_BAT_OPTS[i] == p->low_batt_mv) return i; + return 0; + } + + void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { + const int box_w = 7, box_h = 7, gap = 2; + for (int i = 0; i < max_val; i++) { + int bx = x + i * (box_w + gap); + display.drawRect(bx, y, box_w, box_h); + if (i < value) + display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2); + } + } + + int autoOffIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 1; + for (int i = 0; i < AUTO_OFF_COUNT; i++) + if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i; + return 1; + } + +#if ENV_INCLUDE_GPS == 1 + int gpsIntervalIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < GPS_INTERVAL_COUNT; i++) + if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; + return 0; + } +#endif + + bool isSection(int item) const { + return item == SECTION_DISPLAY || item == SECTION_SOUND || + item == SECTION_HOME_PAGES || + item == SECTION_RADIO || item == SECTION_SYSTEM || + item == SECTION_CONTACTS || item == SECTION_MESSAGES +#if ENV_INCLUDE_GPS == 1 + || item == SECTION_GPS +#endif + ; + } + + const char* sectionName(int item) const { + if (item == SECTION_DISPLAY) return "Display"; + if (item == SECTION_SOUND) return "Sound"; + if (item == SECTION_HOME_PAGES) return "Home Pages"; + if (item == SECTION_RADIO) return "Radio"; + if (item == SECTION_SYSTEM) return "System"; + if (item == SECTION_CONTACTS) return "Contacts"; + if (item == SECTION_MESSAGES) return "Messages"; +#if ENV_INCLUDE_GPS == 1 + if (item == SECTION_GPS) return "GPS"; +#endif + return ""; + } + + bool isHomePage(int item) const { + return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || + item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || + item == HOME_SHUTDOWN +#if ENV_INCLUDE_GPS == 1 + || item == HOME_GPS +#endif +#if UI_SENSORS_PAGE == 1 + || item == HOME_SENSORS +#endif + ; + } + + uint16_t homePageBit(int item) const { + if (item == HOME_CLOCK) return HP_CLOCK; + if (item == HOME_RECENT) return HP_RECENT; + if (item == HOME_RADIO) return HP_RADIO; + if (item == HOME_BT) return HP_BLUETOOTH; + if (item == HOME_ADVERT) return HP_ADVERT; + if (item == HOME_TOOLS) return HP_TOOLS; + if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return HP_GPS; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return HP_SENSORS; +#endif + return 0; + } + + const char* homePageLabel(int item) const { + if (item == HOME_CLOCK) return "Clock"; + if (item == HOME_RECENT) return "Recent"; + if (item == HOME_RADIO) return "Radio"; + if (item == HOME_BT) return "Bluetooth"; + if (item == HOME_ADVERT) return "Advert"; + if (item == HOME_TOOLS) return "Tools"; + if (item == HOME_SHUTDOWN) return "Shutdown"; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return "GPS"; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return "Sensors"; +#endif + return ""; + } + + bool homePageVisible(int item, const NodePrefs* p) const { + uint16_t bit = homePageBit(item); + if (!bit) return false; + uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; + return (mask & bit) != 0; + } + + bool isMsgSlot(int item) const { + return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; + } + + int msgSlotIndex(int item) const { + return item - MSG_SLOT_0; + } + + int nextSelectable(int from) const { + for (int i = from + 1; i < (int)Count; i++) + if (!isSection(i)) return i; + return from; + } + + int prevSelectable(int from) const { + for (int i = from - 1; i >= 0; i--) + if (!isSection(i)) return i; + return from; + } + + void renderItem(DisplayDriver& display, int item, int y) { + NodePrefs* p = _task->getNodePrefs(); + + if (isSection(item)) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), 1); + display.setCursor(2, y + 2); + display.print(sectionName(item)); + return; + } + + bool sel = (item == _selected); + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + + if (item == BRIGHTNESS) { + display.print("Bright"); + renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); + } else if (item == BUZZER) { + display.print("Buzzer"); + display.setCursor(VAL_X, y); +#ifdef PIN_BUZZER + { static const char* labels[] = { "ON", "OFF", "Auto" }; + int m = _task->getBuzzerMode(); + display.print(labels[m < 3 ? m : 0]); } +#else + display.print("N/A"); +#endif + } else if (item == BUZZER_VOLUME) { + display.print("BzrVol"); +#ifdef PIN_BUZZER + renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); +#else + display.setCursor(VAL_X, y); + display.print("N/A"); +#endif + } else if (item == DM_MELODY) { + display.print("DM sound"); + display.setCursor(VAL_X, y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_dm : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (item == CH_MELODY) { + display.print("Ch sound"); + display.setCursor(VAL_X, y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_ch : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (isHomePage(item)) { + display.print(homePageLabel(item)); + display.setCursor(VAL_X, y); + display.print(homePageVisible(item, p) ? "ON" : "OFF"); + } else if (item == TX_POWER) { + display.print("TX Pwr"); + char buf[8]; + sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); + display.setCursor(VAL_X, y); + display.print(buf); + } else if (item == AUTO_OFF) { + display.print("AutoOff"); + display.setCursor(VAL_X, y); + display.print(AUTO_OFF_LABELS[autoOffIndex()]); +#if ENV_INCLUDE_GPS == 1 + } else if (item == GPS_INTERVAL) { + display.print("GPS upd"); + display.setCursor(VAL_X, y); + display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); +#endif + } else if (item == TIMEZONE) { + display.print("TimeZone"); + char buf[8]; + int8_t tz = p ? p->tz_offset_hours : 0; + if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); + else sprintf(buf, "UTC%d", (int)tz); + display.setCursor(VAL_X, y); + display.print(buf); + } else if (item == LOW_BAT) { + display.print("LowBat"); + display.setCursor(VAL_X, y); + display.print(LOW_BAT_LABELS[lowBatIndex()]); + } else if (item == BATT_DISPLAY) { + display.print("BattDisp"); + display.setCursor(VAL_X, y); + uint8_t mode = p ? p->batt_display_mode : 0; + display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); + } else if (item == CLOCK_SECONDS) { + display.print("Seconds"); + display.setCursor(VAL_X, y); + display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); + } else if (item == DM_FILTER) { + display.print("DM"); + display.setCursor(VAL_X, y); + display.print((p && p->dm_show_all) ? "all" : "fav"); + } else if (item == ROOM_FILTER) { + display.print("Rooms"); + display.setCursor(VAL_X, y); + display.print((p && p->room_fav_only) ? "fav" : "all"); + } else if (isMsgSlot(item)) { + int slot = msgSlotIndex(item); + char label[5]; + snprintf(label, sizeof(label), "Q%d:", slot + 1); + display.print(label); + const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; + display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); + } + } + + // Keyboard state for editing message slots + int _edit_slot; // -1 = not editing, 0..9 = slot being edited + KeyboardWidget _kb; + +public: + SettingsScreen(UITask* task) + : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} + + + void markClean() { _dirty = false; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + + if (_edit_slot >= 0) { + return _kb.render(display); + } + + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { + renderItem(display, _scroll + i, START_Y + i * ITEM_H); + } + + // scroll indicators + if (_scroll > 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - 6, START_Y); + display.print("^"); + } + if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); + display.print("v"); + } + + return 2000; + } + + bool handleInput(char c) override { + NodePrefs* p = _task->getNodePrefs(); + + // Keyboard editing mode for message slots + if (_edit_slot >= 0) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::DONE) { + if (p) { + strncpy(p->custom_msgs[_edit_slot], _kb.buf, sizeof(p->custom_msgs[0]) - 1); + p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; + _dirty = true; + } + _edit_slot = -1; + } else if (res == KeyboardWidget::CANCELLED) { + _edit_slot = -1; + } + return true; + } + + if (c == KEY_UP) { + int prev = prevSelectable(_selected); + if (prev != _selected) { + _selected = prev; + if (_selected < _scroll) _scroll = _selected; + } + return true; + } + if (c == KEY_DOWN) { + int next = nextSelectable(_selected); + if (next != _selected) { + _selected = next; + if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; + } + return true; + } + if (c == KEY_CANCEL) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoHomeScreen(); + return true; + } + + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool enter = (c == KEY_ENTER); + + if (_selected == BRIGHTNESS) { + uint8_t lvl = _task->getBrightnessLevel(); + if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; } + if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; } + return right || left; + } + if (_selected == BUZZER && (left || right || enter)) { + _task->cycleBuzzerMode(); + _dirty = true; + return true; + } + if (_selected == BUZZER_VOLUME) { +#ifdef PIN_BUZZER + uint8_t lvl = _task->getBuzzerVolume(); + if (right && lvl < 4) { _task->setBuzzerVolumeLevel(lvl + 1); _dirty = true; return true; } + if (left && lvl > 0) { _task->setBuzzerVolumeLevel(lvl - 1); _dirty = true; return true; } +#endif + return right || left; + } + if (_selected == DM_MELODY && p && (left || right || enter)) { + p->notif_melody_dm = (p->notif_melody_dm + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (_selected == CH_MELODY && p && (left || right || enter)) { + p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (isHomePage(_selected) && p && (left || right || enter)) { + p->home_pages_mask ^= homePageBit(_selected); + _dirty = true; + return true; + } + if (_selected == TX_POWER && p) { + if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } + if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } + } + if (_selected == AUTO_OFF && p) { + int idx = autoOffIndex(); + if (right) idx = (idx + 1) % AUTO_OFF_COUNT; + if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; + if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } + } +#if ENV_INCLUDE_GPS == 1 + if (_selected == GPS_INTERVAL && p) { + int idx = gpsIntervalIndex(); + if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; + if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; + if (left || right) { + p->gps_interval = GPS_INTERVAL_OPTS[idx]; + _task->applyGPSInterval(); + _dirty = true; + return true; + } + } +#endif + if (_selected == TIMEZONE && p) { + if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; _dirty = true; return true; } + if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; _dirty = true; return true; } + } + if (_selected == LOW_BAT && p) { + int idx = lowBatIndex(); + if (right) idx = (idx + 1) % LOW_BAT_COUNT; + if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; + if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } + } + if (_selected == BATT_DISPLAY && p) { + int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; + if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; + if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; + if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } + } + if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { + p->clock_hide_seconds ^= 1; + _dirty = true; + return true; + } + if (_selected == DM_FILTER && p && (left || right || enter)) { + p->dm_show_all = p->dm_show_all ? 0 : 1; + _dirty = true; + return true; + } + if (_selected == ROOM_FILTER && p && (left || right || enter)) { + p->room_fav_only = p->room_fav_only ? 0 : 1; + _dirty = true; + return true; + } + if (isMsgSlot(_selected) && enter) { + int slot = msgSlotIndex(_selected); + _edit_slot = slot; + _kb.begin(p ? p->custom_msgs[slot] : ""); + kbAddSensorPlaceholders(_kb, &sensors); + return true; + } + return false; + } +}; + +const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; +const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; +#if ENV_INCLUDE_GPS == 1 +const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; +const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; +#endif +const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 }; +const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; +const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ed17df45..0c5c1f50 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -117,1589 +117,8 @@ static const uint16_t HP_ALL = 0x01FF; #include "KeyboardWidget.h" #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" - -class SettingsScreen : public UIScreen { - UITask* _task; - - enum SettingItem { - // Display section - SECTION_DISPLAY, - BRIGHTNESS, - AUTO_OFF, - BATT_DISPLAY, - CLOCK_SECONDS, - // Sound section - SECTION_SOUND, - BUZZER, - BUZZER_VOLUME, - DM_MELODY, - CH_MELODY, - // Home pages section - SECTION_HOME_PAGES, - HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, -#if ENV_INCLUDE_GPS == 1 - HOME_GPS, -#endif -#if UI_SENSORS_PAGE == 1 - HOME_SENSORS, -#endif - HOME_TOOLS, HOME_SHUTDOWN, - // Radio section - SECTION_RADIO, - TX_POWER, - // System section - SECTION_SYSTEM, - TIMEZONE, - LOW_BAT, -#if ENV_INCLUDE_GPS == 1 - // GPS section - SECTION_GPS, - GPS_INTERVAL, -#endif - // Contacts section - SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, - // Messages section - SECTION_MESSAGES, - MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, - MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, - Count - }; - - static const int VISIBLE_ITEMS = 4; - static const int ITEM_H = 11; - static const int START_Y = 12; - static const int VAL_X = 80; - - int _selected; - int _scroll; - bool _dirty; - - static const uint16_t AUTO_OFF_OPTS[5]; - static const char* AUTO_OFF_LABELS[5]; - static const int AUTO_OFF_COUNT = 5; -#if ENV_INCLUDE_GPS == 1 - static const uint32_t GPS_INTERVAL_OPTS[6]; - static const char* GPS_INTERVAL_LABELS[6]; - static const int GPS_INTERVAL_COUNT = 6; -#endif - static const uint16_t LOW_BAT_OPTS[7]; - static const char* LOW_BAT_LABELS[7]; - static const int LOW_BAT_COUNT = 7; - static const char* BATT_DISPLAY_LABELS[3]; - static const int BATT_DISPLAY_COUNT = 3; - - int lowBatIndex() { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < LOW_BAT_COUNT; i++) - if (LOW_BAT_OPTS[i] == p->low_batt_mv) return i; - return 0; - } - - void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { - const int box_w = 7, box_h = 7, gap = 2; - for (int i = 0; i < max_val; i++) { - int bx = x + i * (box_w + gap); - display.drawRect(bx, y, box_w, box_h); - if (i < value) - display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2); - } - } - - int autoOffIndex() { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 1; - for (int i = 0; i < AUTO_OFF_COUNT; i++) - if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i; - return 1; - } - -#if ENV_INCLUDE_GPS == 1 - int gpsIntervalIndex() { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < GPS_INTERVAL_COUNT; i++) - if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; - return 0; - } -#endif - - bool isSection(int item) const { - return item == SECTION_DISPLAY || item == SECTION_SOUND || - item == SECTION_HOME_PAGES || - item == SECTION_RADIO || item == SECTION_SYSTEM || - item == SECTION_CONTACTS || item == SECTION_MESSAGES -#if ENV_INCLUDE_GPS == 1 - || item == SECTION_GPS -#endif - ; - } - - const char* sectionName(int item) const { - if (item == SECTION_DISPLAY) return "Display"; - if (item == SECTION_SOUND) return "Sound"; - if (item == SECTION_HOME_PAGES) return "Home Pages"; - if (item == SECTION_RADIO) return "Radio"; - if (item == SECTION_SYSTEM) return "System"; - if (item == SECTION_CONTACTS) return "Contacts"; - if (item == SECTION_MESSAGES) return "Messages"; -#if ENV_INCLUDE_GPS == 1 - if (item == SECTION_GPS) return "GPS"; -#endif - return ""; - } - - bool isHomePage(int item) const { - return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || - item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || - item == HOME_SHUTDOWN -#if ENV_INCLUDE_GPS == 1 - || item == HOME_GPS -#endif -#if UI_SENSORS_PAGE == 1 - || item == HOME_SENSORS -#endif - ; - } - - uint16_t homePageBit(int item) const { - if (item == HOME_CLOCK) return HP_CLOCK; - if (item == HOME_RECENT) return HP_RECENT; - if (item == HOME_RADIO) return HP_RADIO; - if (item == HOME_BT) return HP_BLUETOOTH; - if (item == HOME_ADVERT) return HP_ADVERT; - if (item == HOME_TOOLS) return HP_TOOLS; - if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; -#if ENV_INCLUDE_GPS == 1 - if (item == HOME_GPS) return HP_GPS; -#endif -#if UI_SENSORS_PAGE == 1 - if (item == HOME_SENSORS) return HP_SENSORS; -#endif - return 0; - } - - const char* homePageLabel(int item) const { - if (item == HOME_CLOCK) return "Clock"; - if (item == HOME_RECENT) return "Recent"; - if (item == HOME_RADIO) return "Radio"; - if (item == HOME_BT) return "Bluetooth"; - if (item == HOME_ADVERT) return "Advert"; - if (item == HOME_TOOLS) return "Tools"; - if (item == HOME_SHUTDOWN) return "Shutdown"; -#if ENV_INCLUDE_GPS == 1 - if (item == HOME_GPS) return "GPS"; -#endif -#if UI_SENSORS_PAGE == 1 - if (item == HOME_SENSORS) return "Sensors"; -#endif - return ""; - } - - bool homePageVisible(int item, const NodePrefs* p) const { - uint16_t bit = homePageBit(item); - if (!bit) return false; - uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; - return (mask & bit) != 0; - } - - bool isMsgSlot(int item) const { - return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; - } - - int msgSlotIndex(int item) const { - return item - MSG_SLOT_0; - } - - int nextSelectable(int from) const { - for (int i = from + 1; i < (int)Count; i++) - if (!isSection(i)) return i; - return from; - } - - int prevSelectable(int from) const { - for (int i = from - 1; i >= 0; i--) - if (!isSection(i)) return i; - return from; - } - - void renderItem(DisplayDriver& display, int item, int y) { - NodePrefs* p = _task->getNodePrefs(); - - if (isSection(item)) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), 1); - display.setCursor(2, y + 2); - display.print(sectionName(item)); - return; - } - - bool sel = (item == _selected); - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(8, y); - - if (item == BRIGHTNESS) { - display.print("Bright"); - renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); - } else if (item == BUZZER) { - display.print("Buzzer"); - display.setCursor(VAL_X, y); -#ifdef PIN_BUZZER - { static const char* labels[] = { "ON", "OFF", "Auto" }; - int m = _task->getBuzzerMode(); - display.print(labels[m < 3 ? m : 0]); } -#else - display.print("N/A"); -#endif - } else if (item == BUZZER_VOLUME) { - display.print("BzrVol"); -#ifdef PIN_BUZZER - renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); -#else - display.setCursor(VAL_X, y); - display.print("N/A"); -#endif - } else if (item == DM_MELODY) { - display.print("DM sound"); - display.setCursor(VAL_X, y); - { static const char* L[] = { "built-in", "M1", "M2" }; - uint8_t v = p ? p->notif_melody_dm : 0; - display.print(L[v < 3 ? v : 0]); } - } else if (item == CH_MELODY) { - display.print("Ch sound"); - display.setCursor(VAL_X, y); - { static const char* L[] = { "built-in", "M1", "M2" }; - uint8_t v = p ? p->notif_melody_ch : 0; - display.print(L[v < 3 ? v : 0]); } - } else if (isHomePage(item)) { - display.print(homePageLabel(item)); - display.setCursor(VAL_X, y); - display.print(homePageVisible(item, p) ? "ON" : "OFF"); - } else if (item == TX_POWER) { - display.print("TX Pwr"); - char buf[8]; - sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); - display.setCursor(VAL_X, y); - display.print(buf); - } else if (item == AUTO_OFF) { - display.print("AutoOff"); - display.setCursor(VAL_X, y); - display.print(AUTO_OFF_LABELS[autoOffIndex()]); -#if ENV_INCLUDE_GPS == 1 - } else if (item == GPS_INTERVAL) { - display.print("GPS upd"); - display.setCursor(VAL_X, y); - display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); -#endif - } else if (item == TIMEZONE) { - display.print("TimeZone"); - char buf[8]; - int8_t tz = p ? p->tz_offset_hours : 0; - if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); - else sprintf(buf, "UTC%d", (int)tz); - display.setCursor(VAL_X, y); - display.print(buf); - } else if (item == LOW_BAT) { - display.print("LowBat"); - display.setCursor(VAL_X, y); - display.print(LOW_BAT_LABELS[lowBatIndex()]); - } else if (item == BATT_DISPLAY) { - display.print("BattDisp"); - display.setCursor(VAL_X, y); - uint8_t mode = p ? p->batt_display_mode : 0; - display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); - } else if (item == CLOCK_SECONDS) { - display.print("Seconds"); - display.setCursor(VAL_X, y); - display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); - } else if (item == DM_FILTER) { - display.print("DM"); - display.setCursor(VAL_X, y); - display.print((p && p->dm_show_all) ? "all" : "fav"); - } else if (item == ROOM_FILTER) { - display.print("Rooms"); - display.setCursor(VAL_X, y); - display.print((p && p->room_fav_only) ? "fav" : "all"); - } else if (isMsgSlot(item)) { - int slot = msgSlotIndex(item); - char label[5]; - snprintf(label, sizeof(label), "Q%d:", slot + 1); - display.print(label); - const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; - display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); - } - } - - // Keyboard state for editing message slots - int _edit_slot; // -1 = not editing, 0..9 = slot being edited - KeyboardWidget _kb; - -public: - SettingsScreen(UITask* task) - : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} - - - void markClean() { _dirty = false; } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - - if (_edit_slot >= 0) { - return _kb.render(display); - } - - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); - display.fillRect(0, 10, display.width(), 1); - - for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { - renderItem(display, _scroll + i, START_Y + i * ITEM_H); - } - - // scroll indicators - if (_scroll > 0) { - display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, START_Y); - display.print("^"); - } - if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { - display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); - display.print("v"); - } - - return 2000; - } - - bool handleInput(char c) override { - NodePrefs* p = _task->getNodePrefs(); - - // Keyboard editing mode for message slots - if (_edit_slot >= 0) { - auto res = _kb.handleInput(c); - if (res == KeyboardWidget::DONE) { - if (p) { - strncpy(p->custom_msgs[_edit_slot], _kb.buf, sizeof(p->custom_msgs[0]) - 1); - p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0'; - _dirty = true; - } - _edit_slot = -1; - } else if (res == KeyboardWidget::CANCELLED) { - _edit_slot = -1; - } - return true; - } - - if (c == KEY_UP) { - int prev = prevSelectable(_selected); - if (prev != _selected) { - _selected = prev; - if (_selected < _scroll) _scroll = _selected; - } - return true; - } - if (c == KEY_DOWN) { - int next = nextSelectable(_selected); - if (next != _selected) { - _selected = next; - if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; - } - return true; - } - if (c == KEY_CANCEL) { - if (_dirty) the_mesh.savePrefs(); - _task->gotoHomeScreen(); - return true; - } - - bool right = (c == KEY_RIGHT || c == KEY_NEXT); - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool enter = (c == KEY_ENTER); - - if (_selected == BRIGHTNESS) { - uint8_t lvl = _task->getBrightnessLevel(); - if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; } - if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; } - return right || left; - } - if (_selected == BUZZER && (left || right || enter)) { - _task->cycleBuzzerMode(); - _dirty = true; - return true; - } - if (_selected == BUZZER_VOLUME) { -#ifdef PIN_BUZZER - uint8_t lvl = _task->getBuzzerVolume(); - if (right && lvl < 4) { _task->setBuzzerVolumeLevel(lvl + 1); _dirty = true; return true; } - if (left && lvl > 0) { _task->setBuzzerVolumeLevel(lvl - 1); _dirty = true; return true; } -#endif - return right || left; - } - if (_selected == DM_MELODY && p && (left || right || enter)) { - p->notif_melody_dm = (p->notif_melody_dm + (left ? 2 : 1)) % 3; - _dirty = true; return true; - } - if (_selected == CH_MELODY && p && (left || right || enter)) { - p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; - _dirty = true; return true; - } - if (isHomePage(_selected) && p && (left || right || enter)) { - p->home_pages_mask ^= homePageBit(_selected); - _dirty = true; - return true; - } - if (_selected == TX_POWER && p) { - if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } - if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } - } - if (_selected == AUTO_OFF && p) { - int idx = autoOffIndex(); - if (right) idx = (idx + 1) % AUTO_OFF_COUNT; - if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; - if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } - } -#if ENV_INCLUDE_GPS == 1 - if (_selected == GPS_INTERVAL && p) { - int idx = gpsIntervalIndex(); - if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; - if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; - if (left || right) { - p->gps_interval = GPS_INTERVAL_OPTS[idx]; - _task->applyGPSInterval(); - _dirty = true; - return true; - } - } -#endif - if (_selected == TIMEZONE && p) { - if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; _dirty = true; return true; } - if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; _dirty = true; return true; } - } - if (_selected == LOW_BAT && p) { - int idx = lowBatIndex(); - if (right) idx = (idx + 1) % LOW_BAT_COUNT; - if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; - if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; } - } - if (_selected == BATT_DISPLAY && p) { - int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0; - if (right) idx = (idx + 1) % BATT_DISPLAY_COUNT; - if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; - if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } - } - if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { - p->clock_hide_seconds ^= 1; - _dirty = true; - return true; - } - if (_selected == DM_FILTER && p && (left || right || enter)) { - p->dm_show_all = p->dm_show_all ? 0 : 1; - _dirty = true; - return true; - } - if (_selected == ROOM_FILTER && p && (left || right || enter)) { - p->room_fav_only = p->room_fav_only ? 0 : 1; - _dirty = true; - return true; - } - if (isMsgSlot(_selected) && enter) { - int slot = msgSlotIndex(_selected); - _edit_slot = slot; - _kb.begin(p ? p->custom_msgs[slot] : ""); - kbAddSensorPlaceholders(_kb, &sensors); - return true; - } - return false; - } -}; - -const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; -const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; -#if ENV_INCLUDE_GPS == 1 -const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; -const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; -#endif -const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 }; -const char* SettingsScreen::LOW_BAT_LABELS[7] = { "off", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" }; -const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; - -class QuickMsgScreen : public UIScreen { - UITask* _task; - - enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; - Phase _phase; - - // MODE_SELECT - int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers - - // CONTACT_PICK - int _contact_sel, _contact_scroll; - int _num_contacts; - uint16_t _sorted[MAX_CONTACTS]; - ContactInfo _sel_contact; - bool _room_mode; // true = picking a room server, false = picking a DM contact - - // CHANNEL_PICK - int _channel_sel, _channel_scroll; - int _num_channels; - uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - uint8_t _ch_unread[MAX_GROUP_CHANNELS]; - int _sel_channel_idx; - bool _sending_to_channel; - - // MSG_PICK (shared) - int _msg_sel, _msg_scroll; - int _active_msgs[QUICK_MSGS_MAX]; - int _active_msg_count; - - // CHANNEL_HIST - int _hist_sel, _hist_scroll; - FullscreenMsgView _fs; - int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST - int _viewing_max_seen; // highest _hist_sel reached in current session - static const int CH_HIST_MAX = 32; - - // KEYBOARD - KeyboardWidget _kb; - - // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) - PopupMenu _ctx_menu; - bool _ctx_dirty; - char _ctx_notif_item[22]; - char _ctx_melody_item[20]; - - struct ChHistEntry { uint8_t ch_idx; char text[140]; }; - ChHistEntry _hist[CH_HIST_MAX]; - int _hist_head, _hist_count; - - // DM_HIST - struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; - static const int DM_HIST_MAX = 64; - DmHistEntry _dm_hist[DM_HIST_MAX]; - int _dm_hist_head, _dm_hist_count; - int _dm_hist_sel, _dm_hist_scroll; - FullscreenMsgView _dm_fs; - - static const int VISIBLE = 4; - static const int ITEM_H = 12; - static const int START_Y = 12; - static const int HIST_VISIBLE = 2; // 2-line boxed history entries - static const int HIST_ITEM_H = 21; // box(19) + gap(2) - static const int HIST_BOX_H = 19; - static const int HIST_START_Y = 11; - - void expandMsg(const char* tmpl, char* out, int out_len) const { - double lat = 0, lon = 0; - bool gps_valid = false; -#if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - if (loc && loc->isValid()) { - lat = loc->getLatitude() / 1000000.0; - lon = loc->getLongitude() / 1000000.0; - gps_valid = true; - } -#endif - NodePrefs* np = _task->getNodePrefs(); - float batt = (float)board.getBattMilliVolts() / 1000.0f; - ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, - rtc_clock.getCurrentTime(), - np ? np->tz_offset_hours : 0, - &sensors, batt); - } - - void setupMsgPick() { - _msg_sel = _msg_scroll = 0; - _active_msg_count = 0; - NodePrefs* p = _task->getNodePrefs(); - if (p) { - for (int i = 0; i < QUICK_MSGS_MAX; i++) { - if (p->custom_msgs[i][0] != '\0') - _active_msgs[_active_msg_count++] = i; - } - } - } - - void renderScrollHints(DisplayDriver& display, int scroll, int count) { - display.setColor(DisplayDriver::LIGHT); - if (scroll > 0) { - display.setCursor(display.width() - 6, START_Y); - display.print("^"); - } - if (scroll + VISIBLE < count) { - display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); - display.print("v"); - } - } - - // count history entries for a specific channel - int histCountForChannel(int ch_idx) const { - int n = 0; - for (int i = 0; i < _hist_count; i++) { - if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; - } - return n; - } - - // get ring-buffer position of j-th history entry for channel (newest first) - int histEntryForChannel(int ch_idx, int j) const { - int n = 0; - for (int i = _hist_count - 1; i >= 0; i--) { - int pos = (_hist_head + i) % CH_HIST_MAX; - if (_hist[pos].ch_idx == (uint8_t)ch_idx) { - if (n == j) return pos; - n++; - } - } - return -1; - } - - void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { - int pos; - if (_dm_hist_count < DM_HIST_MAX) { - pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; - _dm_hist_count++; - } else { - pos = _dm_hist_head; - _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; - } - memcpy(_dm_hist[pos].prefix, pub_key, 4); - _dm_hist[pos].outgoing = outgoing ? 1 : 0; - strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); - _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; - } - - int dmHistCountForContact(const uint8_t* prefix) const { - int n = 0; - for (int i = 0; i < _dm_hist_count; i++) - if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; - return n; - } - - int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { - if (n == j) return pos; - n++; - } - } - return -1; - } - - void afterSend(bool ok, const char* msg) { - if (ok && _sending_to_channel) { - _hist_sel = 0; - _hist_scroll = 0; - _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump - char entry[sizeof(ChHistEntry::text)]; - snprintf(entry, sizeof(entry), "Me: %s", msg); - addChannelMsg(_sel_channel_idx, entry); - // After inserting sent msg at index 0, the unread index range is stale. - // User is active in this channel — treat as fully read. - _ch_unread[_sel_channel_idx] = 0; - _unread_at_entry = 0; - _viewing_max_seen = 0; - _task->showAlert("Sent!", 600); - } else if (ok) { - storeDMMsg(_sel_contact.id.pub_key, true, msg); - _dm_hist_sel = 0; - _dm_hist_scroll = 0; - _phase = DM_HIST; - _task->showAlert("Sent!", 600); - } else { - _task->showAlert("Send failed", 1500); - _task->gotoHomeScreen(); - } - } - - bool sendText(const char* msg) { - if (_sending_to_channel) { - ChannelDetails ch; - if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; - return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, - the_mesh.getNodeName(), msg, strlen(msg)); - } else { - uint32_t expected_ack = 0, est_timeout = 0; - return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, - msg, expected_ack, est_timeout) > 0; - } - } - - void buildContactList() { - NodePrefs* p = _task->getNodePrefs(); - ContactInfo c; - int total = the_mesh.getNumContacts(); - _num_contacts = 0; - if (_room_mode) { - bool fav_only = (p && p->room_fav_only); - for (int i = 0; i < total; i++) { - if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; - if (fav_only && !(c.flags & 0x01)) continue; - _sorted[_num_contacts++] = i; - } - } else { - bool show_all = (p && p->dm_show_all); - for (int i = 0; i < total; i++) { - if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; - if (!show_all && !(c.flags & 0x01)) continue; - _sorted[_num_contacts++] = i; - } - } - } - - void buildChannelList() { - _num_channels = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { - ChannelDetails ch; - if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { - _channel_indices[_num_channels++] = (uint8_t)i; - } - } - } - - // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on - uint8_t chNotifState(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_override & mask)) return 0; - return (p->ch_notif_muted & mask) ? 1 : 2; - } - - void setChNotifState(uint8_t ch_idx, uint8_t state) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (state == 0) { - p->ch_notif_override &= ~mask; - p->ch_notif_muted &= ~mask; - } else if (state == 1) { - p->ch_notif_override |= mask; - p->ch_notif_muted |= mask; - } else { - p->ch_notif_override |= mask; - p->ch_notif_muted &= ~mask; - } - } - - uint8_t dmNotifState(const uint8_t* pub_key) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) - return p->dm_notif[i].state; - return 0; - } - - void setDmNotifState(const uint8_t* pub_key, uint8_t state) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { - if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } - else p->dm_notif[i].state = state; - return; - } - } - if (state == 0) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state == 0) { - memcpy(p->dm_notif[i].prefix, pub_key, 4); - p->dm_notif[i].state = state; - return; - } - } - // table full — overwrite slot 0 - memcpy(p->dm_notif[0].prefix, pub_key, 4); - p->dm_notif[0].state = state; - } - - uint8_t chNotifMelody(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_melody_set & mask)) return 0; - return (p->ch_notif_melody_2 & mask) ? 2 : 1; - } - - void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (slot == 0) { - p->ch_notif_melody_set &= ~mask; - p->ch_notif_melody_2 &= ~mask; - } else if (slot == 1) { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 &= ~mask; - } else { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 |= mask; - } - } - - uint8_t dmMelodySlot(const uint8_t* pub_key) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) - return p->dm_melody[i].slot; - return 0; - } - - void setDmMelody(const uint8_t* pub_key, uint8_t slot) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { - if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); - else p->dm_melody[i].slot = slot; - return; - } - } - if (slot == 0) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot == 0) { - memcpy(p->dm_melody[i].prefix, pub_key, 4); - p->dm_melody[i].slot = slot; - return; - } - } - memcpy(p->dm_melody[0].prefix, pub_key, 4); - p->dm_melody[0].slot = slot; - } - -public: - QuickMsgScreen(UITask* task) - : _task(task), _phase(MODE_SELECT), _mode_sel(0), - _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), - _channel_sel(0), _channel_scroll(0), _num_channels(0), - _sel_channel_idx(0), _sending_to_channel(false), - _msg_sel(0), _msg_scroll(0), _active_msg_count(0), - _hist_sel(0), _hist_scroll(0), - _unread_at_entry(0), _viewing_max_seen(0), - _hist_head(0), _hist_count(0), - _dm_hist_head(0), _dm_hist_count(0), - _dm_hist_sel(-1), _dm_hist_scroll(0), - _ctx_dirty(false) { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } - - void addChannelMsg(uint8_t ch_idx, const char* text) { - int pos; - if (_hist_count < CH_HIST_MAX) { - pos = (_hist_head + _hist_count) % CH_HIST_MAX; - _hist_count++; - } else { - pos = _hist_head; - _hist_head = (_hist_head + 1) % CH_HIST_MAX; - } - _hist[pos].ch_idx = ch_idx; - strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); - _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; - - if (ch_idx < MAX_GROUP_CHANNELS) { - bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; - } - } - - void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { - storeDMMsg(pub_key, outgoing, text); - } - - int getDMUnreadTotal() const { - return _task->getDMUnreadTotal(); - } - - int getTotalChannelUnread() const { - int total = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; - return total; - } - - void clearAllChannelUnread() { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } - - void updateChannelUnread() { - if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; - if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; - // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. - // Each step down from 0 sees one more message; seen count = max_seen + 1. - int remaining = _unread_at_entry - (_viewing_max_seen + 1); - _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); - } - - void reset() { - _phase = MODE_SELECT; - _mode_sel = 0; - _contact_sel = _contact_scroll = 0; - _msg_sel = _msg_scroll = 0; - _channel_sel = _channel_scroll = 0; - _sending_to_channel = false; - - _room_mode = false; - buildContactList(); - buildChannelList(); - - _ctx_menu.active = false; - _ctx_dirty = false; - _unread_at_entry = 0; - _viewing_max_seen = 0; - } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - - if (_phase == MODE_SELECT) { - display.drawTextCentered(display.width()/2, 0, "MESSAGE"); - display.fillRect(0, 10, display.width(), 1); - const char* opts[] = { "Direct message", "Channels", "Room Servers" }; - int badges[3] = { - getDMUnreadTotal(), - _task->getChannelUnreadCount(), - _task->getRoomUnreadCount() - }; - for (int i = 0; i < 3; i++) { - int y = START_Y + i * ITEM_H; - bool sel = (i == _mode_sel); - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(8, y); - display.print(opts[i]); - if (badges[i] > 0) { - char badge[5]; - snprintf(badge, sizeof(badge), "%d", badges[i]); - int bw = display.getTextWidth(badge) + 2; - display.setCursor(display.width() - bw, y); - display.print(badge); - } - } - display.setColor(DisplayDriver::LIGHT); - - } else if (_phase == CONTACT_PICK) { - display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); - display.fillRect(0, 10, display.width(), 1); - - if (_num_contacts == 0) { - display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); - return 5000; - } - - for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { - int list_idx = _contact_scroll + i; - int mesh_idx = _sorted[list_idx]; - bool sel = (list_idx == _contact_sel); - int y = START_Y + i * ITEM_H; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - - ContactInfo c; - if (the_mesh.getContactByIdx(mesh_idx, c)) { - display.setCursor(0, y); - display.print(sel ? ">" : " "); - char filtered[sizeof(c.name)]; - display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); - uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); - char badge[5] = ""; - int bw = 0; - if (dm_unread > 0) { - snprintf(badge, sizeof(badge), "%d", (int)dm_unread); - bw = display.getTextWidth(badge) + 2; - } - display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); - if (dm_unread > 0) { - display.setCursor(display.width() - bw + 1, y); - display.print(badge); - } - } - } - display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _contact_scroll, _num_contacts); - - // Context menu overlay - if (_ctx_menu.active) _ctx_menu.render(display); - - } else if (_phase == CHANNEL_PICK) { - display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); - display.fillRect(0, 10, display.width(), 1); - - if (_num_channels == 0) { - display.drawTextCentered(display.width()/2, 32, "No channels"); - return 5000; - } - - for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { - int list_idx = _channel_scroll + i; - bool sel = (list_idx == _channel_sel); - int y = START_Y + i * ITEM_H; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - ChannelDetails ch; - if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { - uint8_t unread = _ch_unread[_channel_indices[list_idx]]; - char badge[5] = ""; - int bw = 0; - if (unread > 0) { - snprintf(badge, sizeof(badge), "%d", (int)unread); - bw = display.getTextWidth(badge) + 2; - } - display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); - if (unread > 0) { - display.setCursor(display.width() - bw, y); - display.print(badge); - } - } - } - display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _channel_scroll, _num_channels); - - // Context menu overlay - if (_ctx_menu.active) _ctx_menu.render(display); - - } else if (_phase == DM_HIST) { - display.setTextSize(1); - char filtered_name[sizeof(_sel_contact.name)]; - display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); - - if (_dm_fs.active && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); - if (ring_pos >= 0) { - const DmHistEntry& e = _dm_hist[ring_pos]; - const char* sender = e.outgoing ? "Me" : filtered_name; - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - return _dm_fs.render(display, sender, e.text, - _dm_hist_sel < dm_count - 1, - _dm_hist_sel > 0); - } - return 500; - } - - char title[24]; - display.setColor(DisplayDriver::LIGHT); - snprintf(title, sizeof(title), "%.23s", filtered_name); - display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 9, display.width(), 1); - - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - - for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { - int item = _dm_hist_scroll + i; - bool sel = (item == _dm_hist_sel); - int y = HIST_START_Y + i * HIST_ITEM_H; - - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); - if (ring_pos < 0) continue; - - const DmHistEntry& e = _dm_hist[ring_pos]; - const char* sender = e.outgoing ? "Me" : filtered_name; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), HIST_BOX_H); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), HIST_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); - } - } - - if (dm_count == 0) { - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width()/2, 32, "No messages yet"); - } - - display.setColor(DisplayDriver::LIGHT); - if (_dm_hist_scroll > 0) { - display.setCursor(display.width() - 6, HIST_START_Y + 1); - display.print("^"); - } - if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { - display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); - display.print("v"); - } - - bool compose_sel = (_dm_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1, cby = 55; - if (compose_sel) { - display.fillRect(cbx, cby - 1, ctw + 4, 9); - display.setColor(DisplayDriver::DARK); - } - display.setCursor(cbx + 2, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); - return dm_count > 0 ? 500 : 2000; - - } else if (_phase == CHANNEL_HIST) { - if (_fs.active && _hist_sel >= 0) { - int fs_hist_count = histCountForChannel(_sel_channel_idx); - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); - if (ring_pos >= 0) { - const char* ftext = _hist[ring_pos].text; - const char* fsep = strstr(ftext, ": "); - char fsender[22], fmsg[140]; - if (fsep) { - int nl = fsep - ftext; if (nl > 21) nl = 21; - strncpy(fsender, ftext, nl); fsender[nl] = '\0'; - strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; - } else { - strncpy(fsender, "?", sizeof(fsender)); - strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; - } - return _fs.render(display, fsender, fmsg, - _hist_sel < fs_hist_count - 1, - _hist_sel > 0); - } - return 2000; - } - - ChannelDetails ch; - the_mesh.getChannel(_sel_channel_idx, ch); - char title[24]; - snprintf(title, sizeof(title), "%.23s", ch.name); - display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 9, display.width(), 1); - - int ch_hist_count = histCountForChannel(_sel_channel_idx); - - for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { - int item = _hist_scroll + i; - bool sel = (item == _hist_sel); - int y = HIST_START_Y + i * HIST_ITEM_H; - - int ring_pos = histEntryForChannel(_sel_channel_idx, item); - if (ring_pos < 0) continue; - - const char* text = _hist[ring_pos].text; - const char* sep = strstr(text, ": "); - char sender[22], msg_part[79]; - if (sep) { - int nl = sep - text; if (nl > 21) nl = 21; - strncpy(sender, text, nl); sender[nl] = '\0'; - strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); - msg_part[sizeof(msg_part) - 1] = '\0'; - } else { - strncpy(sender, "?", sizeof(sender)); - strncpy(msg_part, text, sizeof(msg_part) - 1); - msg_part[sizeof(msg_part) - 1] = '\0'; - } - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), HIST_BOX_H); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), HIST_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); - } - } - - if (ch_hist_count == 0) { - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width()/2, 32, "No messages yet"); - } - - // scroll hints - display.setColor(DisplayDriver::LIGHT); - if (_hist_scroll > 0) { - display.setCursor(display.width() - 6, HIST_START_Y + 1); - display.print("^"); - } - if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { - display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); - display.print("v"); - } - - // small compose button (bottom-left, always bordered, inverted when selected) - bool compose_sel = (_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1, cby = 55; - if (compose_sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); - } - display.setCursor(cbx + 1, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); - - } else if (_phase == KEYBOARD) { - return _kb.render(display); - - } else { // MSG_PICK - char title[24]; - if (_sending_to_channel) { - ChannelDetails ch; - the_mesh.getChannel(_sel_channel_idx, ch); - snprintf(title, sizeof(title), "%.23s", ch.name); - } else { - snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); - } - display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 10, display.width(), 1); - - int total_msg_items = 1 + _active_msg_count; - for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { - int idx = _msg_scroll + i; - bool sel = (idx == _msg_sel); - int y = START_Y + i * ITEM_H; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - - if (idx == 0) { - display.setCursor(8, y); - display.print("Custom message..."); - } else { - NodePrefs* p = _task->getNodePrefs(); - int slot = _active_msgs[idx - 1]; - const char* tmpl = p ? p->custom_msgs[slot] : ""; - display.drawTextEllipsized(8, y, display.width() - 10, tmpl); - } - } - display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _msg_scroll, total_msg_items); - } - return 2000; - } - - bool handleInput(char c) override { - if (_phase == MODE_SELECT) { - if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } - if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } - if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } - if (c == KEY_ENTER) { - if (_mode_sel == 1) { - _phase = CHANNEL_PICK; - } else { - _room_mode = (_mode_sel == 2); - if (_room_mode) _task->clearRoomUnread(); - buildContactList(); - _contact_sel = _contact_scroll = 0; - _phase = CONTACT_PICK; - } - return true; - } - - } else if (_phase == CONTACT_PICK) { - // Context menu consumes all input while open - if (_ctx_menu.active) { - auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED && _num_contacts > 0) { - ContactInfo ci; - if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { - if (_ctx_menu.selectedIndex() == 0) { - _task->clearDMUnread(ci.id.pub_key); - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = dmNotifState(ci.id.pub_key); - setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); - _ctx_dirty = true; - } else { - uint8_t slot = dmMelodySlot(ci.id.pub_key); - setDmMelody(ci.id.pub_key, (slot + 1) % 3); - _ctx_dirty = true; - } - } - } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } - return true; - } - if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } - if (c == KEY_UP && _contact_sel > 0) { - _contact_sel--; - if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; - return true; - } - if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { - _contact_sel++; - if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; - return true; - } - if (c == KEY_ENTER && _num_contacts > 0) { - if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { - _task->clearDMUnread(_sel_contact.id.pub_key); - _dm_hist_sel = -1; - _dm_hist_scroll = 0; - _dm_fs.active = false; - _phase = DM_HIST; - } - return true; - } - if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { - static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; - ContactInfo ci; - the_mesh.getContactByIdx(_sorted[_contact_sel], ci); - snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", - NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); - { static const char* ML[] = { "global", "M1", "M2" }; - snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", - ML[dmMelodySlot(ci.id.pub_key)]); } - _ctx_menu.begin("Contact options", 3); - _ctx_menu.addItem("Mark as read"); - _ctx_menu.addItem(_ctx_notif_item); - _ctx_menu.addItem(_ctx_melody_item); - _ctx_dirty = false; - return true; - } - - } else if (_phase == CHANNEL_PICK) { - // Context menu consumes all input while open - if (_ctx_menu.active) { - auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED && _num_channels > 0) { - uint8_t ch_idx = _channel_indices[_channel_sel]; - if (_ctx_menu.selectedIndex() == 0) { - _ch_unread[ch_idx] = 0; - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = chNotifState(ch_idx); - setChNotifState(ch_idx, (nstate + 1) % 3); - _ctx_dirty = true; - } else { - uint8_t slot = chNotifMelody(ch_idx); - setChNotifMelody(ch_idx, (slot + 1) % 3); - _ctx_dirty = true; - } - } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } - return true; - } - if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } - if (c == KEY_UP && _channel_sel > 0) { - _channel_sel--; - if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; - return true; - } - if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { - _channel_sel++; - if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; - return true; - } - if (c == KEY_ENTER && _num_channels > 0) { - _sel_channel_idx = _channel_indices[_channel_sel]; - int hc = histCountForChannel(_sel_channel_idx); - _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; - _hist_scroll = 0; - _hist_sel = hc > 0 ? 0 : -1; - _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; - _phase = CHANNEL_HIST; - updateChannelUnread(); - return true; - } - if (c == KEY_CONTEXT_MENU && _num_channels > 0) { - uint8_t ch_idx = _channel_indices[_channel_sel]; - static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; - snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", - NOTIF_LABELS[chNotifState(ch_idx)]); - { static const char* ML[] = { "global", "M1", "M2" }; - snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", - ML[chNotifMelody(ch_idx)]); } - _ctx_menu.begin("Channel options", 3); - _ctx_menu.addItem("Mark all read"); - _ctx_menu.addItem(_ctx_notif_item); - _ctx_menu.addItem(_ctx_melody_item); - _ctx_dirty = false; - return true; - } - - } else if (_phase == DM_HIST) { - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - if (_dm_fs.active) { - auto res = _dm_fs.handleInput(c); - if (res == FullscreenMsgView::PREV) { - if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } - } else if (res == FullscreenMsgView::NEXT) { - if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } - } else if (res == FullscreenMsgView::CLOSE) { - _dm_fs.active = false; - } - return true; - } - if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } - if (c == KEY_UP) { - if (_dm_hist_sel > 0) { - _dm_hist_sel--; - if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; - } else if (_dm_hist_sel == 0) { - _dm_hist_sel = -1; - } - return true; - } - if (c == KEY_DOWN) { - if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } - else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { - _dm_hist_sel++; - if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) - _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; - } - return true; - } - if (c == KEY_ENTER) { - if (_dm_hist_sel >= 0) { - _dm_fs.begin(); - } else { - _sending_to_channel = false; - setupMsgPick(); - _phase = MSG_PICK; - } - return true; - } - - } else if (_phase == CHANNEL_HIST) { - int ch_hist_count = histCountForChannel(_sel_channel_idx); - if (_fs.active) { - auto res = _fs.handleInput(c); - if (res == FullscreenMsgView::PREV) { - if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } - } else if (res == FullscreenMsgView::NEXT) { - if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } - } else if (res == FullscreenMsgView::CLOSE) { - _fs.active = false; - } - return true; - } - if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } - if (c == KEY_UP) { - if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } - else if (_hist_sel == 0) _hist_sel = -1; - updateChannelUnread(); - return true; - } - if (c == KEY_DOWN) { - if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } - else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { - _hist_sel++; - if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; - } - updateChannelUnread(); - return true; - } - if (c == KEY_ENTER) { - if (_hist_sel >= 0) { - _fs.begin(); - updateChannelUnread(); - } else { - _sending_to_channel = true; - setupMsgPick(); - _phase = MSG_PICK; - } - return true; - } - - } else if (_phase == KEYBOARD) { - auto res = _kb.handleInput(c); - if (res == KeyboardWidget::CANCELLED) { - _phase = MSG_PICK; - } else if (res == KeyboardWidget::DONE) { - if (_kb.len > 0) { - char expanded[KB_MAX_LEN + 1]; - expandMsg(_kb.buf, expanded, sizeof(expanded)); - bool ok = sendText(expanded); - afterSend(ok, expanded); - } - } - return true; - - } else { // MSG_PICK - int total_msg_items = 1 + _active_msg_count; - if (c == KEY_CANCEL) { - _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; - return true; - } - if (c == KEY_UP && _msg_sel > 0) { - _msg_sel--; - if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; - return true; - } - if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { - _msg_sel++; - if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; - return true; - } - if (c == KEY_ENTER) { - if (_msg_sel == 0) { - _kb.begin(); - kbAddSensorPlaceholders(_kb, &sensors); - _phase = KEYBOARD; - return true; - } - NodePrefs* p = _task->getNodePrefs(); - int slot = _active_msgs[_msg_sel - 1]; - const char* tmpl = p ? p->custom_msgs[slot] : "OK"; - char msg[80]; - expandMsg(tmpl, msg, sizeof(msg)); - bool ok = sendText(msg); - afterSend(ok, msg); - return true; - } - } - return false; - } -}; +#include "SettingsScreen.h" +#include "QuickMsgScreen.h" // ── Custom screens (separate files to ease upstream merges) ─────────────────── #include "RingtoneEditorScreen.h" From 9ff42a9296d6fe3ca9394703b4b011248609a8e0 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 09:55:51 +0200 Subject: [PATCH 111/183] =?UTF-8?q?feat:=20screen=20lock=20=E2=80=94=20hol?= =?UTF-8?q?d=20Back=20+=203=C3=97Enter=20to=20lock/unlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Long press Back starts lock sequence; 3 Enter presses within 3s toggle lock - Locked screen wakes for 5 seconds on any key, showing clock and unlock hint - Incoming messages do not wake the display while locked - Auto Lock setting in Display section: locks automatically when screen turns off - Lock state persisted in UITask; NodePrefs gains auto_lock flag Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/NodePrefs.h | 1 + .../companion_radio/ui-new/SettingsScreen.h | 10 ++ examples/companion_radio/ui-new/UITask.cpp | 115 ++++++++++++++++-- examples/companion_radio/ui-new/UITask.h | 8 ++ 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3f70e34e..e40f99a1 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -37,6 +37,7 @@ struct NodePrefs { // persisted to file uint8_t default_scope_key[16]; uint8_t display_brightness; // 0=min..4=max, default 2 (medium) uint16_t auto_off_secs; // display auto-off: 0=never, else seconds (default 15) + uint8_t auto_lock; // 0=disabled, 1=lock screen when display turns off int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0) uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index e5e8c6ed..ecd59b83 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -10,6 +10,7 @@ class SettingsScreen : public UIScreen { SECTION_DISPLAY, BRIGHTNESS, AUTO_OFF, + AUTO_LOCK, BATT_DISPLAY, CLOCK_SECONDS, // Sound section @@ -279,6 +280,10 @@ class SettingsScreen : public UIScreen { display.print("AutoOff"); display.setCursor(VAL_X, y); display.print(AUTO_OFF_LABELS[autoOffIndex()]); + } else if (item == AUTO_LOCK) { + display.print("AutoLock"); + display.setCursor(VAL_X, y); + display.print((p && p->auto_lock) ? "ON" : "OFF"); #if ENV_INCLUDE_GPS == 1 } else if (item == GPS_INTERVAL) { display.print("GPS upd"); @@ -452,6 +457,11 @@ public: if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } } + if (_selected == AUTO_LOCK && p && (left || right || enter)) { + p->auto_lock ^= 1; + _dirty = true; + return true; + } #if ENV_INCLUDE_GPS == 1 if (_selected == GPS_INTERVAL && p) { int idx = gpsIntervalIndex(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0c5c1f50..0142e62c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1049,7 +1049,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); showAlert(alert_buf, 3000); - if (_display != NULL) { + if (_display != NULL && !_locked) { if (!_display->isOn() && !hasConnection()) { _display->turnOn(); } @@ -1157,6 +1157,13 @@ void UITask::loop() { ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_CANCEL); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + if (_display != NULL && _display->isOn()) { + _lock_seq = 1; + _lock_seq_count = 0; + _lock_seq_ms = millis(); + _next_refresh = 0; + } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { c = handleTripleClick(KEY_SELECT); } @@ -1199,10 +1206,48 @@ void UITask::loop() { } #endif - if (c != 0 && curr) { - curr->handleInput(c); - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer - _next_refresh = 100; // trigger refresh + // Lock sequence timeout check + if (_lock_seq == 1 && millis() - _lock_seq_ms > 3000) { + _lock_seq = 0; + _lock_seq_count = 0; + } + + if (c != 0) { + // Intercept Enter presses when a lock/unlock sequence is active + if (_lock_seq == 1 && c == KEY_ENTER) { + _lock_seq_count++; + if (_lock_seq_count >= 3) { + _lock_seq = 0; + _lock_seq_count = 0; + _locked = !_locked; + if (_locked) { + _lock_wake_until = millis() + 2000; // brief "locked" display, then blank + } else { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } + } + _next_refresh = 0; + } else if (_lock_seq == 1) { + // Non-Enter key cancels the sequence; process key normally below + _lock_seq = 0; + _lock_seq_count = 0; + if (!_locked && curr) { + curr->handleInput(c); + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } + _next_refresh = 100; + } + } else if (!_locked && curr) { + curr->handleInput(c); + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer + _next_refresh = 100; // trigger refresh + } else if (_locked) { + // Locked: eat all keys except unlock sequence (handled above) + // Any key extends the wake window if display is already on + if (_display != NULL && _display->isOn()) + _lock_wake_until = millis() + 5000; + _next_refresh = 0; + } } userLedHandler(); @@ -1221,7 +1266,49 @@ void UITask::loop() { if (curr) curr->poll(); if (_display != NULL && _display->isOn()) { - if (millis() >= _next_refresh && curr) { + if (_locked && millis() > _lock_wake_until) { + _display->turnOff(); + } else if (_locked && millis() >= _next_refresh) { + _display->startFrame(); + // Lock screen: clock + unlock hint popup + uint32_t unix_ts = rtc_clock.getCurrentTime(); + _display->setColor(DisplayDriver::LIGHT); + if (unix_ts < 1000000000UL) { + _display->setTextSize(1); + _display->drawTextCentered(_display->width() / 2, 20, "No time sync"); + } else { + int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + unix_ts += (int32_t)tz * 3600; + time_t t = (time_t)unix_ts; + struct tm* ti = gmtime(&t); + char buf[12]; + _display->setTextSize(2); + sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + _display->drawTextCentered(_display->width() / 2, 8, buf); + _display->setTextSize(1); + static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); + _display->drawTextCentered(_display->width() / 2, 26, buf); + } + // Hint popup at bottom (like alert style) + _display->setTextSize(1); + const char* hint = _lock_seq == 1 + ? (_lock_seq_count == 0 ? "Enter x3 to unlock" : + _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...") + : "Hold Back + 3xEnter"; + int p = 3; + int hy = _display->height() - 13; + int hw = _display->getTextWidth(hint); + int hx = (_display->width() - hw) / 2; + _display->setColor(DisplayDriver::LIGHT); + _display->fillRect(hx - p, hy - p, hw + p*2, 8 + p*2); + _display->setColor(DisplayDriver::DARK); + _display->setCursor(hx, hy); + _display->print(hint); + _display->endFrame(); + _next_refresh = millis() + 1000; + } else if (!_locked && millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); if (millis() < _alert_expiry && curr == home) { // render alert only on home screen @@ -1240,11 +1327,15 @@ void UITask::loop() { _display->endFrame(); } #if AUTO_OFF_MILLIS > 0 - if (autoOffMillis() > 0 && millis() > _auto_off) { + if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power #endif + if (_node_prefs && _node_prefs->auto_lock) { + _locked = true; + _lock_wake_until = 0; + } } #endif } @@ -1283,9 +1374,17 @@ char UITask::checkDisplayOn(char c) { #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over) #endif + if (_locked) { + _lock_wake_until = millis() + 5000; + _next_refresh = 0; + return 0; // eat the waking key press + } c = 0; } - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer + if (!_locked) { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; // extend auto-off timer + } _next_refresh = 0; // trigger refresh } return c; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index af8f87ad..5c6fadea 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -33,6 +33,11 @@ class UITask : public AbstractUITask { #endif unsigned long _next_refresh, _auto_off; NodePrefs* _node_prefs; + bool _locked; + unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) + int _lock_seq; // 0=idle, 1=active (long-Back pressed, awaiting Enters) + int _lock_seq_count; // Enter presses so far in current sequence + unsigned long _lock_seq_ms; // millis() when sequence started char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; @@ -86,6 +91,9 @@ public: ui_started_at = 0; _batt_mv = 0; _msgcount = _room_unread = 0; + _locked = false; + _lock_wake_until = 0; + _lock_seq = 0; _lock_seq_count = 0; _lock_seq_ms = 0; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); From ef0241b5f3b4fda780fdcb437123e8111b3876cd Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 10:04:28 +0200 Subject: [PATCH 112/183] feat: show two dashboard sensor values on lock screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses dashboard_fields[0] and [1] from NodePrefs — left-aligned and right-aligned in one row under the clock, no labels to save space. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 78 ++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0142e62c..bc932386 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1123,6 +1123,63 @@ bool UITask::isButtonPressed() const { #endif } +static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv) { + val[0] = '\0'; + switch (field) { + case DASH_NONE: return; + case DASH_BATT: + if (batt_mv > 0) snprintf(val, val_len, "%u.%02uV", batt_mv/1000, (batt_mv%1000)/10); + else strcpy(val, "--"); + return; + case DASH_NODES: + snprintf(val, val_len, "%d nodes", the_mesh.getNumContacts()); + return; +#if ENV_INCLUDE_GPS == 1 + case DASH_GPS: { + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) + snprintf(val, val_len, "%.2f %.2f", + loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f); + else strcpy(val, "no fix"); + return; + } +#endif + default: break; + } + // LPP sensor fields: query sensors into a local buffer + uint8_t lpp_type = 0; + switch (field) { + case DASH_TEMP: lpp_type = LPP_TEMPERATURE; break; + case DASH_HUM: lpp_type = LPP_RELATIVE_HUMIDITY; break; + case DASH_PRES: lpp_type = LPP_BAROMETRIC_PRESSURE; break; + case DASH_ALT: lpp_type = LPP_ALTITUDE; break; + case DASH_LUX: lpp_type = LPP_LUMINOSITY; break; + case DASH_CO2: lpp_type = LPP_CONCENTRATION; break; + } + if (lpp_type) { + CayenneLPP lpp(200); + lpp.reset(); + sensors.querySensors(0xFF, lpp); + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + if (type == lpp_type) { + float v; + switch (lpp_type) { + case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, val_len, "%.1f\xf8""C", v); return; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, val_len, "%.0f%%", v); return; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, val_len, "%.0fhPa", v); return; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, val_len, "%.0fm", v); return; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, val_len, "%.0flux", v); return; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, val_len, "%.0fppm", v); return; + } + } + r.skipData(type); + } + strcpy(val, "--"); + } +} + void UITask::loop() { char c = 0; #if UI_HAS_JOYSTICK @@ -1290,6 +1347,27 @@ void UITask::loop() { static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); _display->drawTextCentered(_display->width() / 2, 26, buf); + + // Two sensor values side by side (dashboard_fields[0] and [1]) + if (_node_prefs) { + char v0[20] = "", v1[20] = ""; + formatDashVal(_node_prefs->dashboard_fields[0], v0, sizeof(v0), _batt_mv); + formatDashVal(_node_prefs->dashboard_fields[1], v1, sizeof(v1), _batt_mv); + if (v0[0] || v1[0]) { + _display->setTextSize(1); + _display->setColor(DisplayDriver::LIGHT); + if (v0[0] && v1[0]) { + _display->setCursor(0, 37); + _display->print(v0); + int vw = _display->getTextWidth(v1); + _display->setCursor(_display->width() - vw, 37); + _display->print(v1); + } else { + const char* sv = v0[0] ? v0 : v1; + _display->drawTextCentered(_display->width() / 2, 37, sv); + } + } + } } // Hint popup at bottom (like alert style) _display->setTextSize(1); From 43f3621fdc817542da5cfa35d06f3dc3f5bd4880 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 10:09:54 +0200 Subject: [PATCH 113/183] fix: replace long-press lock trigger with back_btn.isPressed() check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long press detection on back_btn was unreliable — the release generated a spurious CLICK that cancelled the sequence. Now the Enter click handler checks back_btn.isPressed() directly: holding Back and clicking Enter 3× within 3 seconds locks/unlocks the screen. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 71 +++++++++------------- examples/companion_radio/ui-new/UITask.h | 7 +-- 2 files changed, 31 insertions(+), 47 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc932386..35ab5abb 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1185,7 +1185,26 @@ void UITask::loop() { #if UI_HAS_JOYSTICK int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_ENTER); + if (back_btn.isPressed()) { + // Enter clicked while Back is held — lock/unlock sequence + if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset + _lock_seq_count++; + _lock_seq_ms = millis(); + if (_lock_seq_count >= 3) { + _lock_seq_count = 0; + _locked = !_locked; + if (_locked) { + _lock_wake_until = millis() + 2000; + } else { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } + _next_refresh = 0; + } + // eat the Enter — don't pass to curr + } else { + c = checkDisplayOn(KEY_ENTER); + } } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code } @@ -1213,13 +1232,11 @@ void UITask::loop() { } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_CANCEL); - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - if (_display != NULL && _display->isOn()) { - _lock_seq = 1; + if (_lock_seq_count > 0) { + // Back released mid-sequence — cancel sequence, eat the click _lock_seq_count = 0; - _lock_seq_ms = millis(); - _next_refresh = 0; + } else { + c = checkDisplayOn(KEY_CANCEL); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { c = handleTripleClick(KEY_SELECT); @@ -1263,43 +1280,13 @@ void UITask::loop() { } #endif - // Lock sequence timeout check - if (_lock_seq == 1 && millis() - _lock_seq_ms > 3000) { - _lock_seq = 0; - _lock_seq_count = 0; - } - if (c != 0) { - // Intercept Enter presses when a lock/unlock sequence is active - if (_lock_seq == 1 && c == KEY_ENTER) { - _lock_seq_count++; - if (_lock_seq_count >= 3) { - _lock_seq = 0; - _lock_seq_count = 0; - _locked = !_locked; - if (_locked) { - _lock_wake_until = millis() + 2000; // brief "locked" display, then blank - } else { - uint32_t aoff = autoOffMillis(); - if (aoff > 0) _auto_off = millis() + aoff; - } - } - _next_refresh = 0; - } else if (_lock_seq == 1) { - // Non-Enter key cancels the sequence; process key normally below - _lock_seq = 0; - _lock_seq_count = 0; - if (!_locked && curr) { - curr->handleInput(c); - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } - _next_refresh = 100; - } - } else if (!_locked && curr) { + if (!_locked && curr) { curr->handleInput(c); { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 100; // trigger refresh } else if (_locked) { - // Locked: eat all keys except unlock sequence (handled above) + // Locked: eat all keys (unlock sequence handled at button-event level above) // Any key extends the wake window if display is already on if (_display != NULL && _display->isOn()) _lock_wake_until = millis() + 5000; @@ -1371,10 +1358,8 @@ void UITask::loop() { } // Hint popup at bottom (like alert style) _display->setTextSize(1); - const char* hint = _lock_seq == 1 - ? (_lock_seq_count == 0 ? "Enter x3 to unlock" : - _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...") - : "Hold Back + 3xEnter"; + const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" : + _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more..."; int p = 3; int hy = _display->height() - 13; int hw = _display->getTextWidth(hint); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5c6fadea..baa6f0b0 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -35,9 +35,8 @@ class UITask : public AbstractUITask { NodePrefs* _node_prefs; bool _locked; unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) - int _lock_seq; // 0=idle, 1=active (long-Back pressed, awaiting Enters) - int _lock_seq_count; // Enter presses so far in current sequence - unsigned long _lock_seq_ms; // millis() when sequence started + int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) + unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; @@ -93,7 +92,7 @@ public: _msgcount = _room_unread = 0; _locked = false; _lock_wake_until = 0; - _lock_seq = 0; _lock_seq_count = 0; _lock_seq_ms = 0; + _lock_seq_count = 0; _lock_seq_ms = 0; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); From 20e88443b447d40d49cd2bf4d15b6ccd2477732c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 11:17:59 +0200 Subject: [PATCH 114/183] =?UTF-8?q?fix:=20polish=20lock=20screen=20sequenc?= =?UTF-8?q?e=20=E2=80=94=20hints=20update=20instantly,=20screen=20stays=20?= =?UTF-8?q?on,=20triple-Back=20blocked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turn display on at first Back+Enter press so hints are visible even when screen was dark - Extend _lock_wake_until on every sequence step to prevent screen blanking mid-press - Set _next_refresh=0 on every Enter press (not just on completion) for immediate hint redraw - Add _lock_seq_used flag to suppress the Back CLICK that fires on release after unlock - Block triple-Back (buzzer toggle) when screen is locked Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 15 +++++++++++---- examples/companion_radio/ui-new/UITask.h | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 35ab5abb..40f7d775 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1187,19 +1187,25 @@ void UITask::loop() { if (ev == BUTTON_EVENT_CLICK) { if (back_btn.isPressed()) { // Enter clicked while Back is held — lock/unlock sequence + if (_display && !_display->isOn()) { + _display->turnOn(); // turn on display so hints are visible + } + _lock_wake_until = millis() + 5000; // keep display on during sequence if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset _lock_seq_count++; _lock_seq_ms = millis(); + _next_refresh = 0; // update hint immediately on each press if (_lock_seq_count >= 3) { _lock_seq_count = 0; + _lock_seq_used = true; // suppress Back release click _locked = !_locked; if (_locked) { _lock_wake_until = millis() + 2000; } else { + if (_display && !_display->isOn()) _display->turnOn(); uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } - _next_refresh = 0; } // eat the Enter — don't pass to curr } else { @@ -1232,14 +1238,15 @@ void UITask::loop() { } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - if (_lock_seq_count > 0) { - // Back released mid-sequence — cancel sequence, eat the click + if (_lock_seq_count > 0 || _lock_seq_used) { + // Back released mid-sequence or after completing it — cancel/suppress _lock_seq_count = 0; + _lock_seq_used = false; } else { c = checkDisplayOn(KEY_CANCEL); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + if (!_locked) c = handleTripleClick(KEY_SELECT); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index baa6f0b0..29366166 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -37,6 +37,7 @@ class UITask : public AbstractUITask { unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) + bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; @@ -92,7 +93,7 @@ public: _msgcount = _room_unread = 0; _locked = false; _lock_wake_until = 0; - _lock_seq_count = 0; _lock_seq_ms = 0; + _lock_seq_count = 0; _lock_seq_ms = 0; _lock_seq_used = false; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); From 6492fd490ef0508482f8e96302558420179014d4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:01:51 +0200 Subject: [PATCH 115/183] feat: add Czech and Slovak diacritic transliteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds háček and other Czech/Slovak characters (č š ž ř ě ů ď ť ň ľ ĺ ŕ) to the UTF-8 transliteration table so they display as ASCII letters instead of █ blocks. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 229a907a..1bea52fd 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -54,6 +54,19 @@ public: case 0x015B: return 's'; case 0x015A: return 'S'; // ś Ś case 0x017A: return 'z'; case 0x0179: return 'Z'; // ź Ź case 0x017C: return 'z'; case 0x017B: return 'Z'; // ż Ż + // Czech/Slovak + case 0x010D: return 'c'; case 0x010C: return 'C'; // č Č + case 0x0161: return 's'; case 0x0160: return 'S'; // š Š + case 0x017E: return 'z'; case 0x017D: return 'Z'; // ž Ž + case 0x0159: return 'r'; case 0x0158: return 'R'; // ř Ř + case 0x011B: return 'e'; case 0x011A: return 'E'; // ě Ě + case 0x016F: return 'u'; case 0x016E: return 'U'; // ů Ů + case 0x010F: return 'd'; case 0x010E: return 'D'; // ď Ď + case 0x0165: return 't'; case 0x0164: return 'T'; // ť Ť + case 0x0148: return 'n'; case 0x0147: return 'N'; // ň Ň + case 0x013E: return 'l'; case 0x013D: return 'L'; // ľ Ľ + case 0x013A: return 'l'; case 0x0139: return 'L'; // ĺ Ĺ + case 0x0155: return 'r'; case 0x0154: return 'R'; // ŕ Ŕ // German case 0x00E4: return 'a'; case 0x00C4: return 'A'; // ä Ä case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö From b0552e246a6deee47b41cb10615224a6a94c913b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:08:20 +0200 Subject: [PATCH 116/183] feat: extend UTF-8 transliteration to cover all major European languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Scandinavian (å ø æ), Hungarian (ő ű), Romanian (ă ș ț), Croatian (đ), Turkish (ğ ş ı), and Baltic/Lithuanian/Latvian (ā ē ī ū ģ ķ ļ ņ ŗ ų ė į) diacritics. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 1bea52fd..32ef5c71 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -72,6 +72,36 @@ public: case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö case 0x00FC: return 'u'; case 0x00DC: return 'U'; // ü Ü case 0x00DF: return 's'; // ß + // Scandinavian + case 0x00E5: return 'a'; case 0x00C5: return 'A'; // å Å + case 0x00F8: return 'o'; case 0x00D8: return 'O'; // ø Ø + case 0x00E6: return 'a'; case 0x00C6: return 'A'; // æ Æ + // Hungarian + case 0x0151: return 'o'; case 0x0150: return 'O'; // ő Ő + case 0x0171: return 'u'; case 0x0170: return 'U'; // ű Ű + // Romanian + case 0x0103: return 'a'; case 0x0102: return 'A'; // ă Ă + case 0x0219: return 's'; case 0x0218: return 'S'; // ș Ș + case 0x021B: return 't'; case 0x021A: return 'T'; // ț Ț + // Croatian + case 0x0111: return 'd'; case 0x0110: return 'D'; // đ Đ + // Turkish + case 0x011F: return 'g'; case 0x011E: return 'G'; // ğ Ğ + case 0x015F: return 's'; case 0x015E: return 'S'; // ş Ş + case 0x0131: return 'i'; // ı + // Baltic (Lithuanian/Latvian) + case 0x0101: return 'a'; case 0x0100: return 'A'; // ā Ā + case 0x0113: return 'e'; case 0x0112: return 'E'; // ē Ē + case 0x012B: return 'i'; case 0x012A: return 'I'; // ī Ī + case 0x016B: return 'u'; case 0x016A: return 'U'; // ū Ū + case 0x0123: return 'g'; case 0x0122: return 'G'; // ģ Ģ + case 0x0137: return 'k'; case 0x0136: return 'K'; // ķ Ķ + case 0x013C: return 'l'; case 0x013B: return 'L'; // ļ Ļ + case 0x0146: return 'n'; case 0x0145: return 'N'; // ņ Ņ + case 0x0157: return 'r'; case 0x0156: return 'R'; // ŗ Ŗ + case 0x0173: return 'u'; case 0x0172: return 'U'; // ų Ų + case 0x0117: return 'e'; case 0x0116: return 'E'; // ė Ė + case 0x012F: return 'i'; case 0x012E: return 'I'; // į Į // French/Spanish/Portuguese common accents case 0x00E0: case 0x00E1: case 0x00E2: case 0x00E3: return 'a'; case 0x00C0: case 0x00C1: case 0x00C2: case 0x00C3: return 'A'; From 2bc6dae78eec137233e80bcc753d9ede4473f0cf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:11:42 +0200 Subject: [PATCH 117/183] feat: add Icelandic and Romanian legacy cedilla transliteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ð/Ð (eth), þ/Þ (thorn) for Icelandic, and ţ/Ţ (t with cedilla) for legacy-encoded Romanian text. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 32ef5c71..d6cf198b 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -85,6 +85,11 @@ public: case 0x021B: return 't'; case 0x021A: return 'T'; // ț Ț // Croatian case 0x0111: return 'd'; case 0x0110: return 'D'; // đ Đ + // Icelandic + case 0x00F0: return 'd'; case 0x00D0: return 'D'; // ð Ð (eth) + case 0x00FE: return 't'; case 0x00DE: return 'T'; // þ Þ (thorn) + // Romanian cedilla variants (legacy encoding) + case 0x0163: return 't'; case 0x0162: return 'T'; // ţ Ţ // Turkish case 0x011F: return 'g'; case 0x011E: return 'G'; // ğ Ğ case 0x015F: return 's'; case 0x015E: return 'S'; // ş Ş From 3fb0a55aef69d64757db9634570b5a03202a25f6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:17:02 +0200 Subject: [PATCH 118/183] fix: auto-reset _lock_seq_used after 5s in case Back release event is missed Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 40f7d775..7ce0e4d1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1236,6 +1236,9 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_RIGHT); } + if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { + _lock_seq_used = false; // safety reset if Back release event was missed + } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { if (_lock_seq_count > 0 || _lock_seq_used) { From a2856b7a4838d7701b79f0427b0a700084229898 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 14:30:53 +0200 Subject: [PATCH 119/183] fix: lock screen wake window no longer extends on repeated key presses Previously every key press while locked reset _lock_wake_until to now+5s, so multiple presses (or spurious button events) kept extending the window. Now the 5s timer is set only when the display first wakes from dark. The unlock sequence still extends the window on each Back+Enter step. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7ce0e4d1..9fa0f97f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1296,10 +1296,7 @@ void UITask::loop() { { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 100; // trigger refresh } else if (_locked) { - // Locked: eat all keys (unlock sequence handled at button-event level above) - // Any key extends the wake window if display is already on - if (_display != NULL && _display->isOn()) - _lock_wake_until = millis() + 5000; + // Locked: eat all keys — wake window is set only when display first turns on _next_refresh = 0; } } From 360d5d10f7a644afa32d07bf675a135a7641ffae Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 14:33:58 +0200 Subject: [PATCH 120/183] feat: add 30s minimum interval option to auto-advert screen Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/AutoAdvertScreen.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index c4c1dac6..114d0d02 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -7,7 +7,7 @@ class AutoAdvertScreen : public UIScreen { NodePrefs* _prefs; bool _dirty; - static const int OPT_COUNT = 7; + static const int OPT_COUNT = 8; static const uint32_t OPTS[OPT_COUNT]; static const char* OPT_LABELS[OPT_COUNT]; @@ -64,5 +64,5 @@ public: } }; -const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 60, 120, 300, 600, 1800, 3600 }; -const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "off", "1min", "2min", "5min", "10min", "30min", "1h" }; +const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 30, 60, 120, 300, 600, 1800, 3600 }; +const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "off", "30s", "1min", "2min", "5min", "10min", "30min", "1h" }; From 28d69f88f4faa62b5d2bb6aee1dee54d458148d2 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 18:51:48 +0200 Subject: [PATCH 121/183] Add Discord discussion link to README Added a link to the discussion channel on Discord. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2d7709de..ab78069f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1**. +Join the discussion: https://discord.com/channels/1495203904898728149/1505294337884553447 + ## New Features ### Messages Screen From 44122565f6f276d6a4864086a4914cce9081677e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 18:32:49 +0200 Subject: [PATCH 122/183] perf: query sensors once for both lock screen dashboard fields Previously each LPP sensor field called querySensors() separately. Now a single querySensors() call fills a shared buffer used by both fields. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 25 +++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9fa0f97f..456ebc1d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1123,7 +1123,8 @@ bool UITask::isButtonPressed() const { #endif } -static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv) { +static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, + CayenneLPP* lpp = nullptr) { val[0] = '\0'; switch (field) { case DASH_NONE: return; @@ -1146,7 +1147,7 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m #endif default: break; } - // LPP sensor fields: query sensors into a local buffer + // LPP sensor fields uint8_t lpp_type = 0; switch (field) { case DASH_TEMP: lpp_type = LPP_TEMPERATURE; break; @@ -1157,10 +1158,9 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m case DASH_CO2: lpp_type = LPP_CONCENTRATION; break; } if (lpp_type) { - CayenneLPP lpp(200); - lpp.reset(); - sensors.querySensors(0xFF, lpp); - LPPReader r(lpp.getBuffer(), lpp.getSize()); + CayenneLPP local_lpp(200); + if (!lpp) { local_lpp.reset(); sensors.querySensors(0xFF, local_lpp); lpp = &local_lpp; } + LPPReader r(lpp->getBuffer(), lpp->getSize()); uint8_t ch, type; while (r.readHeader(ch, type)) { if (type == lpp_type) { @@ -1345,8 +1345,17 @@ void UITask::loop() { // Two sensor values side by side (dashboard_fields[0] and [1]) if (_node_prefs) { char v0[20] = "", v1[20] = ""; - formatDashVal(_node_prefs->dashboard_fields[0], v0, sizeof(v0), _batt_mv); - formatDashVal(_node_prefs->dashboard_fields[1], v1, sizeof(v1), _batt_mv); + CayenneLPP shared_lpp(200); + CayenneLPP* lpp_ptr = nullptr; + uint8_t f0 = _node_prefs->dashboard_fields[0], f1 = _node_prefs->dashboard_fields[1]; + auto isLPP = [](uint8_t f) { + return f==DASH_TEMP||f==DASH_HUM||f==DASH_PRES||f==DASH_ALT||f==DASH_LUX||f==DASH_CO2; + }; + if (isLPP(f0) || isLPP(f1)) { + shared_lpp.reset(); sensors.querySensors(0xFF, shared_lpp); lpp_ptr = &shared_lpp; + } + formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr); + formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr); if (v0[0] || v1[0]) { _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); From a5a32530f059b656fc63baa35f125884f40b17c3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 18:55:21 +0200 Subject: [PATCH 123/183] Update Discord link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab78069f..907d8a92 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1**. -Join the discussion: https://discord.com/channels/1495203904898728149/1505294337884553447 +Join the discussion on offical MeshCore discord: https://discord.gg/sdhYArU2jr ## New Features From 4df97ae5ed568b01ae1328d964b1580ce93f4825 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 09:39:10 +0200 Subject: [PATCH 124/183] fix: persist auto_lock setting across reboots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto_lock was defined in NodePrefs but missing from both loadPrefsInt and savePrefs in DataStore.cpp. Added at the end of the serialization chain (inside if(file.available())) for backwards compatibility with existing saved files — old files default to auto_lock=0 (disabled). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e3e8657c..d9e0d10c 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -283,6 +283,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); if (file.available()) { file.read((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + if (file.available()) { + file.read((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); + } } } } @@ -371,6 +374,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); file.close(); } From 584ef33b3e610b61788f68d4feb84bb6cc7ccd4c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 09:41:56 +0200 Subject: [PATCH 125/183] fix: channel bot respects bot_enabled flag tryBotReplyChannel checked bot_channel_enabled but not bot_enabled, so disabling the bot via the main Enable toggle had no effect on channel replies. Added bot_enabled to the guard condition. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 5c892164..d75af84e 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -35,7 +35,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { } void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { - if (!(_prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && + if (!(_prefs.bot_enabled && _prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && channel_idx == _prefs.bot_channel_idx && millis() - _bot_last_ch_reply_ms > 10000UL)) return; From 9b61bc30280c6647d48d934feb34045bbcb8347e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 09:53:36 +0200 Subject: [PATCH 126/183] fix: increase quick-message expansion buffer from 80 to 140 bytes custom_msgs templates are up to 140 chars; the 80-byte msg[] buffer silently truncated any message longer than 79 characters before sending. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/QuickMsgScreen.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index a5b4b2ba..e2230605 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1059,7 +1059,7 @@ public: NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[_msg_sel - 1]; const char* tmpl = p ? p->custom_msgs[slot] : "OK"; - char msg[80]; + char msg[140]; expandMsg(tmpl, msg, sizeof(msg)); bool ok = sendText(msg); afterSend(ok, msg); From 8f566732ed4dde44f4bf62373696c3197419a11c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 15:00:01 +0200 Subject: [PATCH 127/183] feat: show @recipient as 'To: nick' bar in fullscreen message view When a message starts with @nick, the fullscreen view expands the header to show a second row 'To: nick' below the sender name, and displays the message body without the @nick prefix. Co-Authored-By: Claude Sonnet 4.6 --- .../ui-new/FullscreenMsgView.h | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index a5bfe99f..969b801f 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -42,35 +42,59 @@ struct FullscreenMsgView { int render(DisplayDriver& display, const char* sender, const char* text, bool has_prev, bool has_next) { display.setTextSize(1); + + // detect @recipient at start of message + char to_nick[32] = ""; + const char* body = text; + if (text[0] == '@') { + const char* sp = strchr(text, ' '); + if (sp && sp[1]) { + int len = (int)(sp - text) - 1; + if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1; + memcpy(to_nick, text + 1, len); + to_nick[len] = '\0'; + body = sp + 1; + } + } + + const int header_h = to_nick[0] ? 20 : 10; + const int startY = header_h + 2; + const int visible = (display.height() - startY - FS_LINE_H) / FS_LINE_H; + display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), 10); + display.fillRect(0, 0, display.width(), header_h); display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(2, 1, display.width() - 4, sender); + if (to_nick[0]) { + char to_label[36]; + snprintf(to_label, sizeof(to_label), "To: %s", to_nick); + display.drawTextEllipsized(2, 11, display.width() - 4, to_label); + } display.setColor(DisplayDriver::LIGHT); char trans_text[512]; - display.translateUTF8ToBlocks(trans_text, text, sizeof(trans_text)); + display.translateUTF8ToBlocks(trans_text, body, sizeof(trans_text)); char lines[12][FS_CHARS + 1]; int lcount = wrapLines(trans_text, lines, 12); - int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; + int max_scroll = lcount > visible ? lcount - visible : 0; if (scroll > max_scroll) scroll = max_scroll; - for (int i = 0; i < FS_VISIBLE && (scroll + i) < lcount; i++) { - display.setCursor(0, FS_START_Y + i * FS_LINE_H); + for (int i = 0; i < visible && (scroll + i) < lcount; i++) { + display.setCursor(0, startY + i * FS_LINE_H); display.print(lines[scroll + i]); } if (scroll > 0) { display.setColor(DisplayDriver::DARK); - display.fillRect(display.width() - 6, FS_START_Y, 6, FS_LINE_H); + display.fillRect(display.width() - 6, startY, 6, FS_LINE_H); display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, FS_START_Y); + display.setCursor(display.width() - 6, startY); display.print("^"); } if (scroll < max_scroll) { display.setColor(DisplayDriver::DARK); - display.fillRect(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H, 6, FS_LINE_H); + display.fillRect(display.width() - 6, startY + (visible - 1) * FS_LINE_H, 6, FS_LINE_H); display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); + display.setCursor(display.width() - 6, startY + (visible - 1) * FS_LINE_H); display.print("v"); } if (has_next) { From 6f7de3ed204d4dab209c9e2fa3a374f770b6f910 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 15:12:11 +0200 Subject: [PATCH 128/183] feat: add reply action to channel and DM message history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-press Enter (KEY_CONTEXT_MENU) on a selected message shows an 'Options / Reply' popup in both list and fullscreen views. Confirming opens MSG_PICK with 'RE: nick' title — the reply can be sent via keyboard or quick message templates, both automatically prefixed with '@nick '. - Channel: reply prefix extracted from 'sender: message' format - DM: reply available only on incoming messages, uses contact name - FullscreenMsgView: REPLY added to Result enum, KEY_CONTEXT_MENU handled - MSG_PICK: _reply_mode flag routes prefix through keyboard and templates - Guard: prevent sending empty reply (prefix-only keyboard input ignored) Co-Authored-By: Claude Sonnet 4.6 --- .../ui-new/FullscreenMsgView.h | 11 +- .../companion_radio/ui-new/QuickMsgScreen.h | 112 +++++++++++++++++- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 969b801f..99582f12 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -14,7 +14,7 @@ struct FullscreenMsgView { FullscreenMsgView() : scroll(0), active(false) {} - enum Result { NONE, PREV, NEXT, CLOSE }; + enum Result { NONE, PREV, NEXT, CLOSE, REPLY }; void begin() { scroll = 0; active = true; } @@ -109,10 +109,11 @@ struct FullscreenMsgView { } Result handleInput(char c) { - if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } - if (c == KEY_DOWN) { scroll++; return NONE; } - if (c == KEY_LEFT) return NEXT; - if (c == KEY_RIGHT) return PREV; + if (c == KEY_UP) { if (scroll > 0) scroll--; return NONE; } + if (c == KEY_DOWN) { scroll++; return NONE; } + if (c == KEY_LEFT) return NEXT; + if (c == KEY_RIGHT) return PREV; + if (c == KEY_CONTEXT_MENU) return REPLY; if (c == KEY_ENTER || c == KEY_CANCEL) return CLOSE; return NONE; } diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index e2230605..65803a0a 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -41,11 +41,13 @@ class QuickMsgScreen : public UIScreen { // KEYBOARD KeyboardWidget _kb; - // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) + // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK / histories) PopupMenu _ctx_menu; bool _ctx_dirty; char _ctx_notif_item[22]; char _ctx_melody_item[20]; + char _reply_prefix[36]; // "@nick " built when reply is triggered + bool _reply_mode; // true while composing a reply (prefix is prepended) struct ChHistEntry { uint8_t ch_idx; char text[140]; }; ChHistEntry _hist[CH_HIST_MAX]; @@ -86,6 +88,25 @@ class QuickMsgScreen : public UIScreen { &sensors, batt); } + // Build "@nick " prefix from a channel message text ("nick: body") into _reply_prefix. + // Returns false if sender is "Me" (own message — no reply prefix needed). + bool buildChannelReplyPrefix(const char* text) { + const char* sep = strstr(text, ": "); + if (!sep) return false; + int slen = (int)(sep - text); + if (slen == 2 && strncmp(text, "Me", 2) == 0) return false; + if (slen > 32) slen = 32; + snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.*s ", slen, text); + return true; + } + + void startReply(bool to_channel) { + _sending_to_channel = to_channel; + _reply_mode = true; + setupMsgPick(); + _phase = MSG_PICK; + } + void setupMsgPick() { _msg_sel = _msg_scroll = 0; _active_msg_count = 0; @@ -167,6 +188,7 @@ class QuickMsgScreen : public UIScreen { } void afterSend(bool ok, const char* msg) { + _reply_mode = false; if (ok && _sending_to_channel) { _hist_sel = 0; _hist_scroll = 0; @@ -361,7 +383,7 @@ public: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0), _dm_hist_sel(-1), _dm_hist_scroll(0), - _ctx_dirty(false) { + _ctx_dirty(false), _reply_mode(false) { memset(_ch_unread, 0, sizeof(_ch_unread)); } @@ -751,7 +773,9 @@ public: } else { // MSG_PICK char title[24]; - if (_sending_to_channel) { + if (_reply_mode) { + snprintf(title, sizeof(title), "RE:%.20s", _reply_prefix + 1); // skip '@' + } else if (_sending_to_channel) { ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); snprintf(title, sizeof(title), "%.23s", ch.name); @@ -938,6 +962,16 @@ public: } else if (_phase == DM_HIST) { int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); if (_dm_fs.active) { + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + _dm_fs.active = false; + startReply(false); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; + } + return true; + } auto res = _dm_fs.handleInput(c); if (res == FullscreenMsgView::PREV) { if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } @@ -945,6 +979,22 @@ public: if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } } else if (res == FullscreenMsgView::CLOSE) { _dm_fs.active = false; + } else if (res == FullscreenMsgView::REPLY) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) { + snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.32s ", _sel_contact.name); + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + } + return true; + } + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + startReply(false); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; } return true; } @@ -977,10 +1027,29 @@ public: } return true; } + if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) { + snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.32s ", _sel_contact.name); + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + return true; + } } else if (_phase == CHANNEL_HIST) { int ch_hist_count = histCountForChannel(_sel_channel_idx); if (_fs.active) { + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + _fs.active = false; + startReply(true); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; + } + return true; + } auto res = _fs.handleInput(c); if (res == FullscreenMsgView::PREV) { if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } @@ -988,6 +1057,21 @@ public: if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } } else if (res == FullscreenMsgView::CLOSE) { _fs.active = false; + } else if (res == FullscreenMsgView::REPLY) { + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) { + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + } + return true; + } + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED) { + startReply(true); + } else if (res != PopupMenu::NONE) { + _ctx_menu.active = false; } return true; } @@ -1018,13 +1102,22 @@ public: } return true; } + if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0 && buildChannelReplyPrefix(_hist[ring_pos].text)) { + _ctx_menu.begin("Options", 1); + _ctx_menu.addItem("Reply"); + } + return true; + } } else if (_phase == KEYBOARD) { auto res = _kb.handleInput(c); if (res == KeyboardWidget::CANCELLED) { _phase = MSG_PICK; } else if (res == KeyboardWidget::DONE) { - if (_kb.len > 0) { + int min_len = _reply_mode ? (int)strlen(_reply_prefix) : 0; + if (_kb.len > min_len) { char expanded[KB_MAX_LEN + 1]; expandMsg(_kb.buf, expanded, sizeof(expanded)); bool ok = sendText(expanded); @@ -1036,6 +1129,7 @@ public: } else { // MSG_PICK int total_msg_items = 1 + _active_msg_count; if (c == KEY_CANCEL) { + _reply_mode = false; _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; return true; } @@ -1051,7 +1145,7 @@ public: } if (c == KEY_ENTER) { if (_msg_sel == 0) { - _kb.begin(); + _kb.begin(_reply_mode ? _reply_prefix : ""); kbAddSensorPlaceholders(_kb, &sensors); _phase = KEYBOARD; return true; @@ -1060,7 +1154,13 @@ public: int slot = _active_msgs[_msg_sel - 1]; const char* tmpl = p ? p->custom_msgs[slot] : "OK"; char msg[140]; - expandMsg(tmpl, msg, sizeof(msg)); + if (_reply_mode) { + char body[140]; + expandMsg(tmpl, body, sizeof(body)); + snprintf(msg, sizeof(msg), "%s%s", _reply_prefix, body); + } else { + expandMsg(tmpl, msg, sizeof(msg)); + } bool ok = sendText(msg); afterSend(ok, msg); return true; From b21bcb5ca4a50d2183a469b7412ea60209df933d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 16:11:25 +0200 Subject: [PATCH 129/183] fix: render reply popup in CHANNEL_HIST and DM_HIST phases _ctx_menu.render() was only called in CONTACT_PICK and CHANNEL_PICK, so the reply popup was active but invisible in history views. Added render calls for list view and fullscreen view in both DM_HIST and CHANNEL_HIST phases. Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/QuickMsgScreen.h | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 65803a0a..f2b61aa2 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -593,9 +593,11 @@ public: const DmHistEntry& e = _dm_hist[ring_pos]; const char* sender = e.outgoing ? "Me" : filtered_name; int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - return _dm_fs.render(display, sender, e.text, - _dm_hist_sel < dm_count - 1, - _dm_hist_sel > 0); + int ret = _dm_fs.render(display, sender, e.text, + _dm_hist_sel < dm_count - 1, + _dm_hist_sel > 0); + if (_ctx_menu.active) _ctx_menu.render(display); + return ret; } return 500; } @@ -662,6 +664,7 @@ public: display.setCursor(cbx + 2, cby); display.print(ctxt); display.setColor(DisplayDriver::LIGHT); + if (_ctx_menu.active) _ctx_menu.render(display); return dm_count > 0 ? 500 : 2000; } else if (_phase == CHANNEL_HIST) { @@ -680,9 +683,11 @@ public: strncpy(fsender, "?", sizeof(fsender)); strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; } - return _fs.render(display, fsender, fmsg, - _hist_sel < fs_hist_count - 1, - _hist_sel > 0); + int ret = _fs.render(display, fsender, fmsg, + _hist_sel < fs_hist_count - 1, + _hist_sel > 0); + if (_ctx_menu.active) _ctx_menu.render(display); + return ret; } return 2000; } @@ -767,6 +772,7 @@ public: display.setCursor(cbx + 1, cby); display.print(ctxt); display.setColor(DisplayDriver::LIGHT); + if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == KEYBOARD) { return _kb.render(display); From 34cfd27b8a1f2953a62366f3185c296a0688e377 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 18:18:45 +0200 Subject: [PATCH 130/183] fix: use @[nick] format for replies and fix fullscreen message display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reply prefix changed to "@[nick] " format — bracket delimiter handles nicks with spaces unambiguously - FullscreenMsgView: parse "@[nick] body" to show "To: nick" header; fall back gracefully if format not matched - Reduce FS_CHARS 21→20 to prevent scroll arrows overlapping last char - Transliterate nick before displaying in "To:" header and "RE:" title so non-ASCII characters render correctly on the OLED font Co-Authored-By: Claude Sonnet 4.6 --- .../ui-new/FullscreenMsgView.h | 19 ++++++++++--------- .../companion_radio/ui-new/QuickMsgScreen.h | 17 ++++++++++++----- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 99582f12..ae32a24a 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -3,7 +3,7 @@ #include #include -static const int FS_CHARS = 21; +static const int FS_CHARS = 20; static const int FS_LINE_H = 8; static const int FS_START_Y = 12; static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; @@ -46,14 +46,14 @@ struct FullscreenMsgView { // detect @recipient at start of message char to_nick[32] = ""; const char* body = text; - if (text[0] == '@') { - const char* sp = strchr(text, ' '); - if (sp && sp[1]) { - int len = (int)(sp - text) - 1; + if (text[0] == '@' && text[1] == '[') { + const char* close = strchr(text + 2, ']'); + if (close && close[1] == ' ' && close[2]) { + int len = (int)(close - text) - 2; if (len >= (int)sizeof(to_nick)) len = sizeof(to_nick) - 1; - memcpy(to_nick, text + 1, len); + memcpy(to_nick, text + 2, len); to_nick[len] = '\0'; - body = sp + 1; + body = close + 2; } } @@ -66,8 +66,9 @@ struct FullscreenMsgView { display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(2, 1, display.width() - 4, sender); if (to_nick[0]) { - char to_label[36]; - snprintf(to_label, sizeof(to_label), "To: %s", to_nick); + char trans_nick[32], to_label[36]; + display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick)); + snprintf(to_label, sizeof(to_label), "To: %s", trans_nick); display.drawTextEllipsized(2, 11, display.width() - 4, to_label); } display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index f2b61aa2..13632482 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -46,7 +46,7 @@ class QuickMsgScreen : public UIScreen { bool _ctx_dirty; char _ctx_notif_item[22]; char _ctx_melody_item[20]; - char _reply_prefix[36]; // "@nick " built when reply is triggered + char _reply_prefix[36]; // "@[nick] " built when reply is triggered bool _reply_mode; // true while composing a reply (prefix is prepended) struct ChHistEntry { uint8_t ch_idx; char text[140]; }; @@ -96,7 +96,8 @@ class QuickMsgScreen : public UIScreen { int slen = (int)(sep - text); if (slen == 2 && strncmp(text, "Me", 2) == 0) return false; if (slen > 32) slen = 32; - snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.*s ", slen, text); + if (slen > 31) slen = 31; + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.*s] ", slen, text); return true; } @@ -780,7 +781,13 @@ public: } else { // MSG_PICK char title[24]; if (_reply_mode) { - snprintf(title, sizeof(title), "RE:%.20s", _reply_prefix + 1); // skip '@' + int rlen = (int)strlen(_reply_prefix) - 4; // exclude "@[" and "] " + if (rlen < 0) rlen = 0; + if (rlen > 20) rlen = 20; + char nick_raw[32], nick_trans[32]; + snprintf(nick_raw, sizeof(nick_raw), "%.*s", rlen, _reply_prefix + 2); + display.translateUTF8ToBlocks(nick_trans, nick_raw, sizeof(nick_trans)); + snprintf(title, sizeof(title), "RE:%s", nick_trans); } else if (_sending_to_channel) { ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); @@ -988,7 +995,7 @@ public: } else if (res == FullscreenMsgView::REPLY) { int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) { - snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.32s ", _sel_contact.name); + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _sel_contact.name); _ctx_menu.begin("Options", 1); _ctx_menu.addItem("Reply"); } @@ -1036,7 +1043,7 @@ public: if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); if (ring_pos >= 0 && !_dm_hist[ring_pos].outgoing) { - snprintf(_reply_prefix, sizeof(_reply_prefix), "@%.32s ", _sel_contact.name); + snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _sel_contact.name); _ctx_menu.begin("Options", 1); _ctx_menu.addItem("Reply"); } From 0f5740f6b61877138f93ba628e7cb669e64a27eb Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 18:20:42 +0200 Subject: [PATCH 131/183] fix: remove redundant slen clamp and update stale comment Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/QuickMsgScreen.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 13632482..ba7ec29e 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -88,14 +88,13 @@ class QuickMsgScreen : public UIScreen { &sensors, batt); } - // Build "@nick " prefix from a channel message text ("nick: body") into _reply_prefix. + // Build "@[nick] " prefix from a channel message text ("nick: body") into _reply_prefix. // Returns false if sender is "Me" (own message — no reply prefix needed). bool buildChannelReplyPrefix(const char* text) { const char* sep = strstr(text, ": "); if (!sep) return false; int slen = (int)(sep - text); if (slen == 2 && strncmp(text, "Me", 2) == 0) return false; - if (slen > 32) slen = 32; if (slen > 31) slen = 31; snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.*s] ", slen, text); return true; From 02fd79d5675a595313222ecb412c7a4b6780f0db Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 22:33:04 +0200 Subject: [PATCH 132/183] docs: update README with reply feature and screen lock Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 907d8a92..0fb15770 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Join the discussion on offical MeshCore discord: https://discord.gg/sdhYArU2jr View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are always available; additional placeholders (`{temp}`, `{hum}`, `{pres}`, `{batt}`, `{alt}`, `{lux}`, `{co2}`) appear automatically for sensors that are connected and returning data. -Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). +Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar is shown below the sender name and the body is displayed without the address prefix. -Hold Enter on a message or channel to open a context menu: change per-channel notification settings (mute, follow global, or force-on) and per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. +Hold Enter on a message to open a context menu. From the list or fullscreen view, select **Reply** to pre-fill the keyboard or a quick message with `@[nick]` so the recipient is clearly addressed. On channel or contact list entries, the context menu also lets you change per-channel notification settings (mute, follow global, or force-on), per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. ### Settings Screen @@ -34,6 +34,7 @@ All settings are saved to flash and restored on next boot. - **System** - Timezone (UTC offset in hours) - Low battery shutdown threshold + - Auto-lock — automatically locks the device when the display turns off - **GPS** - Position broadcast interval - **Contacts** @@ -48,6 +49,15 @@ A dedicated clock page on the home screen shows the current time and date, synch Up to three configurable data fields are displayed below the clock. Available fields: battery voltage, temperature, humidity, pressure, GPS coordinates, altitude, luminosity, CO₂, contact count, and total unread message count. +### Screen Lock + +Hold **Back** and press **Enter** three times to lock or unlock the device. While locked: + +- The display turns off and ignores incoming keypresses +- A brief button press shows the lock screen: current time, date, and up to two sensor values (reuses the Dashboard Config fields) +- A hint popup guides through the unlock sequence +- **Auto-lock** (configurable in Settings → System) locks automatically when the display turns off + ### Nearby Nodes Browse nodes that have recently advertised on the mesh. Filter by category (Favourites, All, Companion, Repeater, Room, Sensor). Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. From d9d4315c5d57a55968b80a4fa0efa5da0f4e2b9c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 10:44:25 +0200 Subject: [PATCH 133/183] ci: build lemon-font firmware alongside standard on wio-tracker-v* tags Add build-lemon-font job that checks out the font-experiments branch and builds with FIRMWARE_VERSION suffixed with -lemon-font, producing files like WioTrackerL1_*-v1.15-plus.1.7-lemon-font-SHA.uf2. A separate release job collects artifacts from both builds into one draft release. Tag pattern narrowed from wio-tracker-* to wio-tracker-v* to avoid triggering on wio-tracker-lemon-* tags. Co-Authored-By: Claude Sonnet 4.6 --- .../build-wio-tracker-l1-firmwares.yml | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index 195c8dc9..bc1bc675 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -7,11 +7,11 @@ on: workflow_dispatch: push: tags: - - 'wio-tracker-*' + - 'wio-tracker-v*' jobs: - build: + build-standard: runs-on: ubuntu-latest steps: @@ -32,9 +32,52 @@ jobs: name: wio-tracker-l1-firmwares path: out + build-lemon-font: + runs-on: ubuntu-latest + steps: + + - name: Clone Repo (font-experiments branch) + uses: actions/checkout@v4 + with: + ref: font-experiments + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Lemon Font Firmwares + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}-lemon-font + run: /usr/bin/env bash build.sh build-wio-tracker-l1-firmwares + + - name: Upload Workflow Artifacts + uses: actions/upload-artifact@v4 + with: + name: wio-tracker-l1-lemon-font-firmwares + path: out + + release: + needs: [build-standard, build-lemon-font] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + + - name: Extract Version from Git Tag + run: echo "GIT_TAG_VERSION=v${GITHUB_REF_NAME#*-v}" >> $GITHUB_ENV + + - name: Download Standard Firmwares + uses: actions/download-artifact@v4 + with: + name: wio-tracker-l1-firmwares + path: out + + - name: Download Lemon Font Firmwares + uses: actions/download-artifact@v4 + with: + name: wio-tracker-l1-lemon-font-firmwares + path: out + - name: Create Release uses: softprops/action-gh-release@v2 - if: startsWith(github.ref, 'refs/tags/') with: name: Wio Tracker L1 Firmware ${{ env.GIT_TAG_VERSION }} body: "" From 469b03ddf4079c323a10ce0ef6d425b03d191923 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 13:56:25 +0200 Subject: [PATCH 134/183] ci: update branch reference from font-experiments to font-lemon Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-wio-tracker-l1-firmwares.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index bc1bc675..331a908f 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -39,7 +39,7 @@ jobs: - name: Clone Repo (font-experiments branch) uses: actions/checkout@v4 with: - ref: font-experiments + ref: font-lemon - name: Setup Build Environment uses: ./.github/actions/setup-build-environment From 9a8f324a634f4f1ee524ce687c54651c540cc66c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 14:07:20 +0200 Subject: [PATCH 135/183] docs: add Lemon font build section to README Describes the font-lemon branch variant with native Unicode rendering, pixel-accurate word wrap, and a link to the Lemon font source. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 0fb15770..326105cd 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,18 @@ Automatically replies to incoming messages that contain a configured trigger wor --- +## Lemon Font Build + +An alternative firmware build is available on the `font-lemon` branch. It replaces the default 5×7 Adafruit font with the [Lemon bitmap font](https://github.com/cmvnd/fonts), which offers: + +- **Native Unicode rendering** — Latin Extended, Greek, and Cyrillic characters (U+0020–U+04FF) are displayed directly without transliteration, covering Polish, Czech, Slovak, German, French, Scandinavian, Hungarian, Romanian, Croatian, Turkish, Baltic, Russian, and Greek scripts. +- **Pixel-accurate word wrap** — text wraps based on actual glyph widths rather than a fixed character count. +- **Slightly taller glyphs** — the font uses a 10 px line height compared to 8 px for the default font. + +Prebuilt `.uf2` files for the Lemon font variant are included in each release alongside the standard builds and are identified by `lemon-font` in the filename. + +--- + Feel free to explore, share feedback and feature requests! ## Development From eee496b60bf5c3d98a7c29d0e8501514575b8a5f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 17:44:49 +0200 Subject: [PATCH 136/183] fix: add missing space after Type: and Seen: labels in NearbyScreen detail view Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/NearbyScreen.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 5cbac566..a3432ba9 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -228,12 +228,12 @@ public: display.setCursor(2, 38); display.print("Az: unknown"); } - snprintf(buf, sizeof(buf), "Type:%s", typeName(e.type)); + snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); display.setCursor(2, 47); display.print(buf); char age[16]; fmtAge(age, sizeof(age), e.lastmod); - snprintf(buf, sizeof(buf), "Seen:%s", age); + snprintf(buf, sizeof(buf), "Seen: %s", age); display.setCursor(2, 56); display.print(buf); return 2000; From fa194016d77ca1ca1715ab00ffcd47d191625a29 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 20:21:32 +0200 Subject: [PATCH 137/183] feat: show message age (3m, 2h, >1d) in channel and DM history list Stores RTC timestamp on each history entry and renders it right-aligned on the sender row using a compact format (Xs/Xm/Xh/>1d). Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/QuickMsgScreen.h | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index ba7ec29e..002895cf 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -49,12 +49,12 @@ class QuickMsgScreen : public UIScreen { char _reply_prefix[36]; // "@[nick] " built when reply is triggered bool _reply_mode; // true while composing a reply (prefix is prepended) - struct ChHistEntry { uint8_t ch_idx; char text[140]; }; + struct ChHistEntry { uint8_t ch_idx; char text[140]; uint32_t timestamp; }; ChHistEntry _hist[CH_HIST_MAX]; int _hist_head, _hist_count; // DM_HIST - struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; + struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; uint32_t timestamp; }; static const int DM_HIST_MAX = 64; DmHistEntry _dm_hist[DM_HIST_MAX]; int _dm_hist_head, _dm_hist_count; @@ -131,6 +131,16 @@ class QuickMsgScreen : public UIScreen { } } + static void fmtMsgAge(char* buf, int n, uint32_t timestamp) { + uint32_t now = rtc_clock.getCurrentTime(); + if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; } + uint32_t age = now - timestamp; + if (age < 60) snprintf(buf, n, "%us", age); + else if (age < 3600) snprintf(buf, n, "%um", age / 60); + else if (age < 86400) snprintf(buf, n, "%uh", age / 3600); + else snprintf(buf, n, ">1d"); + } + // count history entries for a specific channel int histCountForChannel(int ch_idx) const { int n = 0; @@ -164,6 +174,7 @@ class QuickMsgScreen : public UIScreen { } memcpy(_dm_hist[pos].prefix, pub_key, 4); _dm_hist[pos].outgoing = outgoing ? 1 : 0; + _dm_hist[pos].timestamp = rtc_clock.getCurrentTime(); strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; } @@ -397,6 +408,7 @@ public: _hist_head = (_hist_head + 1) % CH_HIST_MAX; } _hist[pos].ch_idx = ch_idx; + _hist[pos].timestamp = rtc_clock.getCurrentTime(); strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; @@ -621,18 +633,23 @@ public: const DmHistEntry& e = _dm_hist[ring_pos]; const char* sender = e.outgoing ? "Me" : filtered_name; + char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp); + int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; + if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y, display.width(), HIST_BOX_H); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, y, display.width(), HIST_BOX_H); display.fillRect(1, y + 1, display.width() - 2, 8); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.setColor(DisplayDriver::LIGHT); display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); } @@ -723,18 +740,23 @@ public: msg_part[sizeof(msg_part) - 1] = '\0'; } + char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp); + int age_w = age[0] ? display.getTextWidth(age) + 3 : 0; + if (sel) { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, y, display.width(), HIST_BOX_H); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, y, display.width(), HIST_BOX_H); display.fillRect(1, y + 1, display.width() - 2, 8); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.setColor(DisplayDriver::LIGHT); display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); } From b19ab7f4265ddd56eaa6160cb82622c64ad64111 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 20:37:56 +0200 Subject: [PATCH 138/183] docs: add ASCII screen mockups to README feature sections Co-Authored-By: Claude Sonnet 4.6 --- README.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 326105cd..de3d3cb4 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,52 @@ Join the discussion on offical MeshCore discord: https://discord.gg/sdhYArU2jr View and send messages using the on-screen keyboard or predefined quick replies. The keyboard supports placeholders that insert live sensor data — `{time}` and `{loc}` are always available; additional placeholders (`{temp}`, `{hum}`, `{pres}`, `{batt}`, `{alt}`, `{lux}`, `{co2}`) appear automatically for sensors that are connected and returning data. +Each message in the history list shows the sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner of the entry. + +``` +╔══════════════════════════════╗ +║ #general ║ +╠══════════════════════════════╣ +║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ ← selected +║ Alice 3m ║ +║ Hey, let's meet tomorrow ║ +║┌───────────────────────────┐║ +║│▓▓▓▓▓▓▓▓▓ Bob 1h ▓▓▓▓│║ +║│ Sure, what time works? │║ +║└───────────────────────────┘║ +║[+ send] ║ +╚══════════════════════════════╝ +``` + Press Enter on a message to open it in fullscreen. Navigate between messages with left (newer) and right (older). If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar is shown below the sender name and the body is displayed without the address prefix. -Hold Enter on a message to open a context menu. From the list or fullscreen view, select **Reply** to pre-fill the keyboard or a quick message with `@[nick]` so the recipient is clearly addressed. On channel or contact list entries, the context menu also lets you change per-channel notification settings (mute, follow global, or force-on), per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. +``` +╔══════════════════════════════╗ +║▓▓ Alice ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ ← sender +║▓▓ To: Bob ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ ← recipient +╠══════════════════════════════╣ +║ Hey Bob, let's meet up ║ +║ tomorrow at 6pm downtown. ║ +║ Will you make it? ║ +║ ║ +║< newer older >║ +╚══════════════════════════════╝ +``` + +Hold Enter on a message to open a context menu. From the list or fullscreen view, select **Reply** to pre-fill the keyboard or a quick message with `@[nick]` so the recipient is clearly addressed. The reply picker title shows the recipient's name. + +``` +╔══════════════════════════════╗ +║ RE:Alice ║ +╠══════════════════════════════╣ +║> Custom message... ║ +║ OK, I understand ║ +║ On my way ║ +║ Be right there ║ +╚══════════════════════════════╝ +``` + +On channel or contact list entries, the context menu also lets you change per-channel notification settings (mute, follow global, or force-on), per-channel melody override (follow global, Melody 1, or Melody 2), or mark messages as read. ### Settings Screen @@ -62,6 +105,19 @@ Hold **Back** and press **Enter** three times to lock or unlock the device. Whil Browse nodes that have recently advertised on the mesh. Filter by category (Favourites, All, Companion, Repeater, Room, Sensor). Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. +``` +╔══════════════════════════════╗ +║▓▓▓▓▓ WioTracker-Alice ▓▓▓▓▓▓║ ← node name +╠══════════════════════════════╣ +║ Lat: 50.06190 ║ +║ Lon: 19.94090 ║ +║ Dist: 2.3km ║ +║ Az: 145d (SE) ║ +║ Type: Companion ║ +║ Seen: 5m ago ║ +╚══════════════════════════════╝ +``` + Use **Send my advert** (hold Enter → context menu) to broadcast your position and prompt nearby nodes to respond with theirs. ### Tools Screen From 31e91bda2143fe433489e7aadce2e6e5e49511a6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 19 May 2026 20:39:57 +0200 Subject: [PATCH 139/183] Update message formatting in README.md --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index de3d3cb4..31e4cd66 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ Each message in the history list shows the sender name and a compact age indicat ╔══════════════════════════════╗ ║ #general ║ ╠══════════════════════════════╣ -║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ ← selected -║ Alice 3m ║ -║ Hey, let's meet tomorrow ║ -║┌───────────────────────────┐║ -║│▓▓▓▓▓▓▓▓▓ Bob 1h ▓▓▓▓│║ -║│ Sure, what time works? │║ -║└───────────────────────────┘║ -║[+ send] ║ +║▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ║ ← selected +║ Alice 3m ║ +║ Hey, let's meet tomorrow ║ +║┌───────────────────────────┐ ║ +║│▓▓▓▓▓▓▓▓▓ Bob 1h ▓▓▓▓ │ ║ +║│ Sure, what time works? │ ║ +║└───────────────────────────┘ ║ +║[+ send] ║ ╚══════════════════════════════╝ ``` @@ -31,8 +31,8 @@ Press Enter on a message to open it in fullscreen. Navigate between messages wit ``` ╔══════════════════════════════╗ -║▓▓ Alice ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ ← sender -║▓▓ To: Bob ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓║ ← recipient +║▓▓ Alice ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ║ ← sender +║▓▓ To: Bob ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ║ ← recipient ╠══════════════════════════════╣ ║ Hey Bob, let's meet up ║ ║ tomorrow at 6pm downtown. ║ @@ -107,7 +107,7 @@ Browse nodes that have recently advertised on the mesh. Filter by category (Favo ``` ╔══════════════════════════════╗ -║▓▓▓▓▓ WioTracker-Alice ▓▓▓▓▓▓║ ← node name +║▓▓▓▓▓ WioTracker-Alice ▓▓▓▓▓▓ ║ ← node name ╠══════════════════════════════╣ ║ Lat: 50.06190 ║ ║ Lon: 19.94090 ║ From 6399df1f4a6a71a0f3f5961afbee767f32004023 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 09:06:00 +0200 Subject: [PATCH 140/183] fix: improve buzzer volume control on nRF52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use inverted PWM polarity (0x8000) with PCT {2,5,12,25,50} giving ~6-8 dB perceptual steps (-24/-16/-9/-3/0 dB) vs the original uneven steps where levels 4 and 5 were only 1 dB apart - Guard against cmp=0 truncation in both _nrfStartPwm and applyVolume: at level 1 with high notes (freq > ~2.5 kHz) integer math gives cmp=0 which with inverted polarity means 100% HIGH → complete silence - Change volume preview note from C5 (523 Hz) to C6 (1047 Hz) to better match the piezo resonant frequency range and actual notification sounds Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 2 +- src/helpers/ui/buzzer.cpp | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 456ebc1d..40e12c1c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1560,7 +1560,7 @@ void UITask::setBuzzerVolumeLevel(uint8_t level) { if (level > 4) level = 4; _node_prefs->buzzer_volume = level; buzzer.setVolume(level); - if (level > 0) buzzer.playForced("Vol:d=16,o=5,b=120:c"); + if (level > 0) buzzer.playForced("Vol:d=16,o=6,b=120:c"); _next_refresh = 0; #endif } diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index d35a3428..12cb515a 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -86,7 +86,9 @@ bool genericBuzzer::_parseNext(const char*& p, uint8_t def_dur, uint8_t def_oct, } uint8_t genericBuzzer::_dutyPct() const { - static const uint8_t PCT[5] = { 2, 8, 20, 35, 50 }; + // Inverted polarity (0x8000 bit): duty_HIGH = 100% - PCT. + // Values chosen for ~6-8 dB perceptual steps: -24/-16/-9/-3/0 dB. + static const uint8_t PCT[5] = { 2, 5, 12, 25, 50 }; return PCT[_volume_level < 5 ? _volume_level : 4]; } @@ -96,6 +98,7 @@ void genericBuzzer::_nrfStartPwm(uint16_t freq) { uint32_t nrf_pin = g_ADigitalPinMap[PIN_BUZZER]; uint16_t top = 125000 / freq; uint16_t cmp = (uint16_t)(((uint32_t)top * _dutyPct()) / 100); + if (cmp == 0) cmp = 1; // inverted polarity: cmp=0 → 100% HIGH → no AC → silence // Write duty BEFORE SEQSTART so DMA reads our value on the very first period _duty_buf = 0x8000U | cmp; @@ -168,6 +171,7 @@ void genericBuzzer::applyVolume() { if (!_pwm_on) return; uint16_t top = (uint16_t)NRF_PWM2->COUNTERTOP; uint16_t cmp = (uint16_t)(((uint32_t)top * _dutyPct()) / 100); + if (cmp == 0) cmp = 1; _duty_buf = 0x8000U | cmp; // DMA picks this up within one period (< 2.3 ms at A4) } From e9b38fc559ba4f1cb3c964fc4a0d31868f8750f9 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 09:42:09 +0200 Subject: [PATCH 141/183] ci: replace lemon-font build with font-switcher build Build firmware from the font-switcher branch instead of font-lemon. The font-switcher build includes both Adafruit and Lemon fonts in one binary with a runtime toggle in Settings > Display. Co-Authored-By: Claude Sonnet 4.6 --- .../build-wio-tracker-l1-firmwares.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index 331a908f..3927fb51 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -32,31 +32,31 @@ jobs: name: wio-tracker-l1-firmwares path: out - build-lemon-font: + build-font-switcher: runs-on: ubuntu-latest steps: - - name: Clone Repo (font-experiments branch) + - name: Clone Repo (font-switcher branch) uses: actions/checkout@v4 with: - ref: font-lemon + ref: font-switcher - name: Setup Build Environment uses: ./.github/actions/setup-build-environment - - name: Build Lemon Font Firmwares + - name: Build Font Switcher Firmwares env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}-lemon-font + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}-font-switcher run: /usr/bin/env bash build.sh build-wio-tracker-l1-firmwares - name: Upload Workflow Artifacts uses: actions/upload-artifact@v4 with: - name: wio-tracker-l1-lemon-font-firmwares + name: wio-tracker-l1-font-switcher-firmwares path: out release: - needs: [build-standard, build-lemon-font] + needs: [build-standard, build-font-switcher] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: @@ -70,10 +70,10 @@ jobs: name: wio-tracker-l1-firmwares path: out - - name: Download Lemon Font Firmwares + - name: Download Font Switcher Firmwares uses: actions/download-artifact@v4 with: - name: wio-tracker-l1-lemon-font-firmwares + name: wio-tracker-l1-font-switcher-firmwares path: out - name: Create Release From f289252da5b6accde8c3c16079986e80535b1983 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 10:03:15 +0200 Subject: [PATCH 142/183] ci: opt into Node.js 24 for all GitHub Actions workflows Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build-companion-firmwares.yml | 3 +++ .github/workflows/build-repeater-firmwares.yml | 3 +++ .github/workflows/build-room-server-firmwares.yml | 3 +++ .github/workflows/build-wio-tracker-l1-firmwares.yml | 3 +++ .github/workflows/github-pages.yml | 3 +++ .github/workflows/pr-build-check.yml | 3 +++ 6 files changed, 18 insertions(+) diff --git a/.github/workflows/build-companion-firmwares.yml b/.github/workflows/build-companion-firmwares.yml index 721076a1..4213c8e5 100644 --- a/.github/workflows/build-companion-firmwares.yml +++ b/.github/workflows/build-companion-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'companion-*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: diff --git a/.github/workflows/build-repeater-firmwares.yml b/.github/workflows/build-repeater-firmwares.yml index f12bd829..0af173b5 100644 --- a/.github/workflows/build-repeater-firmwares.yml +++ b/.github/workflows/build-repeater-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'repeater-*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: diff --git a/.github/workflows/build-room-server-firmwares.yml b/.github/workflows/build-room-server-firmwares.yml index a488af6a..e4306d6c 100644 --- a/.github/workflows/build-room-server-firmwares.yml +++ b/.github/workflows/build-room-server-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'room-server-*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index 3927fb51..c1fa554f 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -9,6 +9,9 @@ on: tags: - 'wio-tracker-v*' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build-standard: diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 5fd2734b..7a899a36 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -9,6 +9,9 @@ on: permissions: contents: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: github-pages: runs-on: ubuntu-latest diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index 37f3701b..476954fb 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -18,6 +18,9 @@ on: - 'platformio.ini' - '.github/workflows/pr-build-check.yml' +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: runs-on: ubuntu-latest From 3d739df666b1669292214b469339aaff3f8a4c4a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 10:04:14 +0200 Subject: [PATCH 143/183] docs: update README for font-switcher build and font setting Co-Authored-By: Claude Sonnet 4.6 --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 31e4cd66..2e13e099 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ All settings are saved to flash and restored on next boot. - Auto-off timeout - Battery display mode (icon, %, V) - Clock seconds (show/hide — hiding reduces display refresh from 1 s to 60 s) + - Font — **Default** (5×7 Adafruit, ASCII + transliteration) or **Lemon** (native Unicode, available in the `font-switcher` build only) - **Sound** - Buzzer: On / Off / **Auto** — Auto mode silences the device while connected via Bluetooth, and re-enables sound when the connection drops - Volume (1–5; preview tone plays on each change) @@ -146,15 +147,17 @@ Automatically replies to incoming messages that contain a configured trigger wor --- -## Lemon Font Build +## Font Switcher Build -An alternative firmware build is available on the `font-lemon` branch. It replaces the default 5×7 Adafruit font with the [Lemon bitmap font](https://github.com/cmvnd/fonts), which offers: +An alternative firmware build is available (`font-switcher` in the filename). It includes both the default 5×7 Adafruit font and the [Lemon bitmap font](https://github.com/cmvnd/fonts), switchable at runtime in **Settings → Display → Font** without reflashing. The selected font is saved to flash and takes effect immediately. + +The Lemon font offers: - **Native Unicode rendering** — Latin Extended, Greek, and Cyrillic characters (U+0020–U+04FF) are displayed directly without transliteration, covering Polish, Czech, Slovak, German, French, Scandinavian, Hungarian, Romanian, Croatian, Turkish, Baltic, Russian, and Greek scripts. - **Pixel-accurate word wrap** — text wraps based on actual glyph widths rather than a fixed character count. - **Slightly taller glyphs** — the font uses a 10 px line height compared to 8 px for the default font. -Prebuilt `.uf2` files for the Lemon font variant are included in each release alongside the standard builds and are identified by `lemon-font` in the filename. +Prebuilt `.uf2` files for the font-switcher variant are included in each release alongside the standard builds and are identified by `font-switcher` in the filename. --- From 1787752e82b01d92022440b7cafc03050031fcc5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 18:20:10 +0200 Subject: [PATCH 144/183] feat: add dual BLE+USB serial interface for Wio Tracker L1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLE takes priority when connected; USB is always ready as fallback. Both state machines run continuously — no BLE disconnect on USB connect. Co-Authored-By: Claude Sonnet 4.6 --- build.sh | 1 + examples/companion_radio/main.cpp | 9 ++++- src/helpers/nrf52/DualSerialInterface.h | 49 +++++++++++++++++++++++++ variants/wio-tracker-l1/platformio.ini | 14 +++++++ 4 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 src/helpers/nrf52/DualSerialInterface.h diff --git a/build.sh b/build.sh index 6e2e8cfb..506f9a39 100755 --- a/build.sh +++ b/build.sh @@ -233,6 +233,7 @@ build_companion_firmwares() { build_wio_tracker_l1_firmwares() { build_firmware "WioTrackerL1_companion_radio_usb_settings" build_firmware "WioTrackerL1_companion_radio_ble_settings" + build_firmware "WioTrackerL1_companion_radio_dual_settings" } build_room_server_firmwares() { diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 879d04be..f0c87504 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -71,7 +71,10 @@ static uint32_t _atoi(const char* sp) { ArduinoSerialInterface serial_interface; #endif #elif defined(NRF52_PLATFORM) - #ifdef BLE_PIN_CODE + #ifdef DUAL_SERIAL + #include + DualSerialInterface serial_interface; + #elif defined(BLE_PIN_CODE) #include SerialBLEInterface serial_interface; #else @@ -150,7 +153,9 @@ void setup() { #endif ); -#ifdef BLE_PIN_CODE +#ifdef DUAL_SERIAL + serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin(), Serial); +#elif defined(BLE_PIN_CODE) serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); #else serial_interface.begin(Serial); diff --git a/src/helpers/nrf52/DualSerialInterface.h b/src/helpers/nrf52/DualSerialInterface.h new file mode 100644 index 00000000..b7442040 --- /dev/null +++ b/src/helpers/nrf52/DualSerialInterface.h @@ -0,0 +1,49 @@ +#pragma once + +#include "../BaseSerialInterface.h" +#include "../ArduinoSerialInterface.h" +#include "SerialBLEInterface.h" + +// Wraps BLE + USB serial interfaces: BLE takes priority when connected, +// USB is always ready as a fallback. Both state machines run continuously. +class DualSerialInterface : public BaseSerialInterface { + SerialBLEInterface _ble; + ArduinoSerialInterface _usb; + uint8_t _ble_buf[MAX_FRAME_SIZE]; + uint8_t _usb_buf[MAX_FRAME_SIZE]; + +public: + void begin(const char* ble_prefix, char* node_name, uint32_t pin_code, Stream& usb_stream) { + _ble.begin(ble_prefix, node_name, pin_code); + _usb.begin(usb_stream); + } + + void enable() override { _ble.enable(); _usb.enable(); } + void disable() override { _ble.disable(); _usb.disable(); } + bool isEnabled() const override { return _ble.isEnabled() || _usb.isEnabled(); } + + // Reports BLE connection state (used for UI indicator and buzzer Auto mode) + bool isConnected() const override { return _ble.isConnected(); } + + bool isWriteBusy() const override { + return _ble.isConnected() ? _ble.isWriteBusy() : _usb.isWriteBusy(); + } + + size_t writeFrame(const uint8_t src[], size_t len) override { + return _ble.isConnected() ? _ble.writeFrame(src, len) : _usb.writeFrame(src, len); + } + + size_t checkRecvFrame(uint8_t dest[]) override { + // Always pump both state machines: BLE needs this for TX queue drain and + // advertising watchdog; USB needs it to drain its input buffer. + size_t ble_len = _ble.checkRecvFrame(_ble_buf); + size_t usb_len = _usb.checkRecvFrame(_usb_buf); + + if (_ble.isConnected()) { + if (ble_len > 0) { memcpy(dest, _ble_buf, ble_len); return ble_len; } + } else { + if (usb_len > 0) { memcpy(dest, _usb_buf, usb_len); return usb_len; } + } + return 0; + } +}; diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 755bddc0..4edb67bd 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -135,3 +135,17 @@ extends = WioTrackerL1CompanionBLE build_flags = ${WioTrackerL1CompanionBLE.build_flags} -D UI_HAS_JOYSTICK_UPDOWN=1 extra_scripts = post:create-uf2.py + +[WioTrackerL1CompanionDual] +extends = WioTrackerL1CompanionBLE +build_flags = ${WioTrackerL1CompanionBLE.build_flags} + -D DUAL_SERIAL=1 + +[env:WioTrackerL1_companion_radio_dual] +extends = WioTrackerL1CompanionDual + +[env:WioTrackerL1_companion_radio_dual_settings] +extends = WioTrackerL1CompanionDual +build_flags = ${WioTrackerL1CompanionDual.build_flags} + -D UI_HAS_JOYSTICK_UPDOWN=1 +extra_scripts = post:create-uf2.py From 9427fa1999e0b61d884b4a4fe45b36f9c17b121f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 18:24:55 +0200 Subject: [PATCH 145/183] =?UTF-8?q?fix:=20correct=20dual=20serial=20interf?= =?UTF-8?q?ace=20=E2=80=94=20routing,=20UI=20state,=20debug=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isConnected() returns true always (USB always available, mesh can send) - isBLEConnected() added to BaseSerialInterface for BLE-specific UI/buzzer state - MyMesh uses isBLEConnected() to update UITask connection indicator - Disable BLE_DEBUG_LOGGING in dual build (would corrupt USB stream) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 2 +- src/helpers/BaseSerialInterface.h | 4 ++++ src/helpers/nrf52/DualSerialInterface.h | 6 ++++-- variants/wio-tracker-l1/platformio.ini | 1 + 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1002fa5e..df467d77 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -2216,7 +2216,7 @@ void MyMesh::loop() { } #ifdef DISPLAY_CLASS - if (_ui) _ui->setHasConnection(_serial->isConnected()); + if (_ui) _ui->setHasConnection(_serial->isBLEConnected()); #endif } diff --git a/src/helpers/BaseSerialInterface.h b/src/helpers/BaseSerialInterface.h index e6092765..91d2c8c7 100644 --- a/src/helpers/BaseSerialInterface.h +++ b/src/helpers/BaseSerialInterface.h @@ -14,6 +14,10 @@ public: virtual bool isEnabled() const = 0; virtual bool isConnected() const = 0; + // Returns true when a BLE companion app is connected. Default delegates to + // isConnected(); override in dual-interface wrappers that always report + // isConnected()=true but still need to distinguish BLE from USB state. + virtual bool isBLEConnected() const { return isConnected(); } virtual bool isWriteBusy() const = 0; virtual size_t writeFrame(const uint8_t src[], size_t len) = 0; diff --git a/src/helpers/nrf52/DualSerialInterface.h b/src/helpers/nrf52/DualSerialInterface.h index b7442040..259e41ef 100644 --- a/src/helpers/nrf52/DualSerialInterface.h +++ b/src/helpers/nrf52/DualSerialInterface.h @@ -22,8 +22,10 @@ public: void disable() override { _ble.disable(); _usb.disable(); } bool isEnabled() const override { return _ble.isEnabled() || _usb.isEnabled(); } - // Reports BLE connection state (used for UI indicator and buzzer Auto mode) - bool isConnected() const override { return _ble.isConnected(); } + // Always true — USB is always available as fallback, so the mesh can send. + bool isConnected() const override { return true; } + // True only when a BLE companion app is paired and connected. + bool isBLEConnected() const override { return _ble.isConnected(); } bool isWriteBusy() const override { return _ble.isConnected() ? _ble.isWriteBusy() : _usb.isWriteBusy(); diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index 4edb67bd..dc319d05 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -140,6 +140,7 @@ extra_scripts = post:create-uf2.py extends = WioTrackerL1CompanionBLE build_flags = ${WioTrackerL1CompanionBLE.build_flags} -D DUAL_SERIAL=1 + -U BLE_DEBUG_LOGGING [env:WioTrackerL1_companion_radio_dual] extends = WioTrackerL1CompanionDual From 9cabcd5ac70a4a650ea3e6d42f68a1fb2556c5a4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 00:13:36 +0200 Subject: [PATCH 146/183] feat: replace advert scan with active node discovery in NearbyScreen NearbyScreen now sends CTL_TYPE_NODE_DISCOVER_REQ (same protocol as the companion app) instead of a plain self-advert. Nearby repeaters, sensors and room servers respond with their pub_key and type. Known contacts get their lastmod refreshed; unknown nodes appear as temporary entries (e.g. "Repeater") in the list until their advert is received. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 54 +++++++++++++++++++ examples/companion_radio/MyMesh.h | 14 +++++ .../companion_radio/ui-new/NearbyScreen.h | 33 +++++++++--- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index df467d77..44ddaaa9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -764,7 +764,58 @@ bool MyMesh::onContactPathRecv(ContactInfo& contact, uint8_t* in_path, uint8_t i return BaseChatMesh::onContactPathRecv(contact, in_path, in_path_len, out_path, out_path_len, extra_type, extra, extra_len); } +#define CTL_TYPE_NODE_DISCOVER_REQ 0x80 +#define CTL_TYPE_NODE_DISCOVER_RESP 0x90 + +void MyMesh::sendNodeDiscoverReq() { + uint8_t data[10]; + data[0] = CTL_TYPE_NODE_DISCOVER_REQ; + data[1] = (1 << ADV_TYPE_REPEATER) | (1 << ADV_TYPE_SENSOR) | (1 << ADV_TYPE_ROOM); + getRNG()->random(&data[2], 4); + memcpy(&_pending_node_discover_tag, &data[2], 4); + _pending_node_discover_until = futureMillis(8000); + _discovered_count = 0; + uint32_t since = 0; + memcpy(&data[6], &since, 4); + auto pkt = createControlData(data, sizeof(data)); + if (pkt) sendZeroHop(pkt); +} + +int MyMesh::getDiscoveredNodes(DiscoveredEntry dest[], int max_count) { + int n = min(_discovered_count, max_count); + memcpy(dest, _discovered, n * sizeof(DiscoveredEntry)); + return n; +} + void MyMesh::onControlDataRecv(mesh::Packet *packet) { + // handle node discovery responses when in standalone UI mode (no companion app connected) + if (!_serial->isBLEConnected() && + (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP && + packet->payload_len >= 6 + PUB_KEY_SIZE && + _pending_node_discover_tag != 0 && + !millisHasNowPassed(_pending_node_discover_until)) { + uint32_t tag; + memcpy(&tag, &packet->payload[2], 4); + if (tag == _pending_node_discover_tag) { + uint8_t node_type = packet->payload[0] & 0x0F; + const uint8_t* pub_key = &packet->payload[6]; + // check if already in contacts — update lastmod so it shows as fresh + ContactInfo* known = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (known) { + known->lastmod = getRTCClock()->getCurrentTime(); + } + // if unknown, add to temporary discovered list + if (!known && _discovered_count < DISCOVERED_NODES_MAX) { + DiscoveredEntry& e = _discovered[_discovered_count++]; + memcpy(e.pub_key_prefix, pub_key, 4); + e.type = node_type; + e.timestamp = getRTCClock()->getCurrentTime(); + } + if (_ui) _ui->notify(UIEventType::newContactMessage); + } + return; + } + if (packet->payload_len + 4 > sizeof(out_frame)) { MESH_DEBUG_PRINTLN("onControlDataRecv(), payload_len too long: %d", packet->payload_len); return; @@ -862,6 +913,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe dirty_contacts_expiry = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); + _discovered_count = 0; + _pending_node_discover_tag = 0; + _pending_node_discover_until = 0; // defaults memset(&_prefs, 0, sizeof(_prefs)); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index e6160a16..b037d43c 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -85,6 +85,12 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; +struct DiscoveredEntry { + uint8_t pub_key_prefix[4]; + uint8_t type; // ADV_TYPE_REPEATER / ADV_TYPE_SENSOR / ADV_TYPE_ROOM + uint32_t timestamp; // RTC timestamp of discovery +}; + #define EXPECTED_ACK_TABLE_SIZE 8 class MyMesh : public BaseChatMesh, public DataStoreHost { @@ -101,9 +107,11 @@ public: void loop(); void handleCmdFrame(size_t len); bool advert(); + void sendNodeDiscoverReq(); void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); + int getDiscoveredNodes(DiscoveredEntry dest[], int max_count); protected: float getAirtimeBudgetFactor() const override; @@ -263,6 +271,12 @@ private: #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table + + #define DISCOVERED_NODES_MAX 8 + DiscoveredEntry _discovered[DISCOVERED_NODES_MAX]; + int _discovered_count; + uint32_t _pending_node_discover_tag; + unsigned long _pending_node_discover_until; }; extern MyMesh the_mesh; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index a3432ba9..ef4a59a2 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -22,8 +22,9 @@ class NearbyScreen : public UIScreen { int32_t lat_e6, lon_e6; float dist_km; uint8_t type; - int contact_idx; - uint32_t lastmod; // our RTC timestamp of last received packet + int contact_idx; // -1 for discovered-but-unknown nodes + uint32_t lastmod; + bool is_discovered; // true = found via CTL_TYPE_NODE_DISCOVER_RESP, not in contacts[] }; static const int MAX_NEARBY = 32; @@ -101,7 +102,7 @@ class NearbyScreen : public UIScreen { } void startScan() { - the_mesh.advert(); + the_mesh.sendNodeDiscoverReq(); _scanning = true; _scan_started_ms = millis(); } @@ -139,6 +140,26 @@ class NearbyScreen : public UIScreen { e.type = ci.type; e.contact_idx = i; e.lastmod = ci.lastmod; + e.is_discovered = false; + } + + // add nodes discovered via CTL_TYPE_NODE_DISCOVER_RESP that are not in contacts[] + if (_filter != 0) { // Fav filter never shows anonymous discovered nodes + DiscoveredEntry disc[DISCOVERED_NODES_MAX]; + int disc_count = the_mesh.getDiscoveredNodes(disc, DISCOVERED_NODES_MAX); + for (int i = 0; i < disc_count && _count < MAX_NEARBY; i++) { + if (_filter >= 2 && disc[i].type != FILTER_TYPES[_filter]) continue; + Entry& e = _entries[_count++]; + strncpy(e.name, typeName(disc[i].type), sizeof(e.name) - 1); + e.name[sizeof(e.name) - 1] = '\0'; + e.lat_e6 = 0; + e.lon_e6 = 0; + e.dist_km = -1.0f; + e.type = disc[i].type; + e.contact_idx = -1; + e.lastmod = disc[i].timestamp; + e.is_discovered = true; + } } // sort by distance ascending; nodes without GPS (-1) go to the end @@ -242,7 +263,7 @@ public: // ── list view ──────────────────────────────────────────────────────────── display.setColor(DisplayDriver::LIGHT); if (_scanning) { - display.drawTextCentered(display.width() / 2, 0, "ADVERTISING..."); + display.drawTextCentered(display.width() / 2, 0, "DISCOVERING..."); } else { char title[22]; snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); @@ -252,7 +273,7 @@ public: if (_count == 0) { display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No contacts found"); - display.drawTextCentered(display.width() / 2, 40, "[M]=Advert"); + display.drawTextCentered(display.width() / 2, 40, "[M]=Discover"); } else { for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { int idx = _scroll + i; @@ -323,7 +344,7 @@ public: if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { _ctx_menu.begin("Options", 2); - _ctx_menu.addItem("Send my advert"); + _ctx_menu.addItem("Discover nearby"); _ctx_menu.addItem("Back"); return true; } From d8510430591795cf0d9ee8f2fcc8504da44047b5 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 07:57:21 +0200 Subject: [PATCH 147/183] feat: add dedicated discover sub-screen to NearbyScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the inline advert scan in the nearby list with a proper CTL_TYPE_NODE_DISCOVER_RESP-based sub-screen accessible via the context menu. - DiscoveredEntry → DiscoverResult: adds name[32] and is_known flag so both known contacts and unknown nodes are shown with useful labels - getDiscoveredNodes → getDiscoverResults, capacity 8 → 16 - onControlDataRecv populates name from contacts for known nodes and records all respondents (not just unknowns) - NearbyScreen gains _discover_mode sub-screen: 8-second scan window, live result polling each render cycle, UP/DOWN scroll, CANCEL to return, ENTER/M to re-scan; known nodes show actual name + "known", unknown nodes show "[Type]" + "NEW" - List view is clean: no discovered nodes mixed in, no scanning banner Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 29 +-- examples/companion_radio/MyMesh.h | 15 +- .../companion_radio/ui-new/NearbyScreen.h | 189 +++++++++++------- 3 files changed, 146 insertions(+), 87 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 44ddaaa9..4ee19131 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -774,16 +774,16 @@ void MyMesh::sendNodeDiscoverReq() { getRNG()->random(&data[2], 4); memcpy(&_pending_node_discover_tag, &data[2], 4); _pending_node_discover_until = futureMillis(8000); - _discovered_count = 0; + _discover_count = 0; uint32_t since = 0; memcpy(&data[6], &since, 4); auto pkt = createControlData(data, sizeof(data)); if (pkt) sendZeroHop(pkt); } -int MyMesh::getDiscoveredNodes(DiscoveredEntry dest[], int max_count) { - int n = min(_discovered_count, max_count); - memcpy(dest, _discovered, n * sizeof(DiscoveredEntry)); +int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) { + int n = min(_discover_count, max_count); + memcpy(dest, _discover_results, n * sizeof(DiscoverResult)); return n; } @@ -799,17 +799,22 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { if (tag == _pending_node_discover_tag) { uint8_t node_type = packet->payload[0] & 0x0F; const uint8_t* pub_key = &packet->payload[6]; - // check if already in contacts — update lastmod so it shows as fresh ContactInfo* known = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); if (known) { known->lastmod = getRTCClock()->getCurrentTime(); } - // if unknown, add to temporary discovered list - if (!known && _discovered_count < DISCOVERED_NODES_MAX) { - DiscoveredEntry& e = _discovered[_discovered_count++]; - memcpy(e.pub_key_prefix, pub_key, 4); - e.type = node_type; - e.timestamp = getRTCClock()->getCurrentTime(); + if (_discover_count < DISCOVER_RESULTS_MAX) { + DiscoverResult& r = _discover_results[_discover_count++]; + if (known) { + strncpy(r.name, known->name, sizeof(r.name) - 1); + r.name[sizeof(r.name) - 1] = '\0'; + r.is_known = true; + } else { + r.name[0] = '\0'; + r.is_known = false; + } + r.type = node_type; + r.timestamp = getRTCClock()->getCurrentTime(); } if (_ui) _ui->notify(UIEventType::newContactMessage); } @@ -913,7 +918,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe dirty_contacts_expiry = 0; memset(advert_paths, 0, sizeof(advert_paths)); memset(send_scope.key, 0, sizeof(send_scope.key)); - _discovered_count = 0; + _discover_count = 0; _pending_node_discover_tag = 0; _pending_node_discover_until = 0; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index b037d43c..9d063a99 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -85,10 +85,11 @@ struct AdvertPath { uint8_t path[MAX_PATH_SIZE]; }; -struct DiscoveredEntry { - uint8_t pub_key_prefix[4]; +struct DiscoverResult { + char name[32]; // contact name if known, "" if unknown (use type label) uint8_t type; // ADV_TYPE_REPEATER / ADV_TYPE_SENSOR / ADV_TYPE_ROOM - uint32_t timestamp; // RTC timestamp of discovery + bool is_known; // true = in contacts[], false = new unknown node + uint32_t timestamp; }; #define EXPECTED_ACK_TABLE_SIZE 8 @@ -111,7 +112,7 @@ public: void enterCLIRescue(); int getRecentlyHeard(AdvertPath dest[], int max_num); - int getDiscoveredNodes(DiscoveredEntry dest[], int max_count); + int getDiscoverResults(DiscoverResult dest[], int max_count); protected: float getAirtimeBudgetFactor() const override; @@ -272,9 +273,9 @@ private: #define ADVERT_PATH_TABLE_SIZE 16 AdvertPath advert_paths[ADVERT_PATH_TABLE_SIZE]; // circular table - #define DISCOVERED_NODES_MAX 8 - DiscoveredEntry _discovered[DISCOVERED_NODES_MAX]; - int _discovered_count; + #define DISCOVER_RESULTS_MAX 16 + DiscoverResult _discover_results[DISCOVER_RESULTS_MAX]; + int _discover_count; uint32_t _pending_node_discover_tag; unsigned long _pending_node_discover_until; }; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index ef4a59a2..b7ee25dd 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -17,14 +17,14 @@ class NearbyScreen : public UIScreen { static const char* FILTER_LABELS[FILTER_COUNT]; static const uint8_t FILTER_TYPES[FILTER_COUNT]; + // ── nearby list state ──────────────────────────────────────────────────────── struct Entry { char name[32]; int32_t lat_e6, lon_e6; float dist_km; uint8_t type; - int contact_idx; // -1 for discovered-but-unknown nodes + int contact_idx; uint32_t lastmod; - bool is_discovered; // true = found via CTL_TYPE_NODE_DISCOVER_RESP, not in contacts[] }; static const int MAX_NEARBY = 32; @@ -37,18 +37,22 @@ class NearbyScreen : public UIScreen { bool _own_gps; uint8_t _filter; - // scan state - bool _scanning; - unsigned long _scan_started_ms; - static const unsigned long SCAN_DURATION_MS = 4000UL; - - // detail view periodic refresh unsigned long _detail_refresh_ms; static const unsigned long DETAIL_REFRESH_MS = 10000UL; - // context menu (list view) PopupMenu _ctx_menu; + // ── discover sub-screen state ──────────────────────────────────────────────── + bool _discover_mode; + bool _discovering; + unsigned long _discover_started_ms; + static const unsigned long DISCOVER_DURATION_MS = 8000UL; + + DiscoverResult _dresults[DISCOVER_RESULTS_MAX]; + int _dresult_count; + int _dscroll; + + // ── helpers ────────────────────────────────────────────────────────────────── static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { static const float DEG2RAD = (float)M_PI / 180.0f; float la1 = lat1 * (1e-6f * DEG2RAD); @@ -101,12 +105,6 @@ class NearbyScreen : public UIScreen { } } - void startScan() { - the_mesh.sendNodeDiscoverReq(); - _scanning = true; - _scan_started_ms = millis(); - } - void refresh() { _count = 0; _own_gps = false; @@ -125,8 +123,8 @@ class NearbyScreen : public UIScreen { for (int i = 0; i < nc && _count < MAX_NEARBY; i++) { ContactInfo ci; if (!the_mesh.getContactByIdx(i, ci)) continue; - if (_filter == 0 && !(ci.flags & 1)) continue; // Fav: only starred - if (_filter >= 2 && ci.type != FILTER_TYPES[_filter]) continue; // type filter + if (_filter == 0 && !(ci.flags & 1)) continue; + if (_filter >= 2 && ci.type != FILTER_TYPES[_filter]) continue; Entry& e = _entries[_count++]; strncpy(e.name, ci.name, sizeof(e.name) - 1); @@ -140,29 +138,9 @@ class NearbyScreen : public UIScreen { e.type = ci.type; e.contact_idx = i; e.lastmod = ci.lastmod; - e.is_discovered = false; } - // add nodes discovered via CTL_TYPE_NODE_DISCOVER_RESP that are not in contacts[] - if (_filter != 0) { // Fav filter never shows anonymous discovered nodes - DiscoveredEntry disc[DISCOVERED_NODES_MAX]; - int disc_count = the_mesh.getDiscoveredNodes(disc, DISCOVERED_NODES_MAX); - for (int i = 0; i < disc_count && _count < MAX_NEARBY; i++) { - if (_filter >= 2 && disc[i].type != FILTER_TYPES[_filter]) continue; - Entry& e = _entries[_count++]; - strncpy(e.name, typeName(disc[i].type), sizeof(e.name) - 1); - e.name[sizeof(e.name) - 1] = '\0'; - e.lat_e6 = 0; - e.lon_e6 = 0; - e.dist_km = -1.0f; - e.type = disc[i].type; - e.contact_idx = -1; - e.lastmod = disc[i].timestamp; - e.is_discovered = true; - } - } - - // sort by distance ascending; nodes without GPS (-1) go to the end + // sort by distance ascending; nodes without GPS go to the end for (int i = 0; i < _count - 1; i++) { int best = i; for (int j = i + 1; j < _count; j++) { @@ -180,15 +158,100 @@ class NearbyScreen : public UIScreen { } } + // ── discover sub-screen ────────────────────────────────────────────────────── + void enterDiscoverMode() { + _discover_mode = true; + _discovering = true; + _discover_started_ms = millis(); + _dresult_count = 0; + _dscroll = 0; + the_mesh.sendNodeDiscoverReq(); + } + + int renderDiscover(DisplayDriver& display) { + // poll: fetch latest results + _dresult_count = the_mesh.getDiscoverResults(_dresults, DISCOVER_RESULTS_MAX); + + if (_discovering && millis() - _discover_started_ms >= DISCOVER_DURATION_MS) { + _discovering = false; + } + + display.setColor(DisplayDriver::LIGHT); + char title[28]; + if (_discovering) { + snprintf(title, sizeof(title), "SCANNING... (%d)", _dresult_count); + } else { + if (_dresult_count == 0) + snprintf(title, sizeof(title), "DISCOVER: none"); + else + snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count); + } + display.drawTextCentered(display.width() / 2, 0, title); + display.fillRect(0, 10, display.width(), 1); + + if (_dresult_count == 0) { + display.drawTextCentered(display.width() / 2, 32, + _discovering ? "Waiting for replies..." : "No nodes found"); + } else { + for (int i = 0; i < VISIBLE && (_dscroll + i) < _dresult_count; i++) { + int idx = _dscroll + i; + const DiscoverResult& r = _dresults[idx]; + int y = START_Y + i * ITEM_H; + + display.setColor(DisplayDriver::LIGHT); + + // name: known contact → actual name; unknown → "[Type]" + char label[32]; + if (r.name[0]) { + strncpy(label, r.name, sizeof(label) - 1); + label[sizeof(label) - 1] = '\0'; + } else { + snprintf(label, sizeof(label), "[%s]", typeName(r.type)); + } + char filtered[32]; + display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); + display.drawTextEllipsized(2, y, DIST_COL - 4, filtered); + + // right column: type label, dimmed for known / bright for new + display.setCursor(DIST_COL, y); + display.print(r.is_known ? "known" : "NEW"); + } + + display.setColor(DisplayDriver::LIGHT); + if (_dscroll > 0) + { display.setCursor(display.width() - 6, START_Y); display.print("^"); } + if (_dscroll + VISIBLE < _dresult_count) + { display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); } + } + + return _discovering ? 200 : 2000; + } + + bool handleInputDiscover(char c) { + if (c == KEY_CANCEL) { + _discover_mode = false; + refresh(); // refresh nearby list to pick up any lastmod updates + return true; + } + if (c == KEY_CONTEXT_MENU || c == KEY_ENTER) { + // re-scan + enterDiscoverMode(); + return true; + } + if (c == KEY_UP && _dscroll > 0) { _dscroll--; return true; } + if (c == KEY_DOWN && _dscroll + VISIBLE < _dresult_count) { _dscroll++; return true; } + return true; + } + public: NearbyScreen(UITask* task) - : _task(task), _filter(0), _scanning(false) {} + : _task(task), _filter(0), _discover_mode(false), _discovering(false) {} void enter() { _sel = _scroll = 0; _detail = false; _filter = 0; - _scanning = false; + _discover_mode = false; _ctx_menu.active = false; refresh(); } @@ -196,11 +259,8 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); - // poll scan timer — only refresh list when not in detail view - if (_scanning && !_detail && millis() - _scan_started_ms >= SCAN_DURATION_MS) { - _scanning = false; - refresh(); - } + // ── discover sub-screen ────────────────────────────────────────────────── + if (_discover_mode) return renderDiscover(display); // periodic refresh in detail view — preserve selected contact by idx if (_detail && millis() - _detail_refresh_ms >= DETAIL_REFRESH_MS) { @@ -212,7 +272,7 @@ public: if (_entries[i].contact_idx == saved_contact_idx) { _sel = i; found = true; break; } } } - if (!found) _detail = false; // contact left the filtered list — return to list view + if (!found) _detail = false; _detail_refresh_ms = millis(); } @@ -228,7 +288,6 @@ public: display.drawTextEllipsized(2, 1, display.width() - 4, filtered); display.setColor(DisplayDriver::LIGHT); - // 6 rows in 54px (y=10..63): spacing 9px, start at y=11 char buf[32]; snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); display.setCursor(2, 11); display.print(buf); @@ -262,17 +321,13 @@ public: // ── list view ──────────────────────────────────────────────────────────── display.setColor(DisplayDriver::LIGHT); - if (_scanning) { - display.drawTextCentered(display.width() / 2, 0, "DISCOVERING..."); - } else { - char title[22]; - snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); - display.drawTextCentered(display.width() / 2, 0, title); - } + char title[22]; + snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); + display.drawTextCentered(display.width() / 2, 0, title); display.fillRect(0, 10, display.width(), 1); if (_count == 0) { - display.drawTextCentered(display.width() / 2, 28, _scanning ? "Waiting..." : "No contacts found"); + display.drawTextCentered(display.width() / 2, 28, "No contacts found"); display.drawTextCentered(display.width() / 2, 40, "[M]=Discover"); } else { for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { @@ -308,39 +363,37 @@ public: { display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); } } - // context menu overlay — drawn on top regardless of list state if (_ctx_menu.active) { _ctx_menu.render(display); return 50; } - return _scanning ? 100 : (_count == 0 ? 3000 : 2000); + return _count == 0 ? 3000 : 2000; } bool handleInput(char c) override { - // ── detail view input ──────────────────────────────────────────────────── + // ── discover sub-screen ────────────────────────────────────────────────── + if (_discover_mode) return handleInputDiscover(c); + + // ── detail view ───────────────────────────────────────────────────────── if (_detail) { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { - _detail = false; - return true; - } + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _detail = false; return true; } return true; } - // ── list view — context menu ───────────────────────────────────────────── + // ── context menu ───────────────────────────────────────────────────────── if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); if (res == PopupMenu::SELECTED) { - if (_ctx_menu.selectedIndex() == 0) { - startScan(); - } else { + if (_ctx_menu.selectedIndex() == 0) + enterDiscoverMode(); + else _task->gotoToolsScreen(); - } } return true; } - // ── list view — normal input ───────────────────────────────────────────── + // ── list view ──────────────────────────────────────────────────────────── if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; } if (c == KEY_CONTEXT_MENU) { _ctx_menu.begin("Options", 2); From 64098bc4dc20c34b432dceea61f083901bfc5499 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 08:13:13 +0200 Subject: [PATCH 148/183] fix: change [M]=Discover hint to [Enter]=Discover in NearbyScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [M] key doesn't exist on the device; context menu is long-press Enter. Also handle KEY_ENTER directly when the list is empty so the hint is accurate — pressing Enter with no contacts enters discover mode without going through the context menu. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/NearbyScreen.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index b7ee25dd..ec3f5421 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -328,7 +328,7 @@ public: if (_count == 0) { display.drawTextCentered(display.width() / 2, 28, "No contacts found"); - display.drawTextCentered(display.width() / 2, 40, "[M]=Discover"); + display.drawTextCentered(display.width() / 2, 40, "[Enter]=Discover"); } else { for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { int idx = _scroll + i; @@ -411,6 +411,7 @@ public: if (_sel >= _scroll + VISIBLE) _scroll = _sel - VISIBLE + 1; return true; } + if (c == KEY_ENTER && _count == 0) { enterDiscoverMode(); return true; } if (c == KEY_ENTER && _count > 0) { _detail = true; _detail_refresh_ms = millis(); From 6249e63ec088a6522c5a48c7941d83b1b78a21de Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 08:37:17 +0200 Subject: [PATCH 149/183] fix: remove isBLEConnected guard from discover response handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The !isBLEConnected() check was too restrictive — if BLE happened to be connected (app in background) the standalone discover would silently drop all responses. Tag matching already provides correct isolation: responses matching _pending_node_discover_tag are handled by the standalone UI and not forwarded to the BLE app; responses with a different tag fall through to the normal forward path. Also show short type names (Rpt/Snsr/Room) in the discover results list instead of generic "known"/"NEW", with a '*' prefix for nodes not yet in contacts — matching the repeater/sensor distinction the app shows. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 8 ++++---- examples/companion_radio/ui-new/NearbyScreen.h | 11 ++++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4ee19131..c6631df8 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -788,9 +788,9 @@ int MyMesh::getDiscoverResults(DiscoverResult dest[], int max_count) { } void MyMesh::onControlDataRecv(mesh::Packet *packet) { - // handle node discovery responses when in standalone UI mode (no companion app connected) - if (!_serial->isBLEConnected() && - (packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP && + // If we have an active standalone discover, check if this is a matching response. + // Tag matching provides isolation — no isBLEConnected check needed. + if ((packet->payload[0] & 0xF0) == CTL_TYPE_NODE_DISCOVER_RESP && packet->payload_len >= 6 + PUB_KEY_SIZE && _pending_node_discover_tag != 0 && !millisHasNowPassed(_pending_node_discover_until)) { @@ -817,8 +817,8 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { r.timestamp = getRTCClock()->getCurrentTime(); } if (_ui) _ui->notify(UIEventType::newContactMessage); + return; // our discover — don't forward to BLE app } - return; } if (packet->payload_len + 4 > sizeof(out_frame)) { diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index ec3f5421..59d131c9 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -200,7 +200,7 @@ class NearbyScreen : public UIScreen { display.setColor(DisplayDriver::LIGHT); - // name: known contact → actual name; unknown → "[Type]" + // left: contact name if known, otherwise "[Type]" char label[32]; if (r.name[0]) { strncpy(label, r.name, sizeof(label) - 1); @@ -212,9 +212,14 @@ class NearbyScreen : public UIScreen { display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); display.drawTextEllipsized(2, y, DIST_COL - 4, filtered); - // right column: type label, dimmed for known / bright for new + // right: short type name; prefix '*' for nodes not in contacts + const char* st = (r.type == ADV_TYPE_REPEATER) ? "Rpt" : + (r.type == ADV_TYPE_SENSOR) ? "Snsr" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "?"; + char rtype[8]; + snprintf(rtype, sizeof(rtype), r.is_known ? "%s" : "*%s", st); display.setCursor(DIST_COL, y); - display.print(r.is_known ? "known" : "NEW"); + display.print(rtype); } display.setColor(DisplayDriver::LIGHT); From 2a9874d05d6c8c577ae7ed0616a72b65dc292b16 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 08:51:43 +0200 Subject: [PATCH 150/183] feat: show RSSI in discover results list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rssi field to DiscoverResult, captured from _radio->getLastRSSI() when the discover response is received. Display format in the right column: known node → "Rpt-87" / "Snsr-92" (type + dBm) new node → "*-87" (asterisk + dBm; type already visible in "[Rpt]" label) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/ui-new/NearbyScreen.h | 9 ++++++--- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c6631df8..d1972ede 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -814,6 +814,7 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { r.is_known = false; } r.type = node_type; + r.rssi = (int8_t)_radio->getLastRSSI(); r.timestamp = getRTCClock()->getCurrentTime(); } if (_ui) _ui->notify(UIEventType::newContactMessage); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 9d063a99..de95afa4 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -89,6 +89,7 @@ struct DiscoverResult { char name[32]; // contact name if known, "" if unknown (use type label) uint8_t type; // ADV_TYPE_REPEATER / ADV_TYPE_SENSOR / ADV_TYPE_ROOM bool is_known; // true = in contacts[], false = new unknown node + int8_t rssi; // RSSI of the response as received by us (dBm) uint32_t timestamp; }; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 59d131c9..014d0441 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -212,12 +212,15 @@ class NearbyScreen : public UIScreen { display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); display.drawTextEllipsized(2, y, DIST_COL - 4, filtered); - // right: short type name; prefix '*' for nodes not in contacts + // right: type+RSSI for known ("Rpt-87"), just RSSI with '*' for new ("*-87") const char* st = (r.type == ADV_TYPE_REPEATER) ? "Rpt" : (r.type == ADV_TYPE_SENSOR) ? "Snsr" : (r.type == ADV_TYPE_ROOM) ? "Room" : "?"; - char rtype[8]; - snprintf(rtype, sizeof(rtype), r.is_known ? "%s" : "*%s", st); + char rtype[12]; + if (r.is_known) + snprintf(rtype, sizeof(rtype), "%s%d", st, (int)r.rssi); + else + snprintf(rtype, sizeof(rtype), "*%d", (int)r.rssi); display.setCursor(DIST_COL, y); display.print(rtype); } From 2da37b3af5dcc6ca6f194fbdfe86c8ae5ddf2ff3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 08:56:54 +0200 Subject: [PATCH 151/183] feat: 2-line boxed discover entries with RSSI, SNR, remote SNR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each discovered node now renders as a boxed 2-line card (same style as message history): ┌──────────────────────────────┐ │ NodeName (or [Sensor]) Type │ ← inverted header │ RSSI:-87 SNR:7 Rem:5 │ ← signal stats └──────────────────────────────┘ Add snr_x4 and remote_snr_x4 to DiscoverResult: snr_x4 = _radio->getLastSNR()*4 (how well we heard the response) remote_snr_x4 = payload[1] (how well responder heard our request) 2 items visible at a time; UP/DOWN scroll through results. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 2 + examples/companion_radio/MyMesh.h | 2 + .../companion_radio/ui-new/NearbyScreen.h | 68 +++++++++++-------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index d1972ede..3fdc8e7f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -815,6 +815,8 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { } r.type = node_type; r.rssi = (int8_t)_radio->getLastRSSI(); + r.snr_x4 = (int8_t)(_radio->getLastSNR() * 4); + r.remote_snr_x4 = (int8_t)packet->payload[1]; r.timestamp = getRTCClock()->getCurrentTime(); } if (_ui) _ui->notify(UIEventType::newContactMessage); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index de95afa4..ba2c01cb 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -90,6 +90,8 @@ struct DiscoverResult { uint8_t type; // ADV_TYPE_REPEATER / ADV_TYPE_SENSOR / ADV_TYPE_ROOM bool is_known; // true = in contacts[], false = new unknown node int8_t rssi; // RSSI of the response as received by us (dBm) + int8_t snr_x4; // SNR of the response as received by us (dB × 4) + int8_t remote_snr_x4; // SNR at which responder heard our request (dB × 4) uint32_t timestamp; }; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 014d0441..59685603 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -169,23 +169,24 @@ class NearbyScreen : public UIScreen { } int renderDiscover(DisplayDriver& display) { - // poll: fetch latest results + static const int D_BOX_H = 19; + static const int D_ITEM_H = 21; // box + 2px gap + static const int D_VISIBLE = 2; + static const int D_START_Y = 11; + _dresult_count = the_mesh.getDiscoverResults(_dresults, DISCOVER_RESULTS_MAX); - if (_discovering && millis() - _discover_started_ms >= DISCOVER_DURATION_MS) { + if (_discovering && millis() - _discover_started_ms >= DISCOVER_DURATION_MS) _discovering = false; - } display.setColor(DisplayDriver::LIGHT); char title[28]; - if (_discovering) { + if (_discovering) snprintf(title, sizeof(title), "SCANNING... (%d)", _dresult_count); - } else { - if (_dresult_count == 0) - snprintf(title, sizeof(title), "DISCOVER: none"); - else - snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count); - } + else if (_dresult_count == 0) + snprintf(title, sizeof(title), "DISCOVER: none"); + else + snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count); display.drawTextCentered(display.width() / 2, 0, title); display.fillRect(0, 10, display.width(), 1); @@ -193,43 +194,50 @@ class NearbyScreen : public UIScreen { display.drawTextCentered(display.width() / 2, 32, _discovering ? "Waiting for replies..." : "No nodes found"); } else { - for (int i = 0; i < VISIBLE && (_dscroll + i) < _dresult_count; i++) { + for (int i = 0; i < D_VISIBLE && (_dscroll + i) < _dresult_count; i++) { int idx = _dscroll + i; const DiscoverResult& r = _dresults[idx]; - int y = START_Y + i * ITEM_H; + int y = D_START_Y + i * D_ITEM_H; + const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : + (r.type == ADV_TYPE_SENSOR) ? "Sensor" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + + // header line: inverted background display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), D_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); - // left: contact name if known, otherwise "[Type]" + // header: name (or [Type]) left, type label right char label[32]; if (r.name[0]) { strncpy(label, r.name, sizeof(label) - 1); label[sizeof(label) - 1] = '\0'; } else { - snprintf(label, sizeof(label), "[%s]", typeName(r.type)); + snprintf(label, sizeof(label), "[%s]", typeStr); } char filtered[32]; display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); - display.drawTextEllipsized(2, y, DIST_COL - 4, filtered); + int tw = display.getTextWidth(typeStr); + display.drawTextEllipsized(3, y + 1, display.width() - 6 - tw, filtered); + display.setCursor(display.width() - 3 - tw, y + 1); + display.print(typeStr); - // right: type+RSSI for known ("Rpt-87"), just RSSI with '*' for new ("*-87") - const char* st = (r.type == ADV_TYPE_REPEATER) ? "Rpt" : - (r.type == ADV_TYPE_SENSOR) ? "Snsr" : - (r.type == ADV_TYPE_ROOM) ? "Room" : "?"; - char rtype[12]; - if (r.is_known) - snprintf(rtype, sizeof(rtype), "%s%d", st, (int)r.rssi); - else - snprintf(rtype, sizeof(rtype), "*%d", (int)r.rssi); - display.setCursor(DIST_COL, y); - display.print(rtype); + // body line: RSSI / SNR / remote SNR + display.setColor(DisplayDriver::LIGHT); + char sig[32]; + int snr = r.snr_x4 / 4; + int rsnr = r.remote_snr_x4 / 4; + snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d Rem:%d", (int)r.rssi, snr, rsnr); + display.drawTextEllipsized(3, y + 10, display.width() - 6, sig); } display.setColor(DisplayDriver::LIGHT); if (_dscroll > 0) - { display.setCursor(display.width() - 6, START_Y); display.print("^"); } - if (_dscroll + VISIBLE < _dresult_count) - { display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); } + { display.setCursor(display.width() - 6, D_START_Y + 1); display.print("^"); } + if (_dscroll + D_VISIBLE < _dresult_count) + { display.setCursor(display.width() - 6, D_START_Y + D_VISIBLE * D_ITEM_H - 10); display.print("v"); } } return _discovering ? 200 : 2000; @@ -247,7 +255,7 @@ class NearbyScreen : public UIScreen { return true; } if (c == KEY_UP && _dscroll > 0) { _dscroll--; return true; } - if (c == KEY_DOWN && _dscroll + VISIBLE < _dresult_count) { _dscroll++; return true; } + if (c == KEY_DOWN && _dscroll + 2 < _dresult_count) { _dscroll++; return true; } return true; } From 3b7915d0b7ea0f34648ff6710b49795bcf33ed20 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 09:05:15 +0200 Subject: [PATCH 152/183] feat: discover detail screen + card selection + Rem removed from card body Card body now shows only "RSSI:-87 SNR:7" (Rem was too long to fit). List view: - UP/DOWN selects items; selected card fully inverted, unselected keeps header-only highlight - Enter opens full-screen detail for selected node - Long-press Enter (context menu) re-scans Detail screen: - Inverted title bar with node name (or [Type]) - Type / RSSI / SNR / Rem / Status lines - Cancel or context menu returns to discover list Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 123 ++++++++++++++---- 1 file changed, 95 insertions(+), 28 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 59685603..3b87942c 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -51,6 +51,8 @@ class NearbyScreen : public UIScreen { DiscoverResult _dresults[DISCOVER_RESULTS_MAX]; int _dresult_count; int _dscroll; + int _dsel; + bool _ddetail; // ── helpers ────────────────────────────────────────────────────────────────── static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { @@ -162,18 +164,60 @@ class NearbyScreen : public UIScreen { void enterDiscoverMode() { _discover_mode = true; _discovering = true; + _ddetail = false; _discover_started_ms = millis(); _dresult_count = 0; _dscroll = 0; + _dsel = 0; the_mesh.sendNodeDiscoverReq(); } int renderDiscover(DisplayDriver& display) { static const int D_BOX_H = 19; - static const int D_ITEM_H = 21; // box + 2px gap + static const int D_ITEM_H = 21; static const int D_VISIBLE = 2; static const int D_START_Y = 11; + if (_ddetail) { + // ── full-screen detail for selected node ────────────────────────────── + const DiscoverResult& r = _dresults[_dsel]; + const char* fullType = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : + (r.type == ADV_TYPE_SENSOR) ? "Sensor" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + + // title bar: node name inverted + char label[32]; + if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; } + else { snprintf(label, sizeof(label), "[%s]", fullType); } + char filtered[32]; + display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 0, display.width(), 10); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(2, 1, display.width() - 4, filtered); + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, 10, display.width(), 1); + + char buf[32]; + snprintf(buf, sizeof(buf), "Type: %s", fullType); + display.setCursor(2, 12); display.print(buf); + + snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); + display.setCursor(2, 21); display.print(buf); + + snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4)); + display.setCursor(2, 30); display.print(buf); + + snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4)); + display.setCursor(2, 39); display.print(buf); + + display.setCursor(2, 48); + display.print(r.is_known ? "Status: known" : "Status: new"); + + return 5000; + } + + // ── list view ───────────────────────────────────────────────────────────── _dresult_count = the_mesh.getDiscoverResults(_dresults, DISCOVER_RESULTS_MAX); if (_discovering && millis() - _discover_started_ms >= DISCOVER_DURATION_MS) @@ -194,28 +238,35 @@ class NearbyScreen : public UIScreen { display.drawTextCentered(display.width() / 2, 32, _discovering ? "Waiting for replies..." : "No nodes found"); } else { + if (_dsel >= _dresult_count) _dsel = _dresult_count - 1; for (int i = 0; i < D_VISIBLE && (_dscroll + i) < _dresult_count; i++) { int idx = _dscroll + i; + bool sel = (idx == _dsel); const DiscoverResult& r = _dresults[idx]; int y = D_START_Y + i * D_ITEM_H; - const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : - (r.type == ADV_TYPE_SENSOR) ? "Sensor" : - (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Rpt" : + (r.type == ADV_TYPE_SENSOR) ? "Snsr" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "?"; - // header line: inverted background display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), D_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); - display.setColor(DisplayDriver::DARK); - - // header: name (or [Type]) left, type label right - char label[32]; - if (r.name[0]) { - strncpy(label, r.name, sizeof(label) - 1); - label[sizeof(label) - 1] = '\0'; + if (sel) { + display.fillRect(0, y, display.width(), D_BOX_H); // fully inverted when selected + display.setColor(DisplayDriver::DARK); } else { - snprintf(label, sizeof(label), "[%s]", typeStr); + display.drawRect(0, y, display.width(), D_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + } + + // header: name left, type right + char label[32]; + if (r.name[0]) { strncpy(label, r.name, 31); label[31] = '\0'; } + else { + const char* ft = (r.type == ADV_TYPE_REPEATER) ? "Repeater" : + (r.type == ADV_TYPE_SENSOR) ? "Sensor" : + (r.type == ADV_TYPE_ROOM) ? "Room" : "Node"; + snprintf(label, sizeof(label), "[%s]", ft); } char filtered[32]; display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); @@ -224,12 +275,10 @@ class NearbyScreen : public UIScreen { display.setCursor(display.width() - 3 - tw, y + 1); display.print(typeStr); - // body line: RSSI / SNR / remote SNR - display.setColor(DisplayDriver::LIGHT); - char sig[32]; - int snr = r.snr_x4 / 4; - int rsnr = r.remote_snr_x4 / 4; - snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d Rem:%d", (int)r.rssi, snr, rsnr); + // body: RSSI + SNR + display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); + char sig[24]; + snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d", (int)r.rssi, (int)(r.snr_x4 / 4)); display.drawTextEllipsized(3, y + 10, display.width() - 6, sig); } @@ -244,30 +293,48 @@ class NearbyScreen : public UIScreen { } bool handleInputDiscover(char c) { + if (_ddetail) { + if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _ddetail = false; return true; } + return true; + } if (c == KEY_CANCEL) { _discover_mode = false; - refresh(); // refresh nearby list to pick up any lastmod updates + refresh(); return true; } - if (c == KEY_CONTEXT_MENU || c == KEY_ENTER) { - // re-scan - enterDiscoverMode(); + if (c == KEY_ENTER && _dresult_count > 0) { + _ddetail = true; + return true; + } + if (c == KEY_CONTEXT_MENU) { + enterDiscoverMode(); // re-scan + return true; + } + if (c == KEY_UP && _dsel > 0) { + _dsel--; + if (_dsel < _dscroll) _dscroll = _dsel; + return true; + } + if (c == KEY_DOWN && _dsel < _dresult_count - 1) { + _dsel++; + if (_dsel >= _dscroll + 2) _dscroll = _dsel - 1; return true; } - if (c == KEY_UP && _dscroll > 0) { _dscroll--; return true; } - if (c == KEY_DOWN && _dscroll + 2 < _dresult_count) { _dscroll++; return true; } return true; } public: NearbyScreen(UITask* task) - : _task(task), _filter(0), _discover_mode(false), _discovering(false) {} + : _task(task), _filter(0), _discover_mode(false), _discovering(false), + _ddetail(false), _dsel(0) {} void enter() { _sel = _scroll = 0; _detail = false; _filter = 0; _discover_mode = false; + _ddetail = false; + _dsel = 0; _ctx_menu.active = false; refresh(); } From 52d97ed314d104b46d1125e483ab12943fd8f003 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 09:11:32 +0200 Subject: [PATCH 153/183] feat: show pub_key as base64 in discover detail screen Replaces "Type: Repeater" line with the node's full public key encoded as base64 (44 chars). drawTextEllipsized clips it with ... to fit the 128px wide display. Type is already visible in the inverted header. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 1 + examples/companion_radio/MyMesh.h | 1 + examples/companion_radio/ui-new/NearbyScreen.h | 9 +++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3fdc8e7f..4e43de86 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -817,6 +817,7 @@ void MyMesh::onControlDataRecv(mesh::Packet *packet) { r.rssi = (int8_t)_radio->getLastRSSI(); r.snr_x4 = (int8_t)(_radio->getLastSNR() * 4); r.remote_snr_x4 = (int8_t)packet->payload[1]; + memcpy(r.pub_key, pub_key, PUB_KEY_SIZE); r.timestamp = getRTCClock()->getCurrentTime(); } if (_ui) _ui->notify(UIEventType::newContactMessage); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index ba2c01cb..1d6b8373 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -92,6 +92,7 @@ struct DiscoverResult { int8_t rssi; // RSSI of the response as received by us (dBm) int8_t snr_x4; // SNR of the response as received by us (dB × 4) int8_t remote_snr_x4; // SNR at which responder heard our request (dB × 4) + uint8_t pub_key[PUB_KEY_SIZE]; uint32_t timestamp; }; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 3b87942c..0d4f5e27 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -1,5 +1,6 @@ #pragma once #include +#include #ifndef M_PI #define M_PI 3.14159265358979323846 @@ -198,9 +199,13 @@ class NearbyScreen : public UIScreen { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, 10, display.width(), 1); + // public key as base64, truncated with ... by drawTextEllipsized + uint8_t b64[48]; + encode_base64(r.pub_key, PUB_KEY_SIZE, b64); + b64[44] = '\0'; + display.drawTextEllipsized(2, 12, display.width() - 4, (const char*)b64); + char buf[32]; - snprintf(buf, sizeof(buf), "Type: %s", fullType); - display.setCursor(2, 12); display.print(buf); snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); display.setCursor(2, 21); display.print(buf); From 6b2f2ecf013d554ae37cc4c7f144242da708d919 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 09:14:16 +0200 Subject: [PATCH 154/183] fix: replace base64.hpp include with local encoder to fix ODR link error base64.hpp is header-only; including it in a .h file pulled into multiple translation units caused duplicate symbol errors at link time. Replaced with a self-contained static pubKeyToBase64() method inside NearbyScreen that implements the same standard base64 encoding without any external include. Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 0d4f5e27..2e3c0c51 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -1,6 +1,5 @@ #pragma once #include -#include #ifndef M_PI #define M_PI 3.14159265358979323846 @@ -56,6 +55,21 @@ class NearbyScreen : public UIScreen { bool _ddetail; // ── helpers ────────────────────────────────────────────────────────────────── + static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) { + static const char T[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + int j = 0; + for (int i = 0; i < PUB_KEY_SIZE && j + 5 < out_len; i += 3) { + uint32_t b = ((uint32_t)key[i] << 16) + | (i+1 < PUB_KEY_SIZE ? (uint32_t)key[i+1] << 8 : 0) + | (i+2 < PUB_KEY_SIZE ? (uint32_t)key[i+2] : 0); + out[j++] = T[(b >> 18) & 63]; + out[j++] = T[(b >> 12) & 63]; + if (i+1 < PUB_KEY_SIZE) out[j++] = T[(b >> 6) & 63]; + if (i+2 < PUB_KEY_SIZE) out[j++] = T[b & 63]; + } + out[j] = '\0'; + } + static float haversineKm(int32_t lat1, int32_t lon1, int32_t lat2, int32_t lon2) { static const float DEG2RAD = (float)M_PI / 180.0f; float la1 = lat1 * (1e-6f * DEG2RAD); @@ -200,10 +214,9 @@ class NearbyScreen : public UIScreen { display.fillRect(0, 10, display.width(), 1); // public key as base64, truncated with ... by drawTextEllipsized - uint8_t b64[48]; - encode_base64(r.pub_key, PUB_KEY_SIZE, b64); - b64[44] = '\0'; - display.drawTextEllipsized(2, 12, display.width() - 4, (const char*)b64); + char b64[48]; + pubKeyToBase64(r.pub_key, b64, sizeof(b64)); + display.drawTextEllipsized(2, 12, display.width() - 4, b64); char buf[32]; From 7a49584af73ddd987fe078d488e28dfaefc68de9 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 09:43:18 +0200 Subject: [PATCH 155/183] docs: add Active Discovery section with ASCII mockups to README Co-Authored-By: Claude Sonnet 4.6 --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 2e13e099..e86f031d 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,39 @@ Browse nodes that have recently advertised on the mesh. Filter by category (Favo Use **Send my advert** (hold Enter → context menu) to broadcast your position and prompt nearby nodes to respond with theirs. +#### Active Discovery + +Press **Enter** from the Nearby screen to send a live `NODE_DISCOVER_REQ` ping. All reachable repeaters, sensors and room servers within zero-hop range respond immediately with their name, type and signal data. Results appear as 2-line boxed cards with RSSI, SNR and the remote SNR (signal quality at the responder's end). + +``` +╔═══════════════════════════════╗ +║▓▓▓▓▓▓▓ DISCOVER (2 found) ▓▓ ║ +╠═══════════════════════════════╣ +║▓▓▓▓▓▓▓▓▓▓▓▓▓ Rptr-A Rpt ▓▓▓▓║ ← selected +║▓▓▓▓▓▓▓▓ RSSI:-79 SNR:9 ▓▓▓▓▓▓▓║ +║┌────────────────────────────┐ ║ +║│▓▓▓▓▓▓▓▓▓▓▓▓ Sensor-B Snsr │ ║ +║│ RSSI:-94 SNR:3 │ ║ +║└────────────────────────────┘ ║ +╚═══════════════════════════════╝ +``` + +Navigate with **UP/DOWN**. Press **Enter** on a node to open a full-screen detail view showing the public key, RSSI, SNR, remote SNR and whether the node is already in your contacts. + +``` +╔══════════════════════════════╗ +║▓▓▓▓▓▓▓▓▓▓▓▓▓ Rptr-A ▓▓▓▓▓▓▓ ║ +╠══════════════════════════════╣ +║ Key: ABCdef12XYZ... ║ +║ RSSI: -79 dBm ║ +║ SNR: 9 dB ║ +║ Rem: 6 dB ║ +║ Status: known ║ +╚══════════════════════════════╝ +``` + +Hold **Enter** from the discovery list to rescan. Press **Cancel** or **Back** to return. + ### Tools Screen #### Auto-Advert From 5828600d48cc06e0cd5f927244fd10085709ca0c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 14:16:07 +0200 Subject: [PATCH 156/183] =?UTF-8?q?feat:=20dual=20BLE+USB=20serial=20?= =?UTF-8?q?=E2=80=94=20single=20firmware=20replaces=20separate=20BLE/USB?= =?UTF-8?q?=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DualSerialInterface: enable()/disable() control BLE only, USB always on; USB state machine not read while BLE connected to avoid partial-frame corruption; reset USB on BLE disconnect for clean reconnect - Fix -UBLE_DEBUG_LOGGING (no space) to avoid PlatformIO SCons parse error - build.sh: drop separate USB/BLE builds, dual_settings only - wio-tracker-l1-eink: enable DUAL_SERIAL in companion_radio_ble build Co-Authored-By: Claude Sonnet 4.6 --- build.sh | 2 - src/helpers/nrf52/DualSerialInterface.h | 41 +++++++++++++++------ variants/wio-tracker-l1-eink/platformio.ini | 1 + variants/wio-tracker-l1/platformio.ini | 2 +- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/build.sh b/build.sh index 506f9a39..d34877cc 100755 --- a/build.sh +++ b/build.sh @@ -231,8 +231,6 @@ build_companion_firmwares() { } build_wio_tracker_l1_firmwares() { - build_firmware "WioTrackerL1_companion_radio_usb_settings" - build_firmware "WioTrackerL1_companion_radio_ble_settings" build_firmware "WioTrackerL1_companion_radio_dual_settings" } diff --git a/src/helpers/nrf52/DualSerialInterface.h b/src/helpers/nrf52/DualSerialInterface.h index 259e41ef..6fed9e52 100644 --- a/src/helpers/nrf52/DualSerialInterface.h +++ b/src/helpers/nrf52/DualSerialInterface.h @@ -5,22 +5,29 @@ #include "SerialBLEInterface.h" // Wraps BLE + USB serial interfaces: BLE takes priority when connected, -// USB is always ready as a fallback. Both state machines run continuously. +// USB is always ready as a fallback. +// enable()/disable() control BLE only — USB is always on. +// BLE state machine is only pumped when BLE is enabled; USB is not read while BLE is connected. class DualSerialInterface : public BaseSerialInterface { SerialBLEInterface _ble; ArduinoSerialInterface _usb; uint8_t _ble_buf[MAX_FRAME_SIZE]; uint8_t _usb_buf[MAX_FRAME_SIZE]; + bool _ble_enabled; + bool _ble_was_connected; public: + DualSerialInterface() : _ble_enabled(false), _ble_was_connected(false) {} + void begin(const char* ble_prefix, char* node_name, uint32_t pin_code, Stream& usb_stream) { _ble.begin(ble_prefix, node_name, pin_code); _usb.begin(usb_stream); + _usb.enable(); // USB is always on } - void enable() override { _ble.enable(); _usb.enable(); } - void disable() override { _ble.disable(); _usb.disable(); } - bool isEnabled() const override { return _ble.isEnabled() || _usb.isEnabled(); } + void enable() override { _ble.enable(); _ble_enabled = true; } + void disable() override { _ble.disable(); _ble_enabled = false; } + bool isEnabled() const override { return _ble_enabled; } // Always true — USB is always available as fallback, so the mesh can send. bool isConnected() const override { return true; } @@ -36,16 +43,26 @@ public: } size_t checkRecvFrame(uint8_t dest[]) override { - // Always pump both state machines: BLE needs this for TX queue drain and - // advertising watchdog; USB needs it to drain its input buffer. - size_t ble_len = _ble.checkRecvFrame(_ble_buf); - size_t usb_len = _usb.checkRecvFrame(_usb_buf); + if (_ble_enabled) { + size_t ble_len = _ble.checkRecvFrame(_ble_buf); + bool ble_now = _ble.isConnected(); - if (_ble.isConnected()) { - if (ble_len > 0) { memcpy(dest, _ble_buf, ble_len); return ble_len; } - } else { - if (usb_len > 0) { memcpy(dest, _usb_buf, usb_len); return usb_len; } + if (ble_now) { + _ble_was_connected = true; + if (ble_len > 0) { memcpy(dest, _ble_buf, ble_len); return ble_len; } + return 0; // BLE active — don't read USB to keep its state machine clean + } + + if (_ble_was_connected) { + // BLE just disconnected — reset USB state machine so stale partial frames don't block it + _ble_was_connected = false; + _usb.enable(); + return 0; + } } + + size_t usb_len = _usb.checkRecvFrame(_usb_buf); + if (usb_len > 0) { memcpy(dest, _usb_buf, usb_len); return usb_len; } return 0; } }; diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 42c83477..0afe327f 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -57,6 +57,7 @@ build_flags = ${WioTrackerL1Eink.build_flags} ; -D MESH_DEBUG=1 -D UI_RECENT_LIST_SIZE=6 -D UI_SENSORS_PAGE=1 + -D DUAL_SERIAL build_src_filter = ${WioTrackerL1Eink.build_src_filter} + + diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index dc319d05..1e2325ed 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -140,7 +140,7 @@ extra_scripts = post:create-uf2.py extends = WioTrackerL1CompanionBLE build_flags = ${WioTrackerL1CompanionBLE.build_flags} -D DUAL_SERIAL=1 - -U BLE_DEBUG_LOGGING + -UBLE_DEBUG_LOGGING [env:WioTrackerL1_companion_radio_dual] extends = WioTrackerL1CompanionDual From e0312c2b6bd6f1412c797a82ef9b83176b798726 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 16:37:25 +0200 Subject: [PATCH 157/183] fix: strip @[nick] reply prefix in channel and DM history card previews Reply prefix was eating ~9 chars of display width, causing the actual message body to be ellipsized in list cards. Fullscreen view already showed the recipient correctly via the To: header line. Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/QuickMsgScreen.h | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 002895cf..6febeba3 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -88,6 +88,15 @@ class QuickMsgScreen : public UIScreen { &sensors, batt); } + // Strip "@[nick] " reply prefix from a message body for compact list display. + static const char* skipReplyPrefix(const char* text) { + if (text[0] == '@' && text[1] == '[') { + const char* close = strchr(text + 2, ']'); + if (close && close[1] == ' ' && close[2]) return close + 2; + } + return text; + } + // Build "@[nick] " prefix from a channel message text ("nick: body") into _reply_prefix. // Returns false if sender is "Me" (own message — no reply prefix needed). bool buildChannelReplyPrefix(const char* text) { @@ -642,7 +651,7 @@ public: display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } - display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(e.text)); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, y, display.width(), HIST_BOX_H); @@ -651,7 +660,7 @@ public: display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(e.text)); } } @@ -749,7 +758,7 @@ public: display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } - display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(msg_part)); } else { display.setColor(DisplayDriver::LIGHT); display.drawRect(0, y, display.width(), HIST_BOX_H); @@ -758,7 +767,7 @@ public: display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(msg_part)); } } From 38cd0fada310b47974f4e8f4b29a8c0a002b252f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 21:04:43 +0200 Subject: [PATCH 158/183] feat: add 12h/24h clock format setting (default 24h) Toggle in Settings > Display > Format. In 12h mode the clock shows h:mm AM/PM (or h:mm:ss AM/PM with seconds, no space before AM/PM). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 ++++ examples/companion_radio/NodePrefs.h | 1 + .../companion_radio/ui-new/SettingsScreen.h | 10 +++++++++ examples/companion_radio/ui-new/UITask.cpp | 21 ++++++++++++++----- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index d9e0d10c..f8f274f4 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -285,6 +285,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); if (file.available()) { file.read((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); + if (file.available()) { + file.read((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); + } } } } @@ -375,6 +378,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); + file.write((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); file.close(); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index e40f99a1..524da401 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -57,6 +57,7 @@ struct NodePrefs { // persisted to file char bot_reply_dm[140]; // auto-reply text for DM char bot_reply_ch[140]; // auto-reply text for channel uint8_t clock_hide_seconds; // 0=show HH:MM:SS/refresh 1s (default), 1=hide/refresh 60s + uint8_t clock_12h; // 0=24h (default), 1=12h with AM/PM uint8_t buzzer_auto; // 0=manual (default), 1=auto-mute when BT connected struct DmNotifEntry { uint8_t prefix[4]; uint8_t state; }; // state: 0=default,1=muted,2=force-on static const int DM_NOTIF_TABLE_MAX = 16; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index ecd59b83..8c86197f 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -13,6 +13,7 @@ class SettingsScreen : public UIScreen { AUTO_LOCK, BATT_DISPLAY, CLOCK_SECONDS, + CLOCK_FORMAT, // Sound section SECTION_SOUND, BUZZER, @@ -311,6 +312,10 @@ class SettingsScreen : public UIScreen { display.print("Seconds"); display.setCursor(VAL_X, y); display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); + } else if (item == CLOCK_FORMAT) { + display.print("Format"); + display.setCursor(VAL_X, y); + display.print((p && p->clock_12h) ? "12h" : "24h"); } else if (item == DM_FILTER) { display.print("DM"); display.setCursor(VAL_X, y); @@ -496,6 +501,11 @@ public: _dirty = true; return true; } + if (_selected == CLOCK_FORMAT && p && (left || right || enter)) { + p->clock_12h ^= 1; + _dirty = true; + return true; + } if (_selected == DM_FILTER && p && (left || right || enter)) { p->dm_show_all = p->dm_show_all ? 0 : 1; _dirty = true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 40e12c1c..8c9647a5 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -389,10 +389,16 @@ public: display.setColor(DisplayDriver::LIGHT); display.setTextSize(2); bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; - if (show_sec) - sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); - else - sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + bool h12 = _node_prefs && _node_prefs->clock_12h; + if (h12) { + int h = ti->tm_hour % 12; if (h == 0) h = 12; + const char* ap = ti->tm_hour < 12 ? "AM" : "PM"; + if (show_sec) sprintf(buf, "%d:%02d:%02d%s", h, ti->tm_min, ti->tm_sec, ap); + else sprintf(buf, "%d:%02d %s", h, ti->tm_min, ap); + } else { + if (show_sec) sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); + else sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + } display.drawTextCentered(display.width() / 2, 0, buf); display.setTextSize(1); @@ -1334,7 +1340,12 @@ void UITask::loop() { struct tm* ti = gmtime(&t); char buf[12]; _display->setTextSize(2); - sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + if (_node_prefs && _node_prefs->clock_12h) { + int h = ti->tm_hour % 12; if (h == 0) h = 12; + sprintf(buf, "%d:%02d %s", h, ti->tm_min, ti->tm_hour < 12 ? "AM" : "PM"); + } else { + sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + } _display->drawTextCentered(_display->width() / 2, 8, buf); _display->setTextSize(1); static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; From 72c07df8b3adb6319bbc41e54df9f3786ff7ac91 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 21 May 2026 22:08:28 +0200 Subject: [PATCH 159/183] fix: code review corrections in DualSerialInterface and NearbyScreen - DualSerialInterface: guard writeFrame/isWriteBusy/isBLEConnected with _ble_enabled to avoid BLE-after-disable race - NearbyScreen: initialize all fields in constructor; promote D_* layout constants to class scope; clamp _dscroll on result count change; replace hardcoded 2 with D_VISIBLE in scroll logic; allow re-scan from detail view via KEY_CONTEXT_MENU/KEY_ENTER - variant.cpp: fix inconsistent indentation in initVariant() Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/NearbyScreen.h | 28 +++++++++++++------ src/helpers/nrf52/DualSerialInterface.h | 6 ++-- variants/wio-tracker-l1/variant.cpp | 1 - 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 2e3c0c51..1f656d27 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -54,6 +54,11 @@ class NearbyScreen : public UIScreen { int _dsel; bool _ddetail; + static const int D_BOX_H = 19; + static const int D_ITEM_H = 21; + static const int D_VISIBLE = 2; + static const int D_START_Y = 11; + // ── helpers ────────────────────────────────────────────────────────────────── static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) { static const char T[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -188,11 +193,6 @@ class NearbyScreen : public UIScreen { } int renderDiscover(DisplayDriver& display) { - static const int D_BOX_H = 19; - static const int D_ITEM_H = 21; - static const int D_VISIBLE = 2; - static const int D_START_Y = 11; - if (_ddetail) { // ── full-screen detail for selected node ────────────────────────────── const DiscoverResult& r = _dresults[_dsel]; @@ -257,6 +257,9 @@ class NearbyScreen : public UIScreen { _discovering ? "Waiting for replies..." : "No nodes found"); } else { if (_dsel >= _dresult_count) _dsel = _dresult_count - 1; + if (_dscroll > _dresult_count - D_VISIBLE) + _dscroll = _dresult_count > D_VISIBLE ? _dresult_count - D_VISIBLE : 0; + if (_dscroll < 0) _dscroll = 0; for (int i = 0; i < D_VISIBLE && (_dscroll + i) < _dresult_count; i++) { int idx = _dscroll + i; bool sel = (idx == _dsel); @@ -312,7 +315,11 @@ class NearbyScreen : public UIScreen { bool handleInputDiscover(char c) { if (_ddetail) { - if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _ddetail = false; return true; } + if (c == KEY_CANCEL) { _ddetail = false; return true; } + if (c == KEY_CONTEXT_MENU || c == KEY_ENTER) { + enterDiscoverMode(); // re-scan; clears _ddetail + return true; + } return true; } if (c == KEY_CANCEL) { @@ -335,7 +342,7 @@ class NearbyScreen : public UIScreen { } if (c == KEY_DOWN && _dsel < _dresult_count - 1) { _dsel++; - if (_dsel >= _dscroll + 2) _dscroll = _dsel - 1; + if (_dsel >= _dscroll + D_VISIBLE) _dscroll = _dsel - D_VISIBLE + 1; return true; } return true; @@ -343,8 +350,11 @@ class NearbyScreen : public UIScreen { public: NearbyScreen(UITask* task) - : _task(task), _filter(0), _discover_mode(false), _discovering(false), - _ddetail(false), _dsel(0) {} + : _task(task), _count(0), _sel(0), _scroll(0), _detail(false), + _own_lat(0), _own_lon(0), _own_gps(false), _filter(0), + _detail_refresh_ms(0), + _discover_mode(false), _discovering(false), _discover_started_ms(0), + _dresult_count(0), _dscroll(0), _dsel(0), _ddetail(false) {} void enter() { _sel = _scroll = 0; diff --git a/src/helpers/nrf52/DualSerialInterface.h b/src/helpers/nrf52/DualSerialInterface.h index 6fed9e52..fe239552 100644 --- a/src/helpers/nrf52/DualSerialInterface.h +++ b/src/helpers/nrf52/DualSerialInterface.h @@ -32,14 +32,14 @@ public: // Always true — USB is always available as fallback, so the mesh can send. bool isConnected() const override { return true; } // True only when a BLE companion app is paired and connected. - bool isBLEConnected() const override { return _ble.isConnected(); } + bool isBLEConnected() const override { return _ble_enabled && _ble.isConnected(); } bool isWriteBusy() const override { - return _ble.isConnected() ? _ble.isWriteBusy() : _usb.isWriteBusy(); + return (_ble_enabled && _ble.isConnected()) ? _ble.isWriteBusy() : _usb.isWriteBusy(); } size_t writeFrame(const uint8_t src[], size_t len) override { - return _ble.isConnected() ? _ble.writeFrame(src, len) : _usb.writeFrame(src, len); + return (_ble_enabled && _ble.isConnected()) ? _ble.writeFrame(src, len) : _usb.writeFrame(src, len); } size_t checkRecvFrame(uint8_t dest[]) override { diff --git a/variants/wio-tracker-l1/variant.cpp b/variants/wio-tracker-l1/variant.cpp index d5a77736..d09e4a90 100644 --- a/variants/wio-tracker-l1/variant.cpp +++ b/variants/wio-tracker-l1/variant.cpp @@ -78,5 +78,4 @@ void initVariant() { // set buzzer pin as output and set it low pinMode(12, OUTPUT); digitalWrite(12, LOW); - pinMode(12, OUTPUT); } From a115916d8f61c8e129821f292ba8ddb34653a78c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 09:29:38 +0200 Subject: [PATCH 160/183] =?UTF-8?q?feat:=20runtime=20font=20switcher=20?= =?UTF-8?q?=E2=80=94=20Default=20vs=20Lemon=20in=20Settings=20>=20Display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Lemon font (LemonFont.h, LemonIcons.h) to the standard build alongside the Adafruit built-in font; ~101 KB added to flash - SH1106Display: add _use_lemon flag + setLemonFont(bool) to switch at runtime; print() and getTextWidth() use Lemon path when active; translateUTF8ToBlocks() passes UTF-8 through unchanged (Lemon supports full Unicode U+0020-U+04FF) vs transliterating for the default font - DisplayDriver: add getCharWidth()/getLineHeight() virtuals (defaults 6/8); SH1106Display returns 5/9 for Lemon, 6/8 for default; add setLemonFont() no-op virtual; fix orphaned UTF-8 leading byte in drawTextEllipsized - FullscreenMsgView: replace fixed-char-count word-wrap with pixel-accurate wrap using display.getTextWidth(); use getLineHeight() for spacing so Lemon (9 px) and default (8 px) both render correctly - NodePrefs: add use_lemon_font field (0=default, 1=Lemon) - SettingsScreen: add "Font" item in Display section (Default / Lemon), calls UITask::applyFont() on change - UITask: add applyFont() that calls display->setLemonFont(use_lemon_font); called at startup (begin()) and when the setting changes Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/NodePrefs.h | 1 + .../ui-new/FullscreenMsgView.h | 81 +- .../companion_radio/ui-new/SettingsScreen.h | 11 + examples/companion_radio/ui-new/UITask.cpp | 7 + examples/companion_radio/ui-new/UITask.h | 1 + src/helpers/ui/DisplayDriver.h | 7 + src/helpers/ui/LemonFont.h | 1523 +++++++++++++++++ src/helpers/ui/LemonIcons.h | 19 + src/helpers/ui/SH1106Display.cpp | 99 +- src/helpers/ui/SH1106Display.h | 12 +- 10 files changed, 1732 insertions(+), 29 deletions(-) create mode 100644 src/helpers/ui/LemonFont.h create mode 100644 src/helpers/ui/LemonIcons.h diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 524da401..4f5b878a 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -78,4 +78,5 @@ struct NodePrefs { // persisted to file struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 static const int DM_MELODY_TABLE_MAX = 16; DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; + uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index ae32a24a..d23aa996 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -3,10 +3,8 @@ #include #include -static const int FS_CHARS = 20; -static const int FS_LINE_H = 8; -static const int FS_START_Y = 12; -static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; +static const int FS_CHARS_MAX = 80; // max bytes per wrapped line +static const int FS_START_Y = 12; struct FullscreenMsgView { int scroll; @@ -18,23 +16,49 @@ struct FullscreenMsgView { void begin() { scroll = 0; active = true; } - static int wrapLines(const char* text, char out[][FS_CHARS + 1], int max_lines) { + // Pixel-accurate word-wrap: breaks at last space that fits within max_px, + // hard-breaks long words. Works for both fixed- and variable-width fonts. + static int wrapLines(DisplayDriver& display, const char* text, int max_px, + char out[][FS_CHARS_MAX], int max_lines) { int count = 0; - const char* p = text; - while (*p && count < max_lines) { - int len = strlen(p); - if (len <= FS_CHARS) { - strncpy(out[count++], p, FS_CHARS); - out[count - 1][len] = '\0'; - break; + const char* seg = text; + while (*seg && count < max_lines) { + const char* p = seg; + const char* last_sp = nullptr; + const char* last_fit = seg; + + while (*p && (p - seg) < FS_CHARS_MAX - 1) { + uint8_t b0 = (uint8_t)*p; + int cb = (b0 < 0x80) ? 1 : (b0 < 0xE0) ? 2 : (b0 < 0xF0) ? 3 : 4; + if ((p - seg) + cb >= FS_CHARS_MAX) break; + + char buf[FS_CHARS_MAX]; + int n = (int)(p - seg) + cb; + memcpy(buf, seg, n); buf[n] = '\0'; + if (display.getTextWidth(buf) > max_px && p > seg) break; + + if (*p == ' ') last_sp = p; + p += cb; + last_fit = p; } - int brk = FS_CHARS; - for (int i = FS_CHARS - 1; i > 0; i--) { - if (p[i] == ' ') { brk = i; break; } + + const char* end; + const char* next_seg; + if (!*p) { + end = p; next_seg = p; + } else if (last_sp && last_sp > seg) { + end = last_sp; next_seg = last_sp + 1; + } else { + end = last_fit; next_seg = last_fit; } - strncpy(out[count], p, brk); - out[count++][brk] = '\0'; - p += brk + (p[brk] == ' ' ? 1 : 0); + + int len = (int)(end - seg); + if (len <= 0) { seg = *seg ? seg + 1 : seg; continue; } + if (len > FS_CHARS_MAX - 1) len = FS_CHARS_MAX - 1; + memcpy(out[count], seg, len); + out[count][len] = '\0'; + count++; + seg = next_seg; } return count; } @@ -42,6 +66,8 @@ struct FullscreenMsgView { int render(DisplayDriver& display, const char* sender, const char* text, bool has_prev, bool has_next) { display.setTextSize(1); + const int lineH = display.getLineHeight(); + const int max_px = display.width() - 6; // detect @recipient at start of message char to_nick[32] = ""; @@ -59,7 +85,7 @@ struct FullscreenMsgView { const int header_h = to_nick[0] ? 20 : 10; const int startY = header_h + 2; - const int visible = (display.height() - startY - FS_LINE_H) / FS_LINE_H; + const int visible = (display.height() - startY - lineH) / lineH; display.setColor(DisplayDriver::LIGHT); display.fillRect(0, 0, display.width(), header_h); @@ -75,35 +101,36 @@ struct FullscreenMsgView { char trans_text[512]; display.translateUTF8ToBlocks(trans_text, body, sizeof(trans_text)); - char lines[12][FS_CHARS + 1]; - int lcount = wrapLines(trans_text, lines, 12); + char lines[12][FS_CHARS_MAX]; + int lcount = wrapLines(display, trans_text, max_px, lines, 12); int max_scroll = lcount > visible ? lcount - visible : 0; if (scroll > max_scroll) scroll = max_scroll; for (int i = 0; i < visible && (scroll + i) < lcount; i++) { - display.setCursor(0, startY + i * FS_LINE_H); + display.setCursor(0, startY + i * lineH); display.print(lines[scroll + i]); } if (scroll > 0) { display.setColor(DisplayDriver::DARK); - display.fillRect(display.width() - 6, startY, 6, FS_LINE_H); + display.fillRect(display.width() - 6, startY, 6, lineH); display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, startY); display.print("^"); } if (scroll < max_scroll) { display.setColor(DisplayDriver::DARK); - display.fillRect(display.width() - 6, startY + (visible - 1) * FS_LINE_H, 6, FS_LINE_H); + display.fillRect(display.width() - 6, startY + (visible - 1) * lineH, 6, lineH); display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, startY + (visible - 1) * FS_LINE_H); + display.setCursor(display.width() - 6, startY + (visible - 1) * lineH); display.print("v"); } + const int nav_y = display.height() - lineH; if (has_next) { - display.setCursor(0, 56); + display.setCursor(0, nav_y); display.print("<"); } if (has_prev) { - display.setCursor(display.width() - 6, 56); + display.setCursor(display.width() - 6, nav_y); display.print(">"); } return 2000; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 8c86197f..5102c8b1 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -14,6 +14,7 @@ class SettingsScreen : public UIScreen { BATT_DISPLAY, CLOCK_SECONDS, CLOCK_FORMAT, + FONT, // Sound section SECTION_SOUND, BUZZER, @@ -316,6 +317,10 @@ class SettingsScreen : public UIScreen { display.print("Format"); display.setCursor(VAL_X, y); display.print((p && p->clock_12h) ? "12h" : "24h"); + } else if (item == FONT) { + display.print("Font"); + display.setCursor(VAL_X, y); + display.print((p && p->use_lemon_font) ? "Lemon" : "Default"); } else if (item == DM_FILTER) { display.print("DM"); display.setCursor(VAL_X, y); @@ -506,6 +511,12 @@ public: _dirty = true; return true; } + if (_selected == FONT && p && (left || right || enter)) { + p->use_lemon_font ^= 1; + _task->applyFont(); + _dirty = true; + return true; + } if (_selected == DM_FILTER && p && (left || right || enter)) { p->dm_show_all = p->dm_show_all ? 0 : 1; _dirty = true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 8c9647a5..c2a59b27 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -816,6 +816,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no setCurrScreen(splash); applyBrightness(); + applyFont(); } void UITask::gotoSettingsScreen() { @@ -1557,6 +1558,12 @@ void UITask::applyBrightness() { } } +void UITask::applyFont() { + if (_display != NULL && _node_prefs != NULL) { + _display->setLemonFont(_node_prefs->use_lemon_font != 0); + } +} + void UITask::setBrightnessLevel(uint8_t level) { if (_node_prefs == NULL) return; if (level > 4) level = 4; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 29366166..80b57c43 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -158,6 +158,7 @@ public: uint8_t getBuzzerVolume() const { return _node_prefs ? _node_prefs->buzzer_volume : 4; } void applyTxPower(); void applyGPSInterval(); + void applyFont(); uint32_t autoOffMillis() const { if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0; return (uint32_t)_node_prefs->auto_off_secs * 1000UL; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index d6cf198b..ffda2a12 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -27,6 +27,9 @@ public: virtual void drawRect(int x, int y, int w, int h) = 0; virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0; virtual uint16_t getTextWidth(const char* str) = 0; + virtual int getCharWidth() const { return 6; } // typical character advance width (px) + virtual int getLineHeight() const { return 8; } // pixel rows per text line + virtual void setLemonFont(bool) { } // no-op; overridden by displays that support Lemon virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise) int w = getTextWidth(str); setCursor(mid_x - w/2, y); @@ -182,6 +185,10 @@ public: while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) { temp_str[--str_len] = 0; } + // Strip orphaned UTF-8 leading byte left by byte-at-a-time trimming above. + while (str_len > 0 && ((uint8_t)temp_str[str_len - 1] & 0xC0) == 0xC0) { + temp_str[--str_len] = 0; + } strcat(temp_str, ellipsis); setCursor(x, y); diff --git a/src/helpers/ui/LemonFont.h b/src/helpers/ui/LemonFont.h new file mode 100644 index 00000000..3a81d691 --- /dev/null +++ b/src/helpers/ui/LemonFont.h @@ -0,0 +1,1523 @@ +// Lemon font, converted from lemon.bdf +// Range: U+0020–U+04FF (1074 BDF glyphs + 174 placeholders) +// Generated by bdf2lemon.py +#pragma once +#include + +static const uint8_t lemonBitmaps[] PROGMEM = { + 0xF4, 0xB6, 0x80, 0x56, 0xD5, 0xB5, 0x00, 0x27, 0x86, 0x1E, 0x20, 0xCD, 0x24, 0xB3, 0x45, 0x11, + 0x59, 0x34, 0xE0, 0x2A, 0x49, 0x11, 0x88, 0x92, 0x54, 0x96, 0xF6, 0x90, 0x21, 0x3E, 0x42, 0x00, + 0x58, 0xF0, 0xC0, 0x11, 0x22, 0x44, 0x88, 0x69, 0xDB, 0x96, 0x75, 0x50, 0xE1, 0x16, 0x8F, 0xE1, + 0x61, 0x1E, 0x99, 0x97, 0x11, 0xF8, 0xE1, 0x1E, 0x68, 0xE9, 0x96, 0xF1, 0x22, 0x44, 0x69, 0x69, + 0x96, 0x69, 0x97, 0x16, 0xD8, 0x51, 0x60, 0x2A, 0x22, 0xE3, 0x80, 0x88, 0xA8, 0xF4, 0xA0, 0x80, + 0x69, 0xBB, 0x87, 0x69, 0x9F, 0x99, 0xE9, 0xE9, 0x9E, 0x78, 0x88, 0x87, 0xE9, 0x99, 0x9E, 0xF8, + 0xE8, 0x8F, 0xF8, 0xE8, 0x88, 0x78, 0x8B, 0x97, 0x99, 0xF9, 0x99, 0xE9, 0x25, 0xC0, 0x71, 0x11, + 0x96, 0x9A, 0xCA, 0x99, 0x88, 0x88, 0x8F, 0x9F, 0xF9, 0x99, 0x9D, 0xDB, 0xB9, 0x69, 0x99, 0x96, + 0xE9, 0x9E, 0x88, 0x69, 0x99, 0x96, 0x30, 0xE9, 0x9E, 0x99, 0x78, 0x61, 0x1E, 0xF9, 0x08, 0x42, + 0x10, 0x99, 0x99, 0x97, 0x99, 0x96, 0x66, 0x99, 0x9F, 0xF9, 0x99, 0x69, 0x99, 0x8A, 0x88, 0x42, + 0x10, 0xF1, 0x24, 0x8F, 0xF2, 0x49, 0x27, 0x88, 0x44, 0x22, 0x11, 0xE4, 0x92, 0x4F, 0x22, 0xA2, + 0xF0, 0xA4, 0x79, 0x9B, 0x50, 0x88, 0xE9, 0x99, 0xE0, 0x78, 0x88, 0x70, 0x11, 0x79, 0x9B, 0x50, + 0x69, 0xF8, 0x70, 0x73, 0xC9, 0x20, 0x79, 0x9B, 0x51, 0x60, 0x88, 0xE9, 0x99, 0x90, 0x4D, 0x54, + 0x21, 0x92, 0x49, 0xC0, 0x88, 0x9A, 0xE9, 0x90, 0xD5, 0x54, 0x9F, 0xB9, 0x90, 0xE9, 0x99, 0x90, + 0x69, 0x99, 0x60, 0xE9, 0x99, 0xE8, 0x80, 0x79, 0x99, 0x71, 0x10, 0xBC, 0x88, 0x80, 0x78, 0x61, + 0xE0, 0x5D, 0x24, 0x40, 0x99, 0x99, 0x70, 0x99, 0x96, 0x60, 0x99, 0x9F, 0x90, 0x99, 0x69, 0x90, + 0x99, 0x99, 0x71, 0x60, 0xF2, 0x48, 0xF0, 0x29, 0x6C, 0x91, 0xFF, 0x89, 0x36, 0x94, 0x45, 0x6A, + 0x20, 0x6F, 0xF6, 0xBC, 0x17, 0xAA, 0xA7, 0x40, 0x32, 0x11, 0xC4, 0x27, 0xC0, 0x8B, 0x94, 0xA7, + 0x44, 0x8A, 0x88, 0xE2, 0x38, 0x80, 0xEE, 0x34, 0x69, 0x96, 0x2C, 0xA0, 0x7A, 0x1B, 0x69, 0xB6, + 0x17, 0x80, 0x79, 0x97, 0x5A, 0x50, 0xF8, 0x42, 0xE0, 0x7A, 0x1B, 0xED, 0xAE, 0x17, 0x80, 0xE0, + 0x55, 0x00, 0x27, 0x20, 0x70, 0xC5, 0x4E, 0xC7, 0x1C, 0x60, 0x99, 0x99, 0xE8, 0x7D, 0x6A, 0xD2, + 0x94, 0xF0, 0x47, 0x80, 0x75, 0x69, 0x96, 0xA2, 0xA8, 0x46, 0x10, 0xA3, 0x29, 0xE2, 0x46, 0x10, + 0xE0, 0x88, 0xE0, 0xC0, 0x84, 0x0A, 0xD8, 0xA3, 0xC2, 0x41, 0x4B, 0xC0, 0x42, 0x69, 0x9F, 0x99, + 0x24, 0x69, 0x9F, 0x99, 0x25, 0x06, 0x99, 0xF9, 0x90, 0x5A, 0x69, 0x9F, 0x99, 0x48, 0x0C, 0x94, + 0xBD, 0x29, 0x69, 0x69, 0x9F, 0x99, 0x7E, 0x49, 0xBC, 0x92, 0x70, 0x78, 0x88, 0x87, 0x24, 0x42, + 0xF8, 0xE8, 0x8F, 0x24, 0xF8, 0xE8, 0x8F, 0x4A, 0xF8, 0xE8, 0x8F, 0xA0, 0xF8, 0xE8, 0x8F, 0x88, + 0x74, 0x92, 0xE0, 0x28, 0x74, 0x92, 0xE0, 0x54, 0x74, 0x92, 0xE0, 0xA3, 0xA4, 0x97, 0x72, 0x7A, + 0x94, 0xB8, 0x2A, 0x80, 0xE4, 0xA5, 0x29, 0x84, 0x06, 0x99, 0x96, 0x24, 0x06, 0x99, 0x96, 0x4A, + 0x06, 0x99, 0x96, 0x5A, 0x06, 0x99, 0x96, 0x48, 0x0C, 0x94, 0xA4, 0xC0, 0xAA, 0x80, 0x35, 0x65, + 0x9A, 0x6A, 0xC0, 0x84, 0x99, 0x99, 0x97, 0x12, 0x99, 0x99, 0x97, 0x25, 0x09, 0x99, 0x99, 0x70, + 0x50, 0x99, 0x99, 0x97, 0x11, 0x22, 0xA2, 0x10, 0x84, 0x88, 0xE9, 0x9E, 0x80, 0x69, 0xA9, 0x9A, + 0x80, 0x84, 0x07, 0x99, 0xB5, 0x24, 0x07, 0x99, 0xB5, 0x4A, 0x07, 0x99, 0xB5, 0x5A, 0x07, 0x99, + 0xB5, 0x90, 0x79, 0x9B, 0x50, 0x20, 0x79, 0x9B, 0x50, 0x75, 0x6F, 0x45, 0x80, 0x78, 0x88, 0x72, + 0x40, 0x84, 0x06, 0x9F, 0x87, 0x24, 0x06, 0x9F, 0x87, 0x4A, 0x06, 0x9F, 0x87, 0xA0, 0x69, 0xF8, + 0x70, 0x93, 0x55, 0x28, 0x64, 0x92, 0x54, 0x64, 0x92, 0xA3, 0x24, 0x90, 0xA6, 0x97, 0x99, 0x96, + 0x5A, 0x0E, 0x99, 0x99, 0x41, 0x00, 0x64, 0xA5, 0x26, 0x12, 0x06, 0x99, 0x96, 0x4A, 0x06, 0x99, + 0x96, 0x5A, 0x06, 0x99, 0x96, 0x90, 0x69, 0x99, 0x60, 0x20, 0x3E, 0x02, 0x00, 0x35, 0x67, 0x9A, + 0xB0, 0x84, 0x09, 0x99, 0x97, 0x11, 0x00, 0x94, 0xA5, 0x27, 0x4A, 0x09, 0x99, 0x97, 0x50, 0x12, + 0x94, 0xA4, 0xE0, 0x24, 0x09, 0x99, 0x97, 0x16, 0x88, 0xE9, 0x99, 0xE8, 0x80, 0x50, 0x99, 0x99, + 0x71, 0x60, 0xF0, 0x69, 0x9F, 0x99, 0xF0, 0x79, 0x9B, 0x50, 0x96, 0x06, 0x99, 0xF9, 0x90, 0x96, + 0x07, 0x99, 0xB5, 0x64, 0xA5, 0xE9, 0x48, 0x20, 0x74, 0xA5, 0x65, 0x04, 0x24, 0x07, 0x88, 0x88, + 0x70, 0x24, 0x07, 0x88, 0x87, 0x25, 0x07, 0x88, 0x88, 0x70, 0x25, 0x07, 0x88, 0x87, 0x20, 0x78, + 0x88, 0x87, 0x20, 0x78, 0x88, 0x70, 0x52, 0x07, 0x88, 0x88, 0x70, 0x52, 0x07, 0x88, 0x87, 0xA4, + 0x0E, 0x99, 0x99, 0xE0, 0x04, 0x51, 0x9C, 0x92, 0x4B, 0x14, 0x72, 0x7A, 0x94, 0xB8, 0x38, 0x9D, + 0x29, 0x59, 0x40, 0x60, 0xF8, 0xE8, 0x8F, 0xF0, 0x69, 0xF8, 0x70, 0x96, 0x0F, 0x8E, 0x88, 0xF0, + 0x96, 0x06, 0x9F, 0x87, 0x40, 0xF8, 0xE8, 0x8F, 0x40, 0x69, 0xF8, 0x70, 0xF4, 0x39, 0x08, 0x78, + 0x20, 0x69, 0xF8, 0x61, 0xA4, 0x0F, 0x8E, 0x88, 0xF0, 0xA4, 0x06, 0x9F, 0x87, 0x25, 0x07, 0x88, + 0xB9, 0x70, 0x25, 0x07, 0x99, 0xB5, 0x16, 0x96, 0x07, 0x88, 0xB9, 0x70, 0x96, 0x07, 0x99, 0xB5, + 0x16, 0x20, 0x78, 0x8B, 0x97, 0x20, 0x79, 0x9B, 0x51, 0x60, 0x78, 0x8B, 0x97, 0x24, 0x24, 0x07, + 0x99, 0xB5, 0x16, 0x25, 0x09, 0x9F, 0x99, 0x90, 0x25, 0x88, 0xE9, 0x99, 0x90, 0xFD, 0x27, 0x92, + 0x49, 0x20, 0xF2, 0x1C, 0x94, 0xA5, 0x20, 0x5A, 0x07, 0x22, 0x22, 0x70, 0x5A, 0x06, 0x22, 0x22, + 0xE3, 0xA4, 0x97, 0xE3, 0x24, 0x90, 0xA8, 0x74, 0x92, 0xE0, 0xA8, 0x64, 0x92, 0xE9, 0x25, 0xE2, + 0x4D, 0x5E, 0x40, 0x43, 0xA4, 0x97, 0xD5, 0x40, 0xFD, 0x14, 0x51, 0x57, 0xA0, 0x48, 0x36, 0x94, + 0xA5, 0x26, 0x25, 0x07, 0x11, 0x19, 0x60, 0x25, 0x06, 0x22, 0x22, 0x2C, 0x9A, 0xCA, 0x99, 0x48, + 0x88, 0x9A, 0xE9, 0x94, 0x80, 0x9A, 0xE9, 0x90, 0x12, 0x88, 0x88, 0x8F, 0x12, 0xC4, 0x44, 0x44, + 0x40, 0x88, 0x88, 0x8F, 0x24, 0x64, 0x92, 0x4A, 0x80, 0x39, 0x88, 0x88, 0xF0, 0x31, 0xC4, 0x44, + 0x44, 0x42, 0x88, 0x8A, 0x8F, 0xC4, 0x44, 0x54, 0x40, 0x8A, 0xC8, 0x8F, 0xC9, 0x3C, 0x90, 0x24, + 0x09, 0xDD, 0xBB, 0x90, 0x24, 0x0E, 0x99, 0x99, 0x9D, 0xDB, 0xBD, 0x48, 0x24, 0x0E, 0x99, 0x9D, + 0x48, 0x52, 0x9D, 0xDB, 0xB9, 0x52, 0x0E, 0x99, 0x99, 0x88, 0x0E, 0x99, 0x99, 0x9D, 0xDB, 0xB9, + 0x16, 0x24, 0x0E, 0x99, 0x99, 0x16, 0xF0, 0x69, 0x99, 0x96, 0xF0, 0x69, 0x99, 0x60, 0x96, 0x06, + 0x99, 0x99, 0x60, 0x96, 0x06, 0x99, 0x96, 0x5A, 0x06, 0x99, 0x99, 0x60, 0x5A, 0x06, 0x99, 0x96, + 0x7E, 0x49, 0xA4, 0x91, 0xF0, 0x75, 0x6F, 0x47, 0x80, 0x24, 0xE9, 0x9E, 0x99, 0x24, 0x0B, 0xC8, + 0x88, 0xE9, 0x9E, 0x99, 0x48, 0xBC, 0x88, 0x84, 0x80, 0xA4, 0xE9, 0x9E, 0x99, 0xA4, 0x0B, 0xC8, + 0x88, 0x24, 0x07, 0x86, 0x11, 0xE0, 0x24, 0x07, 0x86, 0x1E, 0x25, 0x07, 0x86, 0x11, 0xE0, 0x25, + 0x07, 0x86, 0x1E, 0x78, 0x61, 0x1E, 0x22, 0x78, 0x61, 0xE2, 0x20, 0x52, 0x07, 0x86, 0x11, 0xE0, + 0x52, 0x07, 0x86, 0x1E, 0xF9, 0x08, 0x42, 0x11, 0x00, 0x5D, 0x24, 0x50, 0x51, 0x01, 0xF2, 0x10, + 0x84, 0x20, 0x31, 0x4E, 0x44, 0x42, 0xF9, 0x08, 0xE2, 0x10, 0x5D, 0x74, 0x40, 0x5A, 0x09, 0x99, + 0x99, 0x70, 0x5A, 0x09, 0x99, 0x97, 0xF0, 0x99, 0x99, 0x97, 0xF0, 0x99, 0x99, 0x70, 0x96, 0x09, + 0x99, 0x99, 0x70, 0x96, 0x09, 0x99, 0x97, 0x25, 0x29, 0x99, 0x99, 0x70, 0x25, 0x29, 0x99, 0x97, + 0x4C, 0x81, 0x29, 0x4A, 0x52, 0x70, 0x4C, 0x81, 0x29, 0x4A, 0x4E, 0x99, 0x99, 0x97, 0x42, 0x99, + 0x99, 0x74, 0x20, 0x25, 0x09, 0x99, 0x9F, 0x90, 0x25, 0x09, 0x99, 0xF9, 0x22, 0x81, 0x15, 0x10, + 0x84, 0x20, 0x25, 0x09, 0x99, 0x97, 0x16, 0x50, 0x22, 0xA2, 0x10, 0x84, 0x24, 0x0F, 0x12, 0x48, + 0xF0, 0x24, 0x0F, 0x24, 0x8F, 0x20, 0xF1, 0x24, 0x8F, 0x20, 0xF2, 0x48, 0xF0, 0x52, 0x0F, 0x12, + 0x48, 0xF0, 0x52, 0x0F, 0x24, 0x8F, 0x34, 0xC4, 0x44, 0x40, 0xE2, 0x1C, 0x94, 0xA5, 0xC0, 0xF2, + 0x5C, 0x94, 0xB8, 0xE5, 0x21, 0xE8, 0xC6, 0x3E, 0xEA, 0x8E, 0x99, 0x9E, 0x43, 0x07, 0x91, 0x45, + 0x17, 0x80, 0x46, 0x1C, 0x94, 0xA5, 0xC0, 0xE1, 0x11, 0x1E, 0x0B, 0xA1, 0x08, 0x41, 0xC0, 0x78, + 0x88, 0x70, 0x72, 0x7A, 0x94, 0xB8, 0x7A, 0x92, 0x49, 0x24, 0xE0, 0x39, 0x42, 0xF8, 0xC6, 0x6D, + 0x75, 0x17, 0x99, 0xB5, 0x7C, 0x4E, 0x10, 0xBC, 0xE1, 0xF9, 0x60, 0x69, 0x48, 0x96, 0x7A, 0x1C, + 0x84, 0x22, 0x00, 0x34, 0x74, 0x44, 0x48, 0x77, 0x16, 0x05, 0x89, 0x0E, 0x00, 0x8A, 0x94, 0x42, + 0x29, 0x44, 0x84, 0x33, 0x5A, 0xC8, 0x92, 0x49, 0x50, 0xE3, 0xA4, 0x97, 0x95, 0x71, 0x49, 0x48, + 0x68, 0x9A, 0xE9, 0x90, 0xC9, 0x74, 0x90, 0x4E, 0x66, 0x99, 0xAD, 0x6B, 0x57, 0x80, 0x4B, 0x5E, + 0xB4, 0xC0, 0xE9, 0x99, 0x10, 0x69, 0xF9, 0x60, 0x04, 0x16, 0xA4, 0x92, 0x49, 0x18, 0x04, 0x10, + 0x98, 0x92, 0x49, 0x18, 0x09, 0x99, 0x65, 0x96, 0x56, 0x40, 0x09, 0x99, 0x65, 0x95, 0x90, 0xFA, + 0x92, 0x4E, 0x20, 0x80, 0x0C, 0x5E, 0x24, 0x92, 0x4E, 0x20, 0x80, 0x87, 0x25, 0x2E, 0x52, 0x41, + 0xE1, 0x68, 0x87, 0xE1, 0x68, 0x70, 0xFA, 0x48, 0x88, 0xFC, 0x45, 0x18, 0x42, 0x10, 0x83, 0x5D, + 0x24, 0x50, 0xF9, 0x48, 0x42, 0x10, 0x2B, 0xA4, 0x88, 0xFD, 0x48, 0x42, 0x10, 0x0C, 0xE5, 0x29, + 0x49, 0xC0, 0x0C, 0xE5, 0x29, 0x38, 0xDA, 0xA3, 0x18, 0xC5, 0xC0, 0x94, 0x63, 0x18, 0xC5, 0xC0, + 0x06, 0x35, 0x08, 0x20, 0x82, 0x00, 0x0C, 0xE5, 0x29, 0x38, 0x4C, 0xF0, 0xBE, 0x88, 0x78, 0xF2, + 0xF8, 0xF0, 0xF1, 0x21, 0x96, 0xF8, 0x48, 0x96, 0xF8, 0x48, 0x96, 0x78, 0x21, 0x02, 0x4A, 0xC4, + 0x4E, 0x74, 0x43, 0xF2, 0x23, 0xE0, 0xFA, 0x1C, 0x10, 0xC5, 0xC0, 0xFA, 0x1C, 0x18, 0xB8, 0x23, + 0x88, 0x20, 0xC5, 0xC0, 0xAD, 0x9A, 0xC8, 0x80, 0x24, 0x92, 0x49, 0x24, 0xB6, 0xDB, 0x6D, 0xB4, + 0x21, 0x09, 0xF2, 0x13, 0xE4, 0x21, 0x00, 0x24, 0x92, 0x08, 0x14, 0x2C, 0x2F, 0xA6, 0xAA, 0xAC, + 0xDC, 0x17, 0x2A, 0x2F, 0xA6, 0xAB, 0x37, 0x20, 0xD2, 0x88, 0x7E, 0x9A, 0xAC, 0x7C, 0x8C, 0x63, + 0x18, 0xD7, 0x40, 0x8C, 0x27, 0x18, 0xC7, 0xA1, 0x30, 0xCA, 0x16, 0x94, 0xA5, 0xA1, 0x30, 0xAD, + 0x7B, 0xDE, 0xD6, 0xC0, 0xAD, 0x3F, 0xDE, 0xD6, 0xA1, 0x30, 0x08, 0x37, 0x5A, 0xD6, 0xA1, 0x30, + 0x96, 0x06, 0x9F, 0x99, 0x96, 0x07, 0x99, 0xB5, 0xA8, 0x74, 0x97, 0xA8, 0x64, 0x92, 0x96, 0x06, + 0x99, 0x96, 0x96, 0x06, 0x99, 0x60, 0x52, 0x99, 0x99, 0x97, 0x96, 0x09, 0x99, 0x70, 0xF2, 0x99, + 0x99, 0x97, 0xF6, 0x09, 0x99, 0x97, 0x24, 0xD9, 0x99, 0x97, 0x26, 0x09, 0x99, 0x97, 0x52, 0xD9, + 0x99, 0x97, 0x52, 0x50, 0x99, 0x97, 0x42, 0xB9, 0x99, 0x97, 0x46, 0x09, 0x99, 0x97, 0xE1, 0xF9, + 0x60, 0xFA, 0x06, 0x9F, 0x99, 0xFA, 0x07, 0x99, 0xB5, 0xF6, 0x06, 0x9F, 0x99, 0xF6, 0x07, 0x99, + 0xB5, 0x70, 0x1F, 0x4A, 0x7A, 0x94, 0xB8, 0x70, 0x1D, 0x5B, 0xD1, 0xE0, 0x78, 0x8B, 0xB7, 0x79, + 0x9B, 0x75, 0x60, 0x52, 0x07, 0x8B, 0x97, 0x52, 0x07, 0x99, 0xB5, 0x16, 0x9A, 0xCA, 0x99, 0x88, + 0x9A, 0xE9, 0x90, 0x69, 0x99, 0x96, 0x30, 0x69, 0x99, 0x63, 0x52, 0x69, 0x99, 0x96, 0x30, 0x52, + 0x06, 0x99, 0x96, 0x30, 0x52, 0xF1, 0x21, 0x96, 0x52, 0x0F, 0x12, 0x19, 0x60, 0x52, 0x06, 0x22, + 0x22, 0x2C, 0xDE, 0x9A, 0xAA, 0xAA, 0xCD, 0xC0, 0xC2, 0x8B, 0xE9, 0xAA, 0xCD, 0xC0, 0x20, 0x87, + 0xE9, 0xAA, 0xC7, 0xC0, 0x52, 0x78, 0x8B, 0x97, 0x52, 0x07, 0x99, 0xB5, 0x16, 0xA5, 0x2B, 0xDA, + 0xD6, 0x40, 0xB6, 0x63, 0x2A, 0x62, 0x00, 0x42, 0x9D, 0xDB, 0xB9, 0x42, 0x0E, 0x99, 0x99, 0x6C, + 0x46, 0x9F, 0x99, 0x6C, 0x47, 0x99, 0xB5, 0x10, 0x87, 0xE4, 0x9B, 0xC9, 0x27, 0x11, 0x1D, 0x5B, + 0xD1, 0x60, 0x08, 0x53, 0x56, 0x59, 0xA6, 0xAC, 0x08, 0x50, 0x0D, 0x59, 0xE6, 0xAC, 0xA5, 0x69, + 0x9F, 0x99, 0xA5, 0x07, 0x99, 0xB5, 0x69, 0x69, 0x9F, 0x99, 0x69, 0x07, 0x99, 0xB5, 0xA5, 0xF8, + 0xE8, 0x8F, 0xA5, 0x06, 0x9F, 0x87, 0x69, 0xF8, 0xE8, 0x8F, 0x69, 0x69, 0xF8, 0x70, 0xA5, 0xE4, + 0x44, 0x4E, 0xA5, 0x06, 0x22, 0x22, 0x74, 0x40, 0xE2, 0x10, 0x8E, 0x69, 0x00, 0x62, 0x22, 0xA2, + 0x80, 0x64, 0xA5, 0x29, 0x30, 0xA2, 0x80, 0x64, 0xA5, 0x26, 0x69, 0x06, 0x99, 0x99, 0x60, 0x69, + 0x06, 0x99, 0x96, 0xA5, 0xE9, 0x9E, 0x99, 0xA5, 0x0B, 0xC8, 0x88, 0x69, 0xE9, 0x9E, 0x99, 0x69, + 0x0B, 0xC8, 0x88, 0xA5, 0x89, 0x99, 0x97, 0xA5, 0x09, 0x99, 0x97, 0x69, 0x09, 0x99, 0x97, 0x69, + 0x00, 0x99, 0x97, 0x78, 0x61, 0x1E, 0x40, 0x78, 0x61, 0xE4, 0xF9, 0x08, 0x42, 0x11, 0x00, 0x5D, + 0x24, 0xE0, 0x69, 0x16, 0x91, 0x68, 0x61, 0x69, 0x2C, 0x52, 0x99, 0xF9, 0x99, 0x5A, 0x8E, 0x99, + 0x99, 0xB6, 0x63, 0x18, 0xC4, 0x21, 0x10, 0x47, 0x24, 0x92, 0x67, 0x4A, 0x30, 0x53, 0x17, 0x46, + 0x2E, 0x54, 0x62, 0xE8, 0xC5, 0xC0, 0xF0, 0x88, 0x88, 0x78, 0x20, 0xF1, 0x11, 0x0F, 0x04, 0x20, + 0x69, 0x9F, 0x99, 0x20, 0x79, 0x9B, 0x50, 0xF8, 0xE8, 0x8F, 0x40, 0x69, 0xF8, 0x78, 0xF0, 0x96, + 0x99, 0x96, 0xF0, 0x90, 0x69, 0x96, 0x75, 0xA6, 0x99, 0x99, 0x60, 0xE5, 0xA0, 0x69, 0x99, 0x60, + 0x20, 0x69, 0x99, 0x96, 0x20, 0x06, 0x99, 0x96, 0xF0, 0x26, 0x99, 0x96, 0xF0, 0x20, 0x69, 0x96, + 0x70, 0x22, 0xA2, 0x10, 0x84, 0x60, 0x99, 0x99, 0x71, 0x60, 0x61, 0x08, 0x43, 0x34, 0xC0, 0xB3, + 0x28, 0xA6, 0xB4, 0x20, 0x42, 0x3C, 0x85, 0x35, 0xC0, 0x64, 0x92, 0x70, 0x21, 0x1D, 0x5A, 0xD5, + 0xC0, 0x75, 0x6B, 0x57, 0x10, 0x80, 0x09, 0x96, 0xD7, 0xA7, 0x20, 0x09, 0xD4, 0xC6, 0x62, 0xE0, + 0x09, 0xD4, 0xC4, 0x5C, 0x41, 0x04, 0x3C, 0x41, 0x07, 0xC0, 0x0F, 0xCC, 0x66, 0x32, 0x94, 0x74, + 0x18, 0x2E, 0x24, 0xC0, 0xF2, 0x48, 0xF4, 0x30, 0x74, 0x42, 0x22, 0x10, 0x80, 0x74, 0x42, 0x22, + 0x10, 0x71, 0x37, 0x1A, 0xC9, 0xC0, 0x99, 0xF9, 0x96, 0x66, 0x69, 0x99, 0x0F, 0xE5, 0x2F, 0x53, + 0x1F, 0x84, 0x00, 0x08, 0x44, 0xEA, 0xFF, 0x0E, 0x84, 0x00, 0x08, 0x20, 0x87, 0x0A, 0x27, 0x00, + 0x10, 0x0C, 0x23, 0x88, 0x42, 0x60, 0x74, 0xA5, 0x29, 0x38, 0x41, 0x74, 0xA5, 0x27, 0x08, 0x20, + 0x72, 0x53, 0xE4, 0xA4, 0x5F, 0x10, 0x84, 0x00, 0x8B, 0x94, 0x42, 0x10, 0x9F, 0x99, 0x71, 0x60, + 0xAD, 0x99, 0xE0, 0x5B, 0x9B, 0x50, 0xAD, 0x9D, 0xA0, 0x88, 0xE9, 0x99, 0xE0, 0x69, 0x89, 0x60, + 0x69, 0x8B, 0x60, 0x10, 0x9D, 0x29, 0x59, 0x41, 0x08, 0x84, 0xE9, 0x4A, 0xCA, 0x69, 0xF1, 0xE0, + 0xE1, 0xF9, 0x60, 0xE8, 0xBD, 0x26, 0x00, 0x78, 0x68, 0x87, 0xE1, 0x61, 0x1E, 0xE8, 0x98, 0x21, + 0x70, 0x69, 0xA9, 0x96, 0x62, 0x36, 0x22, 0xC0, 0x0B, 0xA5, 0x2B, 0x28, 0x4C, 0x79, 0x9B, 0x51, + 0x60, 0x78, 0xB9, 0x70, 0x99, 0x96, 0x69, 0x60, 0x85, 0x24, 0x8C, 0x31, 0x23, 0x00, 0x99, 0x99, + 0x71, 0x10, 0x68, 0x8E, 0x99, 0x99, 0x68, 0x8E, 0x99, 0x99, 0x20, 0x43, 0x2E, 0x90, 0xC9, 0x24, + 0x40, 0xE9, 0x2E, 0x61, 0x08, 0xDB, 0x10, 0x80, 0x62, 0x6A, 0x72, 0x20, 0xC9, 0x24, 0x91, 0x87, + 0xE3, 0x2B, 0x45, 0x26, 0x99, 0x9F, 0x90, 0x99, 0x9F, 0x91, 0x9F, 0xB9, 0x92, 0x72, 0x52, 0x94, + 0xC0, 0xE4, 0xA5, 0x29, 0x04, 0x9D, 0xB9, 0x90, 0x69, 0xF9, 0x60, 0x7A, 0x59, 0xE4, 0x7C, 0x74, + 0x63, 0x5D, 0x80, 0x23, 0xAB, 0x5A, 0xB8, 0x80, 0x11, 0x13, 0xD0, 0x11, 0x11, 0x3D, 0x10, 0x84, + 0x6D, 0x04, 0xBC, 0x88, 0x88, 0xBC, 0x88, 0x88, 0x40, 0x69, 0x88, 0x80, 0x69, 0x11, 0x10, 0x79, + 0x97, 0x99, 0x99, 0xE9, 0x9E, 0x78, 0x61, 0xE8, 0x40, 0x19, 0x08, 0x42, 0x10, 0x98, 0x19, 0x08, + 0xE2, 0x10, 0x98, 0x89, 0x24, 0x88, 0x11, 0x08, 0x42, 0x32, 0xC9, 0xC2, 0x27, 0x22, 0x20, 0x9F, + 0x99, 0x70, 0x96, 0x99, 0x70, 0xA9, 0x96, 0x60, 0x66, 0x99, 0x90, 0x30, 0xC4, 0x92, 0x84, 0x9F, + 0xB9, 0x90, 0x68, 0xE9, 0x99, 0x90, 0x8A, 0x88, 0x40, 0xF2, 0x48, 0xF1, 0x20, 0xF2, 0x4A, 0xF2, + 0xF2, 0x42, 0x19, 0x60, 0xF2, 0x42, 0x5B, 0x60, 0x74, 0x42, 0x22, 0x10, 0x80, 0x74, 0x60, 0x82, + 0x10, 0x80, 0x21, 0x08, 0x20, 0xC5, 0xC0, 0x78, 0x88, 0x88, 0x70, 0x74, 0x6B, 0x17, 0x00, 0xE9, + 0xE9, 0xE0, 0x69, 0x79, 0x60, 0x0B, 0xA1, 0x69, 0x38, 0x99, 0xF9, 0x90, 0x20, 0x62, 0x22, 0x7A, + 0xC0, 0x99, 0x75, 0x91, 0x10, 0x88, 0x88, 0xF0, 0x0B, 0xA5, 0x29, 0x38, 0x42, 0x74, 0x42, 0x22, + 0x38, 0x80, 0x74, 0x60, 0x82, 0x38, 0x80, 0x20, 0x87, 0xE9, 0xAA, 0xC7, 0xC0, 0x20, 0x87, 0xE9, + 0xAA, 0x97, 0x42, 0x20, 0x87, 0xE9, 0xAA, 0xD7, 0xC4, 0x43, 0xF6, 0x16, 0x44, 0xE0, 0x0A, 0xBC, + 0xA5, 0x28, 0xC2, 0x20, 0x43, 0xB5, 0x16, 0x54, 0xF1, 0x00, 0x21, 0x04, 0x3E, 0x55, 0x55, 0x42, + 0x10, 0x84, 0x2F, 0x8B, 0x47, 0xC0, 0x84, 0x3F, 0x2A, 0x63, 0xE0, 0xAD, 0x55, 0x5A, 0xA8, 0xFC, + 0x41, 0xF8, 0x80, 0x8A, 0x52, 0x93, 0x84, 0x20, 0x89, 0x24, 0x92, 0x38, 0x20, 0x81, 0x2A, 0x22, + 0x88, 0xA8, 0x8A, 0x88, 0x22, 0xA2, 0x22, 0xA2, 0x8A, 0x88, 0xC0, 0xC0, 0x60, 0x90, 0xE8, 0x05, + 0xC0, 0xE8, 0x8B, 0x80, 0xF0, 0x55, 0x00, 0x64, 0x6D, 0x80, 0x55, 0xA0, 0x8A, 0x88, 0xA2, 0x00, + 0x72, 0x24, 0xDE, 0xD3, 0x20, 0x90, 0x60, 0x69, 0x6D, 0x80, 0xE0, 0xF8, 0x96, 0x80, 0xA0, 0xC5, + 0x00, 0x55, 0x00, 0x4C, 0x80, 0xA8, 0xC0, 0xB4, 0x24, 0x5C, 0x74, 0x40, 0x6C, 0xD8, 0xE4, 0xD8, + 0xF1, 0x10, 0x64, 0x5D, 0x00, 0xE0, 0xA0, 0x25, 0x20, 0xF4, 0xA8, 0x5A, 0x21, 0x20, 0xBC, 0x75, + 0x70, 0x55, 0x40, 0xF6, 0xE8, 0x8B, 0x80, 0x74, 0x40, 0x88, 0x8F, 0x88, 0x80, 0x82, 0x10, 0xF4, + 0x20, 0xFD, 0x48, 0x42, 0x10, 0xFD, 0x48, 0x42, 0x00, 0x78, 0x36, 0xC0, 0x9B, 0xBD, 0xD9, 0x95, + 0xB5, 0x29, 0x04, 0x10, 0xC0, 0x74, 0x43, 0x17, 0x00, 0x74, 0x69, 0x17, 0x00, 0x74, 0x4B, 0x17, + 0x00, 0x51, 0x60, 0xFF, 0xFF, 0xC0, 0xFF, 0xFE, 0xAA, 0xAA, 0x78, 0x12, 0xD2, 0x00, 0x24, 0x06, + 0x99, 0xF9, 0x90, 0xF0, 0x24, 0xF8, 0xE8, 0x8F, 0x22, 0x72, 0xF4, 0xA5, 0x20, 0x27, 0xA2, 0x22, + 0x70, 0x44, 0x0C, 0x94, 0xA5, 0x26, 0x45, 0x4A, 0x51, 0x08, 0x40, 0x44, 0x1D, 0x18, 0xC5, 0x5B, + 0x12, 0xD2, 0x00, 0x20, 0x82, 0x08, 0x20, 0x69, 0x9F, 0x99, 0xE9, 0xE9, 0x9E, 0xF8, 0x88, 0x80, + 0x21, 0x14, 0xA8, 0xFC, 0xF8, 0xE8, 0x8F, 0xF1, 0x24, 0x8F, 0x99, 0xF9, 0x99, 0x69, 0xBD, 0x96, + 0xE9, 0x25, 0xC0, 0x9A, 0xCA, 0x99, 0x21, 0x14, 0xA8, 0xC4, 0x9F, 0xB9, 0x99, 0x9D, 0xDB, 0xB9, + 0xF8, 0x1C, 0x0F, 0x80, 0x69, 0x99, 0x96, 0x7A, 0x52, 0x94, 0xA4, 0xE9, 0x9E, 0x88, 0xFA, 0x08, + 0x88, 0x7C, 0xF9, 0x08, 0x42, 0x10, 0x8C, 0x54, 0x42, 0x10, 0x23, 0xAB, 0x5A, 0xB8, 0x80, 0x99, + 0x69, 0x99, 0xAD, 0x6A, 0xE2, 0x10, 0x74, 0x63, 0x15, 0x6C, 0xA1, 0x24, 0x90, 0x50, 0x23, 0x15, + 0x10, 0x84, 0x24, 0x07, 0x99, 0xB5, 0x24, 0x07, 0x8E, 0x87, 0x24, 0x0E, 0x99, 0x99, 0x10, 0x28, + 0x64, 0x92, 0x12, 0xD2, 0x12, 0x49, 0x24, 0x8E, 0x79, 0x9B, 0x50, 0x69, 0xA9, 0x9A, 0x80, 0x99, + 0x56, 0x55, 0x20, 0xF8, 0x69, 0x99, 0x60, 0x78, 0xE8, 0x70, 0xF2, 0x48, 0x87, 0x20, 0xE9, 0x99, + 0x91, 0x69, 0xF9, 0x60, 0xD5, 0x40, 0x9A, 0xE9, 0x90, 0x88, 0x46, 0x69, 0x90, 0x99, 0x99, 0xE8, + 0x99, 0x96, 0x60, 0x69, 0x48, 0x86, 0x12, 0x69, 0x99, 0x60, 0xFA, 0x94, 0xA5, 0x00, 0x69, 0x99, + 0xE8, 0x88, 0x78, 0x88, 0x72, 0x40, 0x7C, 0xA5, 0x26, 0x00, 0xF4, 0x44, 0x20, 0xA9, 0x99, 0x60, + 0xB5, 0x6B, 0x57, 0x10, 0x80, 0x99, 0x69, 0x90, 0xAD, 0x6A, 0xE2, 0x10, 0x80, 0x54, 0x6B, 0x55, + 0x00, 0xA3, 0x24, 0x90, 0x50, 0x12, 0x94, 0xA4, 0xE0, 0x12, 0x06, 0x99, 0x96, 0x11, 0x00, 0x94, + 0xA5, 0x27, 0x22, 0x00, 0xA8, 0xD7, 0x71, 0x9A, 0xCA, 0x99, 0x20, 0x69, 0xE9, 0x96, 0x31, 0x25, + 0xFA, 0x48, 0xC0, 0x8A, 0x35, 0x08, 0x20, 0x80, 0x42, 0x28, 0xD4, 0x20, 0x82, 0x00, 0x50, 0x08, + 0xA3, 0x50, 0x82, 0x08, 0x21, 0x1D, 0x5A, 0xD5, 0xC4, 0x20, 0xFC, 0x6B, 0xB8, 0x80, 0x94, 0x99, + 0x29, 0x90, 0x74, 0x63, 0x18, 0xB8, 0x84, 0x74, 0x63, 0x17, 0x10, 0x80, 0x74, 0x21, 0x08, 0x38, + 0x26, 0x74, 0x21, 0x07, 0x04, 0xC0, 0xF8, 0xF9, 0x88, 0xF8, 0xEA, 0x88, 0x81, 0x25, 0x9A, 0x48, + 0x10, 0x2A, 0x72, 0xA0, 0x42, 0x69, 0x35, 0x10, 0x42, 0x69, 0x35, 0x10, 0xAD, 0x6B, 0x55, 0x87, + 0xC0, 0xAD, 0x6A, 0xB0, 0xF8, 0x59, 0x97, 0x11, 0x10, 0x59, 0x97, 0x11, 0x88, 0x8E, 0x99, 0x91, + 0xE0, 0x13, 0xAA, 0x90, 0xBA, 0x00, 0xE1, 0x68, 0x96, 0xE1, 0x68, 0x87, 0x55, 0x48, 0xA5, 0x47, + 0xE0, 0x55, 0x48, 0xA7, 0x00, 0x78, 0xE9, 0x99, 0x60, 0x68, 0xA9, 0x96, 0x23, 0xAA, 0x42, 0x10, + 0x31, 0x3F, 0x42, 0x30, 0x9A, 0x65, 0x90, 0x69, 0x99, 0xE8, 0x61, 0x78, 0x88, 0x70, 0x21, 0x92, + 0x49, 0xC0, 0x69, 0xBD, 0x96, 0x78, 0xE8, 0x70, 0xE1, 0x71, 0xE0, 0x8E, 0x99, 0xE8, 0x8E, 0x99, + 0xE8, 0x80, 0x78, 0x88, 0x87, 0x9F, 0xB9, 0x99, 0x9F, 0xB9, 0x88, 0x69, 0x99, 0xE8, 0xC8, 0x74, + 0x42, 0x18, 0xB8, 0x78, 0xA8, 0x87, 0xE1, 0x51, 0x1E, 0x42, 0xF8, 0xE8, 0x8F, 0xA0, 0xF8, 0xE8, + 0x8F, 0xE2, 0x1C, 0x94, 0xA5, 0x22, 0x24, 0xF8, 0x88, 0x88, 0x69, 0xC8, 0x96, 0x3A, 0x0C, 0x10, + 0xB8, 0x24, 0x92, 0x40, 0xA9, 0x24, 0x90, 0x38, 0x42, 0x14, 0x98, 0x31, 0x45, 0x95, 0x56, 0x60, + 0xA5, 0x3D, 0x5A, 0xD8, 0xF2, 0x1C, 0x94, 0xA5, 0x20, 0x13, 0x54, 0xC5, 0x25, 0x20, 0x42, 0x9B, + 0xBD, 0xD9, 0x52, 0x99, 0x97, 0x16, 0x8C, 0x63, 0x1F, 0x90, 0x80, 0x69, 0x9F, 0x99, 0x72, 0x1C, + 0x94, 0xA5, 0xC0, 0xE9, 0xE9, 0x9E, 0xF8, 0x88, 0x88, 0x32, 0x94, 0xA5, 0x7E, 0x20, 0xF8, 0xE8, + 0x8F, 0xAD, 0x5D, 0x5A, 0xD4, 0x69, 0x21, 0x96, 0x4A, 0xD6, 0xD6, 0xA4, 0x52, 0x9B, 0xBD, 0xD9, + 0x9A, 0xCA, 0x99, 0x75, 0x55, 0x59, 0x9F, 0xB9, 0x99, 0x99, 0xF9, 0x99, 0x69, 0x99, 0x96, 0x7A, + 0x52, 0x94, 0xA4, 0x72, 0x52, 0xE4, 0x20, 0x78, 0x88, 0x87, 0xE9, 0x24, 0x80, 0x99, 0x96, 0x2C, + 0x23, 0xAB, 0x5A, 0xD5, 0xC4, 0x99, 0x69, 0x99, 0xAA, 0xAA, 0xAF, 0x10, 0x99, 0x71, 0x11, 0xAD, + 0x6B, 0x5A, 0xF8, 0xAA, 0xAA, 0xAA, 0xA9, 0xF0, 0x40, 0xC4, 0x46, 0x56, 0x4A, 0x52, 0xD5, 0xB4, + 0x42, 0x10, 0xE4, 0xB8, 0x69, 0x31, 0x96, 0x95, 0x7B, 0x5A, 0xC8, 0x79, 0x97, 0x99, 0x79, 0x9B, + 0x50, 0x16, 0x8E, 0x99, 0xE0, 0xE9, 0xE9, 0xE0, 0xF8, 0x88, 0x80, 0x32, 0x94, 0xA5, 0x7E, 0x20, + 0x69, 0xF8, 0x70, 0xAD, 0x5D, 0x5A, 0x80, 0x69, 0x29, 0x60, 0x9B, 0xFD, 0x90, 0x52, 0x9B, 0xFD, + 0x90, 0x9A, 0xE9, 0x90, 0x75, 0x55, 0x90, 0x9F, 0xB9, 0x90, 0x99, 0xF9, 0x90, 0x69, 0x99, 0x60, + 0xF9, 0x99, 0x90, 0xE9, 0x99, 0xE8, 0x80, 0x78, 0x88, 0x70, 0xE9, 0x24, 0x99, 0x96, 0x2C, 0x23, + 0xAB, 0x5A, 0xB8, 0x80, 0x99, 0x69, 0x90, 0xAA, 0xAA, 0xF1, 0x99, 0x71, 0x10, 0xAD, 0x6B, 0x5F, + 0x00, 0xAA, 0xAA, 0xAA, 0x7C, 0x10, 0xC4, 0x65, 0x60, 0x99, 0xDB, 0xD0, 0x88, 0xE9, 0xE0, 0x69, + 0x39, 0x60, 0x95, 0x7B, 0x59, 0x00, 0x79, 0x79, 0x90, 0x42, 0x06, 0x9F, 0x87, 0xA0, 0x69, 0xF8, + 0x70, 0x47, 0x10, 0xE4, 0xA4, 0x40, 0x24, 0xF8, 0x88, 0x80, 0x69, 0xC9, 0x60, 0x78, 0x61, 0xE0, + 0x4D, 0x54, 0xA3, 0x24, 0x90, 0x21, 0x92, 0x49, 0xC0, 0x31, 0x45, 0x95, 0x98, 0xA5, 0x3D, 0x5B, + 0x00, 0x42, 0x3C, 0x87, 0x25, 0x20, 0x12, 0x09, 0xAE, 0x99, 0x42, 0x09, 0xBB, 0xD9, 0x96, 0x09, + 0x99, 0x97, 0x16, 0x8C, 0x63, 0x17, 0x10, 0x80, 0xAD, 0x6B, 0x5A, 0xD5, 0x40, 0xAD, 0x6B, 0x55, + 0x00, 0x47, 0x10, 0xE4, 0xA5, 0xC0, 0x47, 0x10, 0xE4, 0xB8, 0x95, 0x69, 0xEA, 0x56, 0x40, 0x9D, + 0x3D, 0x49, 0x80, 0x21, 0x14, 0xEA, 0xD4, 0x22, 0x9D, 0x5A, 0x80, 0xA5, 0x35, 0xEA, 0xD4, 0xA6, + 0xBD, 0x5A, 0x80, 0xFC, 0x54, 0xEA, 0xD6, 0xA0, 0x72, 0x88, 0xEA, 0x80, 0xF6, 0xA9, 0xEA, 0xD6, + 0xA0, 0xF6, 0xA9, 0xEA, 0x80, 0xA4, 0x0E, 0x16, 0x11, 0x68, 0x60, 0xA4, 0x0E, 0x16, 0x16, 0x84, + 0xAD, 0x6B, 0x57, 0x10, 0x80, 0xAD, 0x6A, 0xE2, 0x10, 0x69, 0x9F, 0x99, 0x60, 0x69, 0xF9, 0x60, + 0x94, 0xE4, 0xC6, 0x30, 0x94, 0xE4, 0xC6, 0x00, 0x50, 0xA4, 0x93, 0x48, 0xC3, 0x0C, 0xA2, 0xA5, + 0x39, 0x31, 0x80, 0x45, 0x29, 0x5A, 0xD5, 0x61, 0x30, 0x45, 0x6B, 0x55, 0x84, 0xC0, 0x23, 0xAB, + 0x18, 0xC6, 0xAE, 0x20, 0x23, 0xAB, 0x1A, 0xB8, 0x80, 0x64, 0x82, 0x48, 0xC6, 0x35, 0x50, 0x64, + 0x82, 0x48, 0xC6, 0xAA, 0xFD, 0x41, 0x5A, 0xD6, 0xB5, 0x50, 0xFD, 0x41, 0x5A, 0xD6, 0xAA, 0x78, + 0x88, 0x86, 0x27, 0x78, 0x88, 0x62, 0x70, 0x11, 0x63, 0xC6, 0x88, 0x16, 0x80, 0x1F, 0x06, 0x99, + 0x96, 0x2D, 0x06, 0x99, 0x96, 0x44, 0x06, 0x99, 0x96, 0x22, 0x06, 0x99, 0x96, 0x4B, 0x06, 0x99, + 0x96, 0x14, 0x82, 0xFD, 0x05, 0x40, 0x14, 0x82, 0x8D, 0x05, 0x40, 0x51, 0x25, 0x6B, 0x6B, 0x53, + 0x10, 0x51, 0x25, 0x6F, 0x6A, 0x62, 0x47, 0x10, 0xE4, 0xB8, 0xE4, 0xAD, 0xE8, 0xC0, 0xE4, 0xA5, + 0x6F, 0x46, 0x00, 0x1F, 0x88, 0x88, 0x80, 0x1F, 0x88, 0x88, 0x7A, 0x3C, 0x84, 0x20, 0x7A, 0x3C, + 0x84, 0x00, 0xE8, 0xE9, 0x99, 0x20, 0xC8, 0xE9, 0x92, 0xAA, 0xA7, 0x2A, 0xAA, 0xB0, 0x40, 0xAA, + 0xA7, 0x2A, 0xAC, 0x10, 0x69, 0x21, 0x96, 0x24, 0x69, 0x29, 0x62, 0x40, 0x95, 0x31, 0x49, 0x4C, + 0x20, 0x95, 0x39, 0x29, 0x84, 0x9A, 0xEF, 0x99, 0x9E, 0xED, 0x90, 0x4F, 0x98, 0xA4, 0xA4, 0x4F, + 0x9C, 0x94, 0x80, 0xCA, 0x98, 0xA4, 0xA4, 0xCA, 0x9C, 0x94, 0x80, 0x94, 0xBD, 0x29, 0x4C, 0x20, + 0x94, 0xBD, 0x29, 0x84, 0x9C, 0xBD, 0x29, 0x48, 0x9C, 0xBD, 0x29, 0x00, 0xF2, 0x49, 0x24, 0x9A, + 0x50, 0x42, 0xF2, 0x49, 0x26, 0x94, 0x10, 0x80, 0x44, 0xAB, 0x5A, 0xB8, 0x60, 0x44, 0xAB, 0x57, + 0x0C, 0x78, 0x88, 0x87, 0x26, 0x78, 0x88, 0x72, 0x60, 0xF9, 0x08, 0x42, 0x18, 0x40, 0xF9, 0x08, + 0x43, 0x08, 0x8A, 0x88, 0x42, 0x10, 0x8A, 0x88, 0x42, 0x10, 0x8A, 0x88, 0xE2, 0x10, 0x8A, 0x88, + 0xE2, 0x10, 0x94, 0x99, 0x29, 0x4C, 0x20, 0x94, 0x99, 0x29, 0x84, 0xE9, 0x24, 0x92, 0x49, 0xF0, + 0x40, 0xE9, 0x24, 0x92, 0x7C, 0x10, 0x94, 0x9C, 0x21, 0x1C, 0x20, 0x94, 0x9C, 0x23, 0x04, 0x99, + 0xF5, 0x11, 0x99, 0xF5, 0x10, 0x88, 0xAD, 0x99, 0x90, 0x88, 0xE9, 0x99, 0x90, 0x32, 0x72, 0xF4, + 0x1C, 0x36, 0x5E, 0x83, 0x80, 0x32, 0x72, 0xF4, 0x1C, 0x40, 0x36, 0x5E, 0x83, 0x88, 0x24, 0x92, + 0x40, 0x51, 0x23, 0x57, 0x56, 0xB5, 0x51, 0x01, 0x5A, 0xBA, 0xB5, 0x9A, 0xCA, 0x99, 0x20, 0x9A, + 0xE9, 0x92, 0x72, 0x94, 0xA5, 0x4C, 0x20, 0x72, 0x94, 0xA9, 0x84, 0x99, 0xF9, 0x99, 0x20, 0x99, + 0xF9, 0x92, 0x94, 0xBD, 0x29, 0x4C, 0x40, 0x94, 0xBD, 0x29, 0x88, 0x99, 0x71, 0x13, 0x20, 0x99, + 0x71, 0x32, 0x97, 0xAD, 0x29, 0x4C, 0x40, 0x97, 0xAD, 0x29, 0x88, 0x49, 0x74, 0x80, 0x96, 0x69, + 0x9F, 0x99, 0x96, 0x07, 0x99, 0xB5, 0x90, 0x69, 0x9F, 0x99, 0x90, 0x79, 0x9B, 0x50, 0x7E, 0x49, + 0xBC, 0x92, 0x70, 0x75, 0x6F, 0x47, 0x80, 0x96, 0xF8, 0xE8, 0x8F, 0x96, 0x06, 0x9F, 0x87, 0xE1, + 0xF9, 0x96, 0xE1, 0xF9, 0x60, 0x90, 0xE1, 0xF9, 0x96, 0x90, 0xE1, 0xF9, 0x60, 0x50, 0x2B, 0x57, + 0x56, 0xB5, 0x50, 0x2B, 0x57, 0x56, 0xA0, 0x90, 0x69, 0x21, 0x96, 0x90, 0x69, 0x29, 0x60, 0x90, + 0x69, 0x21, 0x96, 0x90, 0x69, 0x29, 0x60, 0x60, 0x9B, 0xBD, 0xD9, 0x60, 0x9B, 0xFD, 0x90, 0x90, + 0x9B, 0xBD, 0xD9, 0x90, 0x9B, 0xFD, 0x90, 0x90, 0x69, 0x99, 0x96, 0x90, 0x69, 0x99, 0x60, 0x69, + 0xF9, 0x96, 0x69, 0xF9, 0x60, 0x90, 0x69, 0xF9, 0x96, 0x69, 0xF9, 0x60, 0x90, 0x69, 0x31, 0x96, + 0x90, 0x69, 0x39, 0x60, 0x70, 0x22, 0xA2, 0x10, 0x84, 0x70, 0x22, 0xA2, 0x10, 0x84, 0x90, 0x99, + 0x96, 0x2C, 0x90, 0x99, 0x96, 0x2C, 0x5B, 0x09, 0x96, 0x2C, 0x5A, 0x09, 0x96, 0x2C, 0x90, 0x99, + 0xF5, 0x11, 0x90, 0x99, 0xF5, 0x10, 0xF8, 0x88, 0x8C, 0x40, 0x7A, 0x38, 0x86, 0x10, 0x90, 0x99, + 0x9D, 0xBD, 0x90, 0x99, 0xDB, 0xD0, 0x7A, 0x38, 0x84, 0x30, 0x42, 0x7A, 0x38, 0x86, 0x08, 0x80, + 0x94, 0x99, 0x29, 0x44, 0x40, 0x97, 0x99, 0x28, 0x88, 0x99, 0x6F, 0x99, 0x99, 0x6F, 0x90, +}; + +static const GFXglyph lemonGlyphs[] PROGMEM = { + { 0, 0, 0, 5, 0, 0 }, // U+0020 char32 + { 0, 1, 6, 2, 0, -6 }, // U+0021 uni0021 + { 1, 3, 3, 4, 0, -7 }, // U+0022 uni0022 + { 3, 5, 5, 6, 0, -5 }, // U+0023 uni0023 + { 7, 4, 7, 5, 0, -7 }, // U+0024 uni0053 + { 11, 4, 6, 5, 0, -6 }, // U+0025 uni0025 + { 14, 5, 6, 6, 0, -6 }, // U+0026 uni0026 + { 18, 1, 3, 2, 0, -7 }, // U+0027 uni0027 + { 19, 3, 8, 4, 0, -7 }, // U+0028 uni0028 + { 22, 3, 8, 4, 0, -7 }, // U+0029 uni0028 + { 25, 4, 5, 5, 0, -5 }, // U+002A uni002A + { 28, 5, 5, 6, 0, -6 }, // U+002B uni002B + { 32, 2, 3, 3, 0, -2 }, // U+002C uni002C + { 33, 4, 1, 5, 0, -4 }, // U+002D uni002D + { 34, 1, 2, 2, 0, -2 }, // U+002E uni002E + { 35, 4, 8, 5, 0, -7 }, // U+002F uni002F + { 39, 4, 6, 5, 0, -6 }, // U+0030 uni0030 + { 42, 2, 6, 3, 0, -6 }, // U+0031 uni0031 + { 44, 4, 6, 5, 0, -6 }, // U+0032 uni0032 + { 47, 4, 6, 5, 0, -6 }, // U+0033 uni0033 + { 50, 4, 6, 5, 0, -6 }, // U+0034 uni0034 + { 53, 4, 6, 5, 0, -6 }, // U+0035 uni0035 + { 56, 4, 6, 5, 0, -6 }, // U+0036 uni0036 + { 59, 4, 6, 5, 0, -6 }, // U+0037 uni0037 + { 62, 4, 6, 5, 0, -6 }, // U+0038 uni0038 + { 65, 4, 6, 5, 0, -6 }, // U+0039 uni0039 + { 68, 1, 5, 2, 0, -5 }, // U+003A uni003A + { 69, 2, 6, 3, 0, -5 }, // U+003B uni003B + { 71, 3, 5, 4, 0, -5 }, // U+003C uni003C + { 73, 3, 3, 4, 0, -5 }, // U+003D uni003D + { 75, 3, 5, 4, 0, -5 }, // U+003E uni003E + { 77, 3, 6, 4, 0, -6 }, // U+003F uni003F + { 80, 4, 6, 5, 0, -6 }, // U+0040 uni0040 + { 83, 4, 6, 5, 0, -6 }, // U+0041 uni0041 + { 86, 4, 6, 5, 0, -6 }, // U+0042 uni0042 + { 89, 4, 6, 5, 0, -6 }, // U+0043 uni0043 + { 92, 4, 6, 5, 0, -6 }, // U+0044 uni0044 + { 95, 4, 6, 5, 0, -6 }, // U+0045 uni0045 + { 98, 4, 6, 5, 0, -6 }, // U+0046 uni0046 + { 101, 4, 6, 5, 0, -6 }, // U+0047 uni0047 + { 104, 4, 6, 5, 0, -6 }, // U+0048 uni0048 + { 107, 3, 6, 4, 0, -6 }, // U+0049 uni0049 + { 110, 4, 6, 5, 0, -6 }, // U+004A uni004A + { 113, 4, 6, 5, 0, -6 }, // U+004B uni004B + { 116, 4, 6, 5, 0, -6 }, // U+004C uni004C + { 119, 4, 6, 5, 0, -6 }, // U+004D uni004D + { 122, 4, 6, 5, 0, -6 }, // U+004E uni004E + { 125, 4, 6, 5, 0, -6 }, // U+004F uni004F + { 128, 4, 6, 5, 0, -6 }, // U+0050 uni0050 + { 131, 4, 7, 5, 0, -6 }, // U+0051 uni0051 + { 135, 4, 6, 5, 0, -6 }, // U+0052 uni0052 + { 138, 4, 6, 5, 0, -6 }, // U+0053 uni0053 + { 141, 5, 6, 6, 0, -6 }, // U+0054 uni0054 + { 145, 4, 6, 5, 0, -6 }, // U+0055 uni0055 + { 148, 4, 6, 5, 0, -6 }, // U+0056 uni0056 + { 151, 4, 6, 5, 0, -6 }, // U+0057 uni0057 + { 154, 4, 6, 5, 0, -6 }, // U+0058 uni0058 + { 157, 5, 6, 6, 0, -6 }, // U+0059 uni0059 + { 161, 4, 6, 5, 0, -6 }, // U+005A uni005A + { 164, 3, 8, 4, 0, -7 }, // U+005B uni005B + { 167, 4, 8, 5, 0, -7 }, // U+005C uni005C + { 171, 3, 8, 4, 0, -7 }, // U+005D uni005D + { 174, 5, 3, 6, 0, -7 }, // U+005E uni005E + { 176, 4, 1, 5, 0, 0 }, // U+005F uni005F + { 177, 2, 3, 3, 0, -7 }, // U+0060 uni0060 + { 178, 4, 5, 5, 0, -5 }, // U+0061 uni0061 + { 181, 4, 7, 5, 0, -7 }, // U+0062 uni0062 + { 185, 4, 5, 5, 0, -5 }, // U+0063 uni0063 + { 188, 4, 7, 5, 0, -7 }, // U+0064 uni0064 + { 192, 4, 5, 5, 0, -5 }, // U+0065 uni0065 + { 195, 3, 7, 4, 0, -7 }, // U+0066 uni0066 + { 198, 4, 7, 5, 0, -5 }, // U+0067 uni0067 + { 202, 4, 7, 5, 0, -7 }, // U+0068 uni0068 + { 206, 2, 7, 3, 0, -7 }, // U+0069 uni0069 + { 208, 3, 9, 4, 0, -7 }, // U+006A uni006A + { 212, 4, 7, 5, 0, -7 }, // U+006B uni006B + { 216, 2, 7, 3, 0, -7 }, // U+006C uni006C + { 218, 4, 5, 5, 0, -5 }, // U+006D uni006D + { 221, 4, 5, 5, 0, -5 }, // U+006E uni006E + { 224, 4, 5, 5, 0, -5 }, // U+006F uni006F + { 227, 4, 7, 5, 0, -5 }, // U+0070 uni0070 + { 231, 4, 7, 5, 0, -5 }, // U+0071 uni0071 + { 235, 4, 5, 5, 0, -5 }, // U+0072 uni0072 + { 238, 4, 5, 5, 0, -5 }, // U+0073 uni0073 + { 241, 3, 6, 4, 0, -6 }, // U+0074 uni0074 + { 244, 4, 5, 5, 0, -5 }, // U+0075 uni0075 + { 247, 4, 5, 5, 0, -5 }, // U+0076 uni0076 + { 250, 4, 5, 5, 0, -5 }, // U+0077 uni0077 + { 253, 4, 5, 5, 0, -5 }, // U+0078 uni0078 + { 256, 4, 7, 5, 0, -5 }, // U+0079 uni0079 + { 260, 4, 5, 5, 0, -5 }, // U+007A uni007A + { 263, 3, 8, 4, 0, -7 }, // U+007B uni007B + { 266, 1, 8, 2, 0, -7 }, // U+007C uni007C + { 267, 3, 8, 4, 0, -7 }, // U+007D uni007D + { 270, 5, 4, 6, 0, -5 }, // U+007E uni007E + { 273, 0, 0, 5, 0, 0 }, // U+007F (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0080 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0081 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0082 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0083 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0084 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0085 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0086 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0087 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0088 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0089 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008A (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008B (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008C (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008D (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008E (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+008F (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0090 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0091 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0092 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0093 (placeholder) + { 273, 0, 0, 5, 0, 0 }, // U+0094 (placeholder) + { 273, 4, 4, 5, 0, -4 }, // U+0095 char149 + { 275, 0, 0, 5, 0, 0 }, // U+0096 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+0097 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+0098 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+0099 (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009A (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009B (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009C (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009D (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009E (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+009F (placeholder) + { 275, 0, 0, 5, 0, 0 }, // U+00A0 (placeholder) + { 275, 1, 6, 2, 0, -6 }, // U+00A1 char161 + { 276, 4, 7, 5, 0, -6 }, // U+00A2 char162 + { 280, 5, 7, 6, 0, -7 }, // U+00A3 char163 + { 285, 5, 6, 6, 0, -6 }, // U+00A4 char164 + { 289, 5, 7, 6, 0, -7 }, // U+00A5 char165 + { 294, 1, 7, 2, 0, -6 }, // U+00A6 char166 + { 295, 4, 8, 5, 0, -7 }, // U+00A7 char167 + { 299, 3, 1, 4, 0, -6 }, // U+00A8 char168 + { 300, 6, 7, 7, 0, -7 }, // U+00A9 char169 + { 306, 4, 4, 5, 0, -6 }, // U+00AA char170 + { 308, 4, 3, 5, 0, -5 }, // U+00AB char171 + { 310, 5, 3, 6, 0, -4 }, // U+00AC char172 + { 312, 3, 1, 4, 0, -3 }, // U+00AD SOFT HYPHEN + { 313, 6, 7, 7, 0, -7 }, // U+00AE char174 + { 319, 4, 1, 5, 0, -7 }, // U+00AF char175 + { 320, 3, 3, 4, 0, -6 }, // U+00B0 char176 + { 322, 4, 5, 5, 0, -5 }, // U+00B1 char177 + { 325, 3, 5, 4, 0, -7 }, // U+00B2 char178 + { 327, 3, 5, 4, 0, -7 }, // U+00B3 char179 + { 329, 2, 2, 3, 0, -8 }, // U+00B4 char180 + { 330, 4, 6, 5, 0, -5 }, // U+00B5 char181 + { 333, 5, 6, 6, 0, -6 }, // U+00B6 char182 + { 337, 2, 2, 3, 0, -4 }, // U+00B7 char183 + { 338, 3, 3, 4, 0, -4 }, // U+00B8 char184 + { 340, 2, 4, 3, 0, -6 }, // U+00B9 char185 + { 341, 4, 4, 5, 0, -8 }, // U+00BA char186 + { 343, 5, 3, 6, 0, -5 }, // U+00BB char187 + { 345, 5, 8, 6, 0, -8 }, // U+00BC char188 + { 350, 5, 7, 6, 0, -8 }, // U+00BD char189 + { 355, 6, 8, 7, 0, -8 }, // U+00BE char190 + { 361, 3, 6, 4, 0, -6 }, // U+00BF char191 + { 364, 4, 8, 5, 0, -8 }, // U+00C0 char192 + { 368, 4, 8, 5, 0, -8 }, // U+00C1 char193 + { 372, 4, 9, 5, 0, -9 }, // U+00C2 char194 + { 377, 4, 8, 5, 0, -8 }, // U+00C3 char195 + { 381, 5, 8, 6, 0, -8 }, // U+00C4 char196 + { 386, 4, 8, 5, 0, -8 }, // U+00C5 char197 + { 390, 6, 6, 7, 0, -6 }, // U+00C6 char198 + { 395, 4, 8, 5, 0, -6 }, // U+00C7 char199 + { 399, 4, 8, 5, 0, -8 }, // U+00C8 char200 + { 403, 4, 8, 5, 0, -8 }, // U+00C9 char201 + { 407, 4, 8, 5, 0, -8 }, // U+00CA char202 + { 411, 4, 8, 5, 0, -8 }, // U+00CB char203 + { 415, 3, 9, 4, 0, -9 }, // U+00CC char204 + { 419, 3, 9, 4, 0, -9 }, // U+00CD char205 + { 423, 3, 9, 4, 0, -9 }, // U+00CE char206 + { 427, 3, 8, 4, 0, -8 }, // U+00CF char207 + { 430, 5, 6, 6, 0, -6 }, // U+00D0 char208 + { 434, 5, 8, 6, 0, -8 }, // U+00D1 char209 + { 439, 4, 8, 5, 0, -8 }, // U+00D2 char210 + { 443, 4, 8, 5, 0, -8 }, // U+00D3 char211 + { 447, 4, 8, 5, 0, -8 }, // U+00D4 char212 + { 451, 4, 8, 5, 0, -8 }, // U+00D5 char213 + { 455, 5, 7, 6, 0, -7 }, // U+00D6 char214 + { 460, 3, 3, 4, 0, -5 }, // U+00D7 char215 + { 462, 6, 6, 7, 0, -6 }, // U+00D8 char216 + { 467, 4, 8, 5, 0, -8 }, // U+00D9 char217 + { 471, 4, 8, 5, 0, -8 }, // U+00DA char218 + { 475, 4, 9, 5, 0, -9 }, // U+00DB char219 + { 480, 4, 8, 5, 0, -8 }, // U+00DC char220 + { 484, 5, 8, 6, 0, -8 }, // U+00DD char221 + { 489, 4, 7, 5, 0, -6 }, // U+00DE char222 + { 493, 4, 7, 5, 0, -6 }, // U+00DF char223 + { 497, 4, 8, 5, 0, -8 }, // U+00E0 char224 + { 501, 4, 8, 5, 0, -8 }, // U+00E1 char225 + { 505, 4, 8, 5, 0, -8 }, // U+00E2 char226 + { 509, 4, 8, 5, 0, -8 }, // U+00E3 char227 + { 513, 4, 7, 5, 0, -7 }, // U+00E4 char228 + { 517, 4, 7, 5, 0, -7 }, // U+00E5 char229 + { 521, 5, 5, 6, 0, -5 }, // U+00E6 char230 + { 525, 4, 7, 5, 0, -5 }, // U+00E7 char231 + { 529, 4, 8, 5, 0, -8 }, // U+00E8 char232 + { 533, 4, 8, 5, 0, -8 }, // U+00E9 char233 + { 537, 4, 8, 5, 0, -8 }, // U+00EA char234 + { 541, 4, 7, 5, 0, -7 }, // U+00EB char235 + { 545, 2, 8, 3, 0, -8 }, // U+00EC char236 + { 547, 3, 8, 4, 0, -8 }, // U+00ED char237 + { 550, 3, 8, 4, 0, -8 }, // U+00EE char238 + { 553, 3, 7, 4, 0, -7 }, // U+00EF char239 + { 556, 4, 8, 5, 0, -8 }, // U+00F0 char240 + { 560, 4, 8, 5, 0, -8 }, // U+00F1 char241 + { 564, 5, 8, 6, 0, -8 }, // U+00F2 char242 + { 569, 4, 8, 5, 0, -8 }, // U+00F3 char243 + { 573, 4, 8, 5, 0, -8 }, // U+00F4 char244 + { 577, 4, 8, 5, 0, -8 }, // U+00F5 char245 + { 581, 4, 7, 5, 0, -7 }, // U+00F6 char246 + { 585, 5, 5, 6, 0, -6 }, // U+00F7 char247 + { 589, 6, 5, 7, 0, -5 }, // U+00F8 char248 + { 593, 4, 8, 5, 0, -8 }, // U+00F9 char249 + { 597, 5, 8, 6, 0, -8 }, // U+00FA char250 + { 602, 4, 8, 5, 0, -8 }, // U+00FB char251 + { 606, 5, 7, 6, 0, -7 }, // U+00FC char252 + { 611, 4, 10, 5, 0, -8 }, // U+00FD char253 + { 616, 4, 9, 5, 0, -7 }, // U+00FE char254 + { 621, 4, 9, 5, 0, -7 }, // U+00FF char255 + { 626, 4, 8, 5, 0, -8 }, // U+0100 uni0041 + { 630, 4, 7, 5, 0, -7 }, // U+0101 uni0061 + { 634, 4, 9, 5, 0, -9 }, // U+0102 uni0041 + { 639, 4, 8, 5, 0, -8 }, // U+0103 uni0061 + { 643, 5, 7, 6, 0, -6 }, // U+0104 uni0041 + { 648, 5, 6, 6, 0, -5 }, // U+0105 uni0061 + { 652, 4, 9, 5, 0, -9 }, // U+0106 uni0043 + { 657, 4, 8, 5, 0, -8 }, // U+0107 uni0063 + { 661, 4, 9, 5, 0, -9 }, // U+0108 uni0043 + { 666, 4, 8, 5, 0, -8 }, // U+0109 uni0063 + { 670, 4, 8, 5, 0, -8 }, // U+010A uni0043 + { 674, 4, 7, 5, 0, -7 }, // U+010B uni0063 + { 678, 4, 9, 5, 0, -9 }, // U+010C uni0043 + { 683, 4, 8, 5, 0, -8 }, // U+010D uni0063 + { 687, 4, 9, 5, 0, -9 }, // U+010E uni0044 + { 692, 6, 8, 7, 0, -8 }, // U+010F uni0064 + { 698, 5, 6, 6, 0, -6 }, // U+0110 uni0044 + { 702, 5, 7, 6, 0, -7 }, // U+0111 uni0064 + { 707, 4, 8, 5, 0, -8 }, // U+0112 uni0045 + { 711, 4, 7, 5, 0, -7 }, // U+0113 uni0065 + { 715, 4, 9, 5, 0, -9 }, // U+0114 uni0045 + { 720, 4, 8, 5, 0, -8 }, // U+0115 uni0065 + { 724, 4, 8, 5, 0, -8 }, // U+0116 uni0045 + { 728, 4, 7, 5, 0, -7 }, // U+0117 uni0065 + { 732, 5, 7, 6, 0, -6 }, // U+0118 uni0045 + { 737, 4, 6, 5, 0, -5 }, // U+0119 uni0065 + { 740, 4, 9, 5, 0, -9 }, // U+011A uni0045 + { 745, 4, 8, 5, 0, -8 }, // U+011B uni0065 + { 749, 4, 9, 5, 0, -9 }, // U+011C uni0047 + { 754, 4, 10, 5, 0, -8 }, // U+011D uni0067 + { 759, 4, 9, 5, 0, -9 }, // U+011E uni0047 + { 764, 4, 10, 5, 0, -8 }, // U+011F uni0067 + { 769, 4, 8, 5, 0, -8 }, // U+0120 uni0047 + { 773, 4, 9, 5, 0, -7 }, // U+0121 uni0067 + { 778, 4, 8, 5, 0, -6 }, // U+0122 uni0047 + { 782, 4, 10, 5, 0, -8 }, // U+0123 uni0067 + { 787, 4, 9, 5, 0, -9 }, // U+0124 uni0048 + { 792, 4, 9, 5, 0, -9 }, // U+0125 uni0068 + { 797, 6, 6, 7, 0, -6 }, // U+0126 uni0048 + { 802, 5, 7, 6, 0, -7 }, // U+0127 uni0068 + { 807, 4, 9, 5, 0, -9 }, // U+0128 uni0049 + { 812, 4, 8, 5, 0, -8 }, // U+0129 uni0069 + { 816, 3, 8, 4, 0, -8 }, // U+012A uni0049 + { 819, 3, 7, 4, 0, -7 }, // U+012B uni0069 + { 822, 3, 9, 4, 0, -9 }, // U+012C uni0049 + { 826, 3, 8, 4, 0, -8 }, // U+012D uni0069 + { 829, 3, 8, 4, 0, -6 }, // U+012E uni0049 + { 832, 2, 9, 3, 0, -7 }, // U+012F uni0069 + { 835, 3, 8, 4, 0, -8 }, // U+0130 uni0049 + { 838, 2, 5, 3, 0, -5 }, // U+0131 uni0069 + { 840, 6, 6, 7, 0, -6 }, // U+0132 uni0049 + { 845, 5, 8, 6, 0, -7 }, // U+0133 uni0069 + { 850, 4, 9, 5, 0, -9 }, // U+0134 uni004A + { 855, 4, 10, 5, 0, -8 }, // U+0135 uni006A + { 860, 4, 8, 5, 0, -6 }, // U+0136 uni004B + { 864, 4, 9, 5, 0, -7 }, // U+0137 uni006B + { 869, 4, 5, 5, 0, -5 }, // U+0138 uni006B + { 872, 4, 8, 5, 0, -8 }, // U+0139 uni004C + { 876, 4, 9, 5, 0, -9 }, // U+013A uni006C + { 881, 4, 8, 5, 0, -6 }, // U+013B uni004C + { 885, 3, 9, 4, 0, -7 }, // U+013C uni006C + { 889, 4, 7, 5, 0, -7 }, // U+013D uni004C + { 893, 4, 10, 5, 0, -9 }, // U+013E uni006C + { 898, 4, 6, 5, 0, -6 }, // U+013F uni004C + { 901, 4, 7, 5, 0, -7 }, // U+0140 uni006C + { 905, 4, 6, 5, 0, -6 }, // U+0141 uni004C + { 908, 3, 7, 4, 0, -7 }, // U+0142 uni006C + { 911, 4, 9, 5, 0, -9 }, // U+0143 uni004E + { 916, 4, 8, 5, 0, -8 }, // U+0144 uni006E + { 920, 4, 8, 5, 0, -6 }, // U+0145 uni004E + { 924, 4, 10, 5, 0, -8 }, // U+0146 uni006E + { 929, 4, 8, 5, 0, -8 }, // U+0147 uni004E + { 933, 4, 8, 5, 0, -8 }, // U+0148 uni006E + { 937, 4, 8, 5, 0, -8 }, // U+0149 uni006E + { 941, 4, 8, 5, 0, -6 }, // U+014A uni004E + { 945, 4, 10, 5, 0, -8 }, // U+014B uni006E + { 950, 4, 8, 5, 0, -8 }, // U+014C uni004F + { 954, 4, 7, 5, 0, -7 }, // U+014D uni006F + { 958, 4, 9, 5, 0, -9 }, // U+014E uni004F + { 963, 4, 8, 5, 0, -8 }, // U+014F uni006F + { 967, 4, 9, 5, 0, -9 }, // U+0150 uni004F + { 972, 4, 8, 5, 0, -8 }, // U+0151 uni006F + { 976, 6, 6, 7, 0, -6 }, // U+0152 char198 + { 981, 5, 5, 6, 0, -5 }, // U+0153 char230 + { 985, 4, 8, 5, 0, -8 }, // U+0154 uni0052 + { 989, 4, 8, 5, 0, -8 }, // U+0155 uni0072 + { 993, 4, 8, 5, 0, -6 }, // U+0156 uni0052 + { 997, 4, 7, 5, 0, -5 }, // U+0157 uni0072 + { 1001, 4, 8, 5, 0, -8 }, // U+0158 uni0052 + { 1005, 4, 8, 5, 0, -8 }, // U+0159 uni0072 + { 1009, 4, 9, 5, 0, -9 }, // U+015A uni0053 + { 1014, 4, 8, 5, 0, -8 }, // U+015B uni0073 + { 1018, 4, 9, 5, 0, -9 }, // U+015C uni0053 + { 1023, 4, 8, 5, 0, -8 }, // U+015D uni0073 + { 1027, 4, 8, 5, 0, -6 }, // U+015E uni0053 + { 1031, 4, 7, 5, 0, -5 }, // U+015F uni0073 + { 1035, 4, 9, 5, 0, -9 }, // U+0160 uni0053 + { 1040, 4, 8, 5, 0, -8 }, // U+0161 uni0073 + { 1044, 5, 7, 6, 0, -6 }, // U+0162 uni0054 + { 1049, 3, 7, 4, 0, -6 }, // U+0163 uni0074 + { 1052, 5, 9, 6, 0, -9 }, // U+0164 uni0054 + { 1058, 4, 8, 5, 0, -8 }, // U+0165 uni0074 + { 1062, 5, 6, 6, 0, -6 }, // U+0166 uni0054 + { 1066, 3, 6, 4, 0, -6 }, // U+0167 uni0074 + { 1069, 4, 9, 5, 0, -9 }, // U+0168 uni0055 + { 1074, 4, 8, 5, 0, -8 }, // U+0169 uni0075 + { 1078, 4, 8, 5, 0, -8 }, // U+016A uni0055 + { 1082, 4, 7, 5, 0, -7 }, // U+016B uni0075 + { 1086, 4, 9, 5, 0, -9 }, // U+016C uni0055 + { 1091, 4, 8, 5, 0, -8 }, // U+016D uni0075 + { 1095, 4, 9, 5, 0, -9 }, // U+016E uni0055 + { 1100, 4, 8, 5, 0, -8 }, // U+016F uni0075 + { 1104, 5, 9, 6, 0, -9 }, // U+0170 uni0055 + { 1110, 5, 8, 6, 0, -8 }, // U+0171 uni0075 + { 1115, 4, 8, 5, 0, -6 }, // U+0172 uni0055 + { 1119, 4, 7, 5, 0, -5 }, // U+0173 uni0075 + { 1123, 4, 9, 5, 0, -9 }, // U+0174 uni0077 + { 1128, 4, 8, 5, 0, -8 }, // U+0175 uni0077 + { 1132, 5, 9, 6, 0, -9 }, // U+0176 uni0059 + { 1138, 4, 10, 5, 0, -8 }, // U+0177 uni0079 + { 1143, 5, 8, 6, 0, -8 }, // U+0178 uni0059 + { 1148, 4, 9, 5, 0, -9 }, // U+0179 uni005A + { 1153, 4, 8, 5, 0, -8 }, // U+017A uni007A + { 1157, 4, 8, 5, 0, -8 }, // U+017B uni005A + { 1161, 4, 7, 5, 0, -7 }, // U+017C uni007A + { 1165, 4, 9, 5, 0, -9 }, // U+017D uni005A + { 1170, 4, 8, 5, 0, -8 }, // U+017E uni007A + { 1174, 4, 7, 5, 0, -7 }, // U+017F LATIN SMALL LETTER LONG S + { 1178, 5, 7, 6, 0, -7 }, // U+0180 uni0062 + { 1183, 5, 6, 6, 0, -6 }, // U+0181 uni0042 + { 1187, 5, 8, 6, 0, -8 }, // U+0182 uni0042 + { 1192, 4, 8, 5, 0, -8 }, // U+0183 uni0042 + { 1196, 6, 7, 7, 0, -7 }, // U+0184 uni0062 + { 1202, 5, 7, 6, 0, -7 }, // U+0185 uni0062 + { 1207, 4, 6, 5, 0, -6 }, // U+0186 uni0043 + { 1210, 5, 7, 6, 0, -7 }, // U+0187 uni0043 + { 1215, 4, 5, 5, 0, -5 }, // U+0188 uni0063 + { 1218, 5, 6, 6, 0, -6 }, // U+0189 uni0044 + { 1222, 6, 6, 7, 0, -6 }, // U+018A uni0044 + { 1227, 5, 8, 6, 0, -8 }, // U+018B uni0042 + { 1232, 4, 8, 5, 0, -8 }, // U+018C uni0042 + { 1236, 0, 0, 5, 0, 0 }, // U+018D (placeholder) + { 1236, 5, 6, 6, 0, -6 }, // U+018E uni0045 + { 1240, 4, 5, 5, 0, -5 }, // U+018F uni0065 + { 1243, 4, 6, 5, 0, -6 }, // U+0190 char1028 + { 1246, 5, 7, 6, 0, -6 }, // U+0191 uni0046 + { 1251, 4, 8, 5, 0, -7 }, // U+0192 uni0066 + { 1255, 7, 6, 8, 0, -6 }, // U+0193 uni0047 + { 1261, 5, 8, 6, 0, -6 }, // U+0194 LATIN CAPITAL LETTER GAMMA + { 1266, 5, 6, 6, 0, -6 }, // U+0195 LATIN SMALL LETTER HV + { 1270, 3, 7, 4, 0, -7 }, // U+0196 LATIN CAPITAL LETTER IOTA + { 1273, 3, 8, 4, 0, -8 }, // U+0197 uni0049 + { 1276, 5, 6, 6, 0, -6 }, // U+0198 uni004B + { 1280, 4, 7, 5, 0, -7 }, // U+0199 uni006B + { 1284, 3, 7, 4, 0, -7 }, // U+019A uni0049 + { 1287, 4, 6, 5, 0, -6 }, // U+019B LATIN SMALL LETTER LAMBDA WITH STROKE + { 1290, 5, 5, 6, 0, -5 }, // U+019C LATIN CAPITAL LETTER TURNED M + { 1294, 5, 6, 6, 0, -5 }, // U+019D LATIN CAPITAL LETTER N WITH LEFT HOOK + { 1298, 4, 5, 5, 0, -4 }, // U+019E uni006E + { 1301, 4, 5, 5, 0, -5 }, // U+019F GREEK SMALL LETTER THETA + { 1304, 6, 8, 7, 0, -8 }, // U+01A0 uni004F + { 1310, 6, 8, 7, 0, -8 }, // U+01A1 uni006F + { 1316, 6, 7, 7, 0, -7 }, // U+01A2 uni004F + { 1322, 6, 6, 7, 0, -6 }, // U+01A3 uni006F + { 1327, 6, 6, 7, 0, -6 }, // U+01A4 uni0050 + { 1332, 6, 9, 7, 0, -7 }, // U+01A5 uni0070 + { 1339, 5, 8, 6, 0, -7 }, // U+01A6 LATIN LETTER YR + { 1344, 4, 6, 5, 0, -6 }, // U+01A7 uni0053 + { 1347, 4, 5, 5, 0, -5 }, // U+01A8 uni0073 + { 1350, 5, 6, 6, 0, -6 }, // U+01A9 GREEK CAPITAL LETTER SIGMA + { 1354, 5, 8, 6, 0, -7 }, // U+01AA LATIN LETTER REVERSED ESH LOOP + { 1359, 3, 7, 4, 0, -6 }, // U+01AB uni0074 + { 1362, 5, 6, 6, 0, -6 }, // U+01AC uni0054 + { 1366, 3, 7, 4, 0, -7 }, // U+01AD uni0074 + { 1369, 5, 6, 6, 0, -6 }, // U+01AE uni0054 + { 1373, 5, 7, 6, 0, -7 }, // U+01AF uni0055 + { 1378, 5, 6, 6, 0, -6 }, // U+01B0 uni0075 + { 1382, 5, 7, 6, 0, -7 }, // U+01B1 LATIN CAPITAL LETTER UPSILON + { 1387, 5, 7, 6, 0, -7 }, // U+01B2 LATIN CAPITAL LETTER V WITH HOOK + { 1392, 6, 7, 7, 0, -7 }, // U+01B3 uni0059 + { 1398, 5, 8, 6, 0, -6 }, // U+01B4 uni0079 + { 1403, 5, 6, 6, 0, -6 }, // U+01B5 uni005A + { 1407, 4, 5, 5, 0, -5 }, // U+01B6 uni007A + { 1410, 4, 6, 5, 0, -6 }, // U+01B7 LATIN CAPITAL LETTER EZH + { 1413, 4, 6, 5, 0, -6 }, // U+01B8 LATIN CAPITAL LETTER EZH + { 1416, 4, 6, 5, 0, -5 }, // U+01B9 LATIN CAPITAL LETTER EZH + { 1419, 6, 8, 7, 0, -6 }, // U+01BA LATIN CAPITAL LETTER EZH + { 1425, 5, 7, 6, 0, -7 }, // U+01BB LATIN LETTER TWO WITH STROKE + { 1430, 5, 7, 6, 0, -7 }, // U+01BC LATIN CAPITAL LETTER TONE FIVE + { 1435, 5, 6, 6, 0, -6 }, // U+01BD LATIN SMALL LETTER TONE FIVE + { 1439, 5, 7, 6, 0, -8 }, // U+01BE LATIN LETTER INVERTED GLOTTAL STOP WITH STROKE + { 1444, 4, 7, 5, 0, -7 }, // U+01BF LATIN LETTER WYNN + { 1448, 3, 10, 4, 0, -8 }, // U+01C0 LATIN LETTER DENTAL CLICK + { 1452, 3, 10, 4, 0, -8 }, // U+01C1 LATIN LETTER LATERAL CLICK + { 1456, 5, 10, 6, 0, -8 }, // U+01C2 LATIN LETTER ALVEOLAR CLICK + { 1463, 3, 7, 4, 0, -7 }, // U+01C3 LATIN LETTER RETROFLEX CLICK + { 1466, 6, 9, 7, 0, -9 }, // U+01C4 LATIN CAPITAL LETTER DZ WITH CARON + { 1473, 6, 8, 7, 0, -8 }, // U+01C5 LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON + { 1479, 6, 9, 7, 0, -9 }, // U+01C6 LATIN SMALL LETTER DZ WITH CARON + { 1486, 5, 7, 6, 0, -7 }, // U+01C7 LATIN CAPITAL LETTER LJ + { 1491, 5, 9, 6, 0, -7 }, // U+01C8 LATIN CAPITAL LETTER L WITH SMALL LETTER J + { 1497, 5, 9, 6, 0, -7 }, // U+01C9 LATIN SMALL LETTER LJ + { 1503, 5, 7, 6, 0, -7 }, // U+01CA LATIN CAPITAL LETTER NJ + { 1508, 5, 9, 6, 0, -7 }, // U+01CB LATIN CAPITAL LETTER N WITH SMALL LETTER J + { 1514, 5, 9, 6, 0, -7 }, // U+01CC LATIN SMALL LETTER NJ + { 1520, 4, 8, 5, 0, -8 }, // U+01CD uni0041 + { 1524, 4, 8, 5, 0, -8 }, // U+01CE uni0061 + { 1528, 3, 8, 4, 0, -8 }, // U+01CF uni0049 + { 1531, 3, 8, 4, 0, -8 }, // U+01D0 uni0069 + { 1534, 4, 8, 5, 0, -8 }, // U+01D1 uni004F + { 1538, 4, 7, 5, 0, -7 }, // U+01D2 uni004F + { 1542, 4, 8, 5, 0, -8 }, // U+01D3 uni0055 + { 1546, 4, 7, 5, 0, -7 }, // U+01D4 uni0075 + { 1550, 4, 8, 5, 0, -8 }, // U+01D5 uni0055 + { 1554, 4, 8, 5, 0, -8 }, // U+01D6 uni0075 + { 1558, 4, 8, 5, 0, -8 }, // U+01D7 uni0055 + { 1562, 4, 8, 5, 0, -8 }, // U+01D8 uni0075 + { 1566, 4, 8, 5, 0, -8 }, // U+01D9 uni0055 + { 1570, 4, 8, 5, 0, -8 }, // U+01DA uni0075 + { 1574, 4, 8, 5, 0, -8 }, // U+01DB uni0055 + { 1578, 4, 8, 5, 0, -8 }, // U+01DC uni0075 + { 1582, 4, 5, 5, 0, -5 }, // U+01DD uni0065 + { 1585, 4, 8, 5, 0, -8 }, // U+01DE uni0041 + { 1589, 4, 8, 5, 0, -8 }, // U+01DF uni0061 + { 1593, 4, 8, 5, 0, -8 }, // U+01E0 uni0041 + { 1597, 4, 8, 5, 0, -8 }, // U+01E1 uni0061 + { 1601, 5, 9, 6, 0, -9 }, // U+01E2 LATIN CAPITAL LETTER AE WITH MACRON + { 1607, 5, 7, 6, 0, -7 }, // U+01E3 LATIN SMALL LETTER AE WITH MACRON + { 1612, 4, 6, 5, 0, -6 }, // U+01E4 uni0047 + { 1615, 4, 7, 5, 0, -5 }, // U+01E5 uni0067 + { 1619, 4, 8, 5, 0, -8 }, // U+01E6 uni0047 + { 1623, 4, 10, 5, 0, -8 }, // U+01E7 uni0067 + { 1628, 4, 6, 5, 0, -6 }, // U+01E8 uni004B + { 1631, 4, 7, 5, 0, -7 }, // U+01E9 uni006B + { 1635, 4, 7, 5, 0, -6 }, // U+01EA uni0051 + { 1639, 4, 6, 5, 0, -5 }, // U+01EB uni0051 + { 1642, 4, 9, 5, 0, -8 }, // U+01EC uni0051 + { 1647, 4, 9, 5, 0, -8 }, // U+01ED uni0051 + { 1652, 4, 8, 5, 0, -8 }, // U+01EE LATIN CAPITAL LETTER EZH + { 1656, 4, 9, 5, 0, -8 }, // U+01EF LATIN CAPITAL LETTER EZH + { 1661, 4, 10, 5, 0, -8 }, // U+01F0 uni006A + { 1666, 6, 7, 7, 0, -7 }, // U+01F1 LATIN CAPITAL LETTER DZ + { 1672, 6, 7, 7, 0, -7 }, // U+01F2 LATIN CAPITAL LETTER D WITH SMALL LETTER Z + { 1678, 6, 7, 7, 0, -7 }, // U+01F3 LATIN SMALL LETTER DZ + { 1684, 4, 8, 5, 0, -8 }, // U+01F4 uni0047 + { 1688, 4, 10, 5, 0, -8 }, // U+01F5 uni0067 + { 1693, 5, 7, 6, 0, -7 }, // U+01F6 LATIN CAPITAL LETTER HWAIR + { 1698, 5, 7, 6, 0, -7 }, // U+01F7 LATIN CAPITAL LETTER WYNN + { 1703, 4, 8, 5, 0, -8 }, // U+01F8 uni004E + { 1707, 4, 8, 5, 0, -8 }, // U+01F9 uni006E + { 1711, 4, 8, 5, 0, -8 }, // U+01FA uni0041 + { 1715, 4, 8, 5, 0, -8 }, // U+01FB uni0061 + { 1719, 6, 8, 7, 0, -8 }, // U+01FC char198 + { 1725, 5, 7, 6, 0, -7 }, // U+01FD char230 + { 1730, 6, 8, 7, 0, -8 }, // U+01FE char216 + { 1736, 6, 8, 7, 0, -8 }, // U+01FF char248 + { 1742, 4, 8, 5, 0, -8 }, // U+0200 uni0041 + { 1746, 4, 8, 5, 0, -8 }, // U+0201 uni0061 + { 1750, 4, 8, 5, 0, -8 }, // U+0202 uni0041 + { 1754, 4, 8, 5, 0, -8 }, // U+0203 uni0061 + { 1758, 4, 8, 5, 0, -8 }, // U+0204 uni0045 + { 1762, 4, 8, 5, 0, -8 }, // U+0205 uni0065 + { 1766, 4, 8, 5, 0, -8 }, // U+0206 uni0045 + { 1770, 4, 7, 5, 0, -7 }, // U+0207 uni0065 + { 1774, 4, 8, 5, 0, -8 }, // U+0208 uni0049 + { 1778, 4, 8, 5, 0, -8 }, // U+0209 uni0069 + { 1782, 5, 8, 6, 0, -8 }, // U+020A uni0049 + { 1787, 4, 8, 5, 0, -8 }, // U+020B uni0069 + { 1791, 5, 9, 6, 0, -9 }, // U+020C LATIN CAPITAL LETTER O WITH DOUBLE GRAVE + { 1797, 5, 8, 6, 0, -8 }, // U+020D LATIN SMALL LETTER O WITH DOUBLE GRAVE + { 1802, 4, 9, 5, 0, -9 }, // U+020E LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1807, 4, 8, 5, 0, -8 }, // U+020F LATIN SMALL LETTER O WITH INVERTED BREVE + { 1811, 4, 8, 5, 0, -8 }, // U+0210 uni0052 + { 1815, 4, 8, 5, 0, -8 }, // U+0211 uni0072 + { 1819, 4, 8, 5, 0, -8 }, // U+0212 uni0052 + { 1823, 4, 8, 5, 0, -8 }, // U+0213 uni0072 + { 1827, 4, 8, 5, 0, -8 }, // U+0214 uni0055 + { 1831, 4, 8, 5, 0, -8 }, // U+0215 uni0075 + { 1835, 4, 8, 5, 0, -8 }, // U+0216 uni0055 + { 1839, 4, 8, 5, 0, -8 }, // U+0217 uni0075 + { 1843, 4, 7, 5, 0, -6 }, // U+0218 uni0053 + { 1847, 4, 6, 5, 0, -5 }, // U+0219 uni0073 + { 1850, 5, 7, 6, 0, -6 }, // U+021A uni0054 + { 1855, 3, 7, 4, 0, -6 }, // U+021B uni0074 + { 1858, 4, 8, 5, 0, -7 }, // U+021C LATIN CAPITAL LETTER YOGH + { 1862, 4, 6, 5, 0, -6 }, // U+021D LATIN SMALL LETTER YOGH + { 1865, 4, 8, 5, 0, -8 }, // U+021E uni0048 + { 1869, 4, 8, 5, 0, -8 }, // U+021F uni0068 + { 1873, 5, 8, 6, 0, -6 }, // U+0220 LATIN CAPITAL LETTER N WITH LONG RIGHT LEG + { 1878, 6, 8, 7, 0, -7 }, // U+0221 LATIN SMALL LETTER D WITH CURL + { 1884, 5, 8, 6, 0, -8 }, // U+0222 LATIN CAPITAL LETTER OU + { 1889, 5, 7, 6, 0, -7 }, // U+0223 LATIN SMALL LETTER OU + { 1894, 5, 7, 6, 0, -6 }, // U+0224 uni005A + { 1899, 5, 6, 6, 0, -5 }, // U+0225 uni007A + { 1903, 4, 8, 5, 0, -8 }, // U+0226 uni0041 + { 1907, 4, 7, 5, 0, -7 }, // U+0227 uni0061 + { 1911, 4, 7, 5, 0, -6 }, // U+0228 uni0045 + { 1915, 4, 6, 5, 0, -5 }, // U+0229 uni0065 + { 1918, 4, 8, 5, 0, -8 }, // U+022A LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1922, 4, 8, 5, 0, -8 }, // U+022B LATIN SMALL LETTER O WITH INVERTED BREVE + { 1926, 4, 9, 5, 0, -9 }, // U+022C LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1931, 4, 9, 5, 0, -9 }, // U+022D LATIN SMALL LETTER O WITH INVERTED BREVE + { 1936, 4, 8, 5, 0, -8 }, // U+022E LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1940, 4, 8, 5, 0, -8 }, // U+022F LATIN SMALL LETTER O WITH INVERTED BREVE + { 1944, 4, 8, 5, 0, -8 }, // U+0230 LATIN CAPITAL LETTER O WITH INVERTED BREVE + { 1948, 4, 8, 5, 0, -8 }, // U+0231 LATIN SMALL LETTER O WITH INVERTED BREVE + { 1952, 5, 8, 6, 0, -8 }, // U+0232 uni0059 + { 1957, 4, 9, 5, 0, -7 }, // U+0233 uni0079 + { 1962, 5, 7, 6, 0, -7 }, // U+0234 LATIN SMALL LETTER L WITH CURL + { 1967, 6, 6, 7, 0, -5 }, // U+0235 LATIN SMALL LETTER N WITH CURL + { 1972, 5, 7, 6, 0, -7 }, // U+0236 LATIN SMALL LETTER T WITH CURL + { 1977, 3, 7, 4, 0, -5 }, // U+0237 LATIN SMALL LETTER DOTLESS J + { 1980, 5, 7, 6, 0, -7 }, // U+0238 LATIN SMALL LETTER DB DIGRAPH + { 1985, 5, 7, 6, 0, -5 }, // U+0239 LATIN SMALL LETTER QP DIGRAPH + { 1990, 5, 7, 6, 0, -7 }, // U+023A uni0041 + { 1995, 5, 7, 6, 0, -7 }, // U+023B uni0043 + { 2000, 5, 6, 6, 0, -6 }, // U+023C uni0063 + { 2004, 6, 7, 7, 0, -7 }, // U+023D LATIN CAPITAL LETTER L WITH BAR + { 2010, 5, 8, 6, 0, -8 }, // U+023E LATIN CAPITAL LETTER T WITH DIAGONAL STROKE + { 2015, 5, 7, 6, 0, -5 }, // U+023F uni0073 + { 2020, 4, 7, 5, 0, -5 }, // U+0240 uni007A + { 2024, 5, 7, 6, 0, -8 }, // U+0241 LATIN CAPITAL LETTER GLOTTAL STOP + { 2029, 5, 6, 6, 0, -7 }, // U+0242 LATIN SMALL LETTER GLOTTAL STOP + { 2033, 6, 6, 7, 0, -6 }, // U+0243 uni0042 + { 2038, 4, 6, 5, 0, -6 }, // U+0244 uni0041 + { 2041, 4, 6, 5, 0, -6 }, // U+0245 uni0056 + { 2044, 5, 10, 6, 0, -8 }, // U+0246 LATIN CAPITAL LETTER E WITH STROKE + { 2051, 5, 10, 6, 0, -8 }, // U+0247 LATIN SMALL LETTER E WITH STROKE + { 2058, 6, 7, 7, 0, -7 }, // U+0248 LATIN CAPITAL LETTER J WITH STROKE + { 2064, 5, 9, 6, 0, -7 }, // U+0249 LATIN SMALL LETTER J WITH STROKE + { 2070, 5, 8, 6, 0, -6 }, // U+024A uni0071 + { 2075, 5, 7, 6, 0, -5 }, // U+024B uni0071 + { 2080, 5, 6, 6, 0, -6 }, // U+024C uni0052 + { 2084, 5, 5, 6, 0, -5 }, // U+024D uni0072 + { 2088, 5, 6, 6, 0, -6 }, // U+024E uni0059 + { 2092, 4, 7, 5, 0, -5 }, // U+024F uni0079 + { 2096, 4, 5, 5, 0, -5 }, // U+0250 uni0061 + { 2099, 4, 5, 5, 0, -5 }, // U+0251 uni0061 + { 2102, 4, 5, 5, 0, -5 }, // U+0252 uni0061 + { 2105, 4, 7, 5, 0, -7 }, // U+0253 uni0062 + { 2109, 4, 5, 5, 0, -5 }, // U+0254 uni0063 + { 2112, 4, 5, 5, 0, -5 }, // U+0255 uni0063 + { 2115, 5, 8, 6, 0, -7 }, // U+0256 uni0064 + { 2120, 5, 8, 6, 0, -8 }, // U+0257 uni0064 + { 2125, 4, 5, 5, 0, -5 }, // U+0258 uni0065 + { 2128, 4, 5, 5, 0, -5 }, // U+0259 uni0065 + { 2131, 5, 5, 6, 0, -5 }, // U+025A uni0065 + { 2135, 4, 6, 5, 0, -6 }, // U+025B uni0033 + { 2138, 4, 6, 5, 0, -6 }, // U+025C uni0033 + { 2141, 5, 6, 6, 0, -6 }, // U+025D uni0033 + { 2145, 4, 6, 5, 0, -6 }, // U+025E char223 + { 2148, 4, 7, 5, 0, -5 }, // U+025F uni006A + { 2152, 5, 8, 6, 0, -6 }, // U+0260 uni0067 + { 2157, 4, 7, 5, 0, -5 }, // U+0261 uni0067 + { 2161, 4, 5, 5, 0, -5 }, // U+0262 uni0047 + { 2164, 4, 7, 5, 0, -5 }, // U+0263 uni0076 + { 2168, 6, 7, 7, 0, -5 }, // U+0264 uni0076 + { 2174, 4, 7, 5, 0, -7 }, // U+0265 uni0068 + { 2178, 4, 8, 5, 0, -8 }, // U+0266 uni0068 + { 2182, 4, 9, 5, 0, -8 }, // U+0267 uni0068 + { 2187, 3, 7, 4, 0, -7 }, // U+0268 uni0069 + { 2190, 3, 6, 4, 0, -5 }, // U+0269 uni0069 + { 2193, 3, 5, 4, 0, -5 }, // U+026A uni0069 + { 2195, 5, 7, 6, 0, -7 }, // U+026B uni006C + { 2200, 4, 7, 5, 0, -7 }, // U+026C uni006C + { 2204, 3, 8, 4, 0, -7 }, // U+026D uni006C + { 2207, 5, 8, 6, 0, -7 }, // U+026E LATIN SMALL LETTER LEZH + { 2212, 4, 5, 5, 0, -5 }, // U+026F uni006D + { 2215, 4, 6, 5, 0, -5 }, // U+0270 uni006D + { 2218, 4, 6, 5, 0, -5 }, // U+0271 uni006D + { 2221, 5, 6, 6, 0, -5 }, // U+0272 uni006E + { 2225, 5, 6, 6, 0, -5 }, // U+0273 uni006E + { 2229, 4, 5, 5, 0, -5 }, // U+0274 GREEK SMALL LETTER PAMPHYLIAN DIGAMMA + { 2232, 4, 5, 5, 0, -5 }, // U+0275 LATIN SMALL LETTER BARRED O + { 2235, 6, 5, 7, 0, -5 }, // U+0276 char230 + { 2239, 5, 5, 6, 0, -5 }, // U+0277 GREEK SMALL LETTER OMEGA WITH TONOS + { 2243, 5, 7, 6, 0, -7 }, // U+0278 GREEK CAPITAL LETTER PHI + { 2248, 4, 5, 5, 0, -5 }, // U+0279 uni0072 + { 2251, 4, 6, 5, 0, -6 }, // U+027A uni0072 + { 2254, 5, 6, 6, 0, -5 }, // U+027B uni0072 + { 2258, 4, 6, 5, 0, -6 }, // U+027C uni0072 + { 2261, 4, 7, 5, 0, -6 }, // U+027D uni0072 + { 2265, 4, 5, 5, 0, -5 }, // U+027E uni0072 + { 2268, 4, 5, 5, 0, -5 }, // U+027F uni0072 + { 2271, 4, 6, 5, 0, -6 }, // U+0280 char1071 + { 2274, 4, 6, 5, 0, -6 }, // U+0281 char1071 + { 2277, 4, 7, 5, 0, -5 }, // U+0282 uni0073 + { 2281, 5, 8, 6, 0, -7 }, // U+0283 uni0066 + { 2286, 5, 8, 6, 0, -7 }, // U+0284 uni0066 + { 2291, 3, 7, 4, 0, -6 }, // U+0285 uni0066 + { 2294, 5, 8, 6, 0, -7 }, // U+0286 uni0066 + { 2299, 4, 7, 5, 0, -7 }, // U+0287 uni0066 + { 2303, 4, 5, 5, 0, -5 }, // U+0288 uni0075 + { 2306, 4, 5, 5, 0, -5 }, // U+0289 uni0075 + { 2309, 4, 5, 5, 0, -5 }, // U+028A uni0076 + { 2312, 4, 5, 5, 0, -5 }, // U+028B uni0076 + { 2315, 6, 5, 7, 0, -5 }, // U+028C uni0076 + { 2319, 4, 5, 5, 0, -5 }, // U+028D uni006D + { 2322, 4, 7, 5, 0, -5 }, // U+028E uni0079 + { 2326, 5, 4, 6, 0, -4 }, // U+028F uni0059 + { 2329, 4, 7, 5, 0, -5 }, // U+0290 uni007A + { 2333, 4, 6, 5, 0, -5 }, // U+0291 uni007A + { 2336, 4, 7, 5, 0, -5 }, // U+0292 uni007A + { 2340, 4, 7, 5, 0, -5 }, // U+0293 uni007A + { 2344, 5, 7, 6, 0, -7 }, // U+0294 LATIN LETTER GLOTTAL STOP + { 2349, 5, 7, 6, 0, -7 }, // U+0295 LATIN LETTER PHARYNGEAL VOICED FRICATIVE + { 2354, 5, 7, 6, 0, -7 }, // U+0296 LATIN LETTER INVERTED GLOTTAL STOP + { 2359, 4, 7, 5, 0, -6 }, // U+0297 uni0043 + { 2363, 5, 5, 6, 0, -5 }, // U+0298 LATIN LETTER BILABIAL CLICK + { 2367, 4, 5, 5, 0, -5 }, // U+0299 LATIN LETTER SMALL CAPITAL B + { 2370, 4, 5, 5, 0, -5 }, // U+029A LATIN SMALL LETTER CLOSED OPEN E + { 2373, 5, 6, 6, 0, -6 }, // U+029B uni0047 + { 2377, 4, 5, 5, 0, -5 }, // U+029C uni0048 + { 2380, 4, 9, 5, 0, -7 }, // U+029D uni006A + { 2385, 4, 7, 5, 0, -7 }, // U+029E uni006B + { 2389, 4, 5, 5, 0, -5 }, // U+029F LATIN LETTER SMALL CAPITAL L + { 2392, 5, 8, 6, 0, -6 }, // U+02A0 uni0071 + { 2397, 5, 7, 6, 0, -7 }, // U+02A1 LATIN LETTER GLOTTAL STOP WITH STROKE + { 2402, 5, 7, 6, 0, -7 }, // U+02A2 LATIN LETTER REVERSED GLOTTAL STOP WITH STROKE + { 2407, 6, 7, 7, 0, -7 }, // U+02A3 LATIN SMALL LETTER DZ DIGRAPH + { 2413, 6, 8, 7, 0, -7 }, // U+02A4 LATIN SMALL LETTER DEZH DIGRAPH + { 2419, 6, 8, 7, 0, -7 }, // U+02A5 LATIN SMALL LETTER DZ DIGRAPH WITH CURL + { 2425, 6, 6, 7, 0, -6 }, // U+02A6 LATIN SMALL LETTER TS DIGRAPH + { 2430, 5, 9, 6, 0, -7 }, // U+02A7 LATIN SMALL LETTER TESH DIGRAPH + { 2436, 6, 7, 7, 0, -6 }, // U+02A8 LATIN SMALL LETTER TC DIGRAPH WITH CURL + { 2442, 6, 9, 7, 0, -7 }, // U+02A9 LATIN SMALL LETTER FENG DIGRAPH + { 2449, 5, 7, 6, 0, -7 }, // U+02AA LATIN SMALL LETTER LS DIGRAPH + { 2454, 5, 7, 6, 0, -7 }, // U+02AB LATIN SMALL LETTER LZ DIGRAPH + { 2459, 5, 6, 6, 0, -6 }, // U+02AC LATIN LETTER BILABIAL PERCUSSIVE + { 2463, 5, 5, 6, 0, -5 }, // U+02AD LATIN LETTER BIDENTAL PERCUSSIVE + { 2467, 5, 7, 6, 0, -7 }, // U+02AE uni0068 + { 2472, 6, 8, 7, 0, -7 }, // U+02AF uni0068 + { 2478, 0, 0, 5, 0, 0 }, // U+02B0 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B1 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B2 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B3 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B4 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B5 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B6 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B7 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B8 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02B9 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BA (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BB (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BC (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BD (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BE (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02BF (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02C0 (placeholder) + { 2478, 0, 0, 5, 0, 0 }, // U+02C1 (placeholder) + { 2478, 3, 5, 4, 0, -5 }, // U+02C2 uni003C + { 2480, 3, 5, 4, 0, -5 }, // U+02C3 uni003C + { 2482, 5, 3, 6, 0, -5 }, // U+02C4 uni005E + { 2484, 5, 3, 6, 0, -5 }, // U+02C5 uni005E + { 2486, 5, 3, 6, 0, -8 }, // U+02C6 MODIFIER LETTER CIRCUMFLEX ACCENT + { 2488, 5, 3, 6, 0, -9 }, // U+02C7 CARON + { 2490, 1, 2, 2, 0, -7 }, // U+02C8 MODIFIER LETTER VERTICAL LINE + { 2491, 2, 1, 3, 0, -7 }, // U+02C9 MODIFIER LETTER MACRON + { 2492, 2, 2, 3, 0, -7 }, // U+02CA MODIFIER LETTER ACUTE ACCENT + { 2493, 2, 2, 3, 0, -7 }, // U+02CB MODIFIER LETTER GRAVE ACCENT + { 2494, 0, 0, 5, 0, 0 }, // U+02CC (placeholder) + { 2494, 0, 0, 5, 0, 0 }, // U+02CD (placeholder) + { 2494, 0, 0, 5, 0, 0 }, // U+02CE (placeholder) + { 2494, 0, 0, 5, 0, 0 }, // U+02CF (placeholder) + { 2494, 3, 6, 4, 0, -6 }, // U+02D0 MODIFIER LETTER TRIANGULAR COLON + { 2497, 3, 2, 4, 0, -4 }, // U+02D1 MODIFIER LETTER HALF TRIANGULAR COLON + { 2498, 0, 0, 5, 0, 0 }, // U+02D2 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D3 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D4 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D5 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D6 (placeholder) + { 2498, 0, 0, 5, 0, 0 }, // U+02D7 (placeholder) + { 2498, 5, 2, 6, 0, -8 }, // U+02D8 BREVE + { 2500, 2, 2, 3, 0, -9 }, // U+02D9 DOT ABOVE + { 2501, 3, 3, 4, 0, -8 }, // U+02DA RING ABOVE + { 2503, 2, 3, 3, 0, -1 }, // U+02DB OGONEK + { 2504, 5, 2, 6, 0, -8 }, // U+02DC SMALL TILDE + { 2506, 4, 3, 5, 0, -9 }, // U+02DD DOUBLE ACUTE ACCENT + { 2508, 0, 0, 5, 0, 0 }, // U+02DE (placeholder) + { 2508, 0, 0, 5, 0, 0 }, // U+02DF (placeholder) + { 2508, 5, 5, 6, 0, -6 }, // U+02E0 MODIFIER LETTER SMALL GAMMA + { 2512, 0, 0, 5, 0, 0 }, // U+02E1 (placeholder) + { 2512, 0, 0, 5, 0, 0 }, // U+02E2 (placeholder) + { 2512, 0, 0, 5, 0, 0 }, // U+02E3 (placeholder) + { 2512, 3, 5, 4, 0, -8 }, // U+02E4 MODIFIER LETTER SMALL REVERSED GLOTTAL STOP + { 2514, 0, 0, 5, 0, 0 }, // U+02E5 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E6 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E7 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E8 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02E9 (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02EA (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02EB (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02EC (placeholder) + { 2514, 0, 0, 5, 0, 0 }, // U+02ED (placeholder) + { 2514, 5, 4, 6, 0, -7 }, // U+02EE MODIFIER LETTER DOUBLE APOSTROPHE + { 2517, 0, 0, 5, 0, 0 }, // U+02EF (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F0 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F1 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F2 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F3 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F4 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F5 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F6 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F7 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F8 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02F9 (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FA (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FB (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FC (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FD (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FE (placeholder) + { 2517, 0, 0, 5, 0, 0 }, // U+02FF (placeholder) + { 2517, 2, 2, 3, 0, -9 }, // U+0300 COMBINING GRAVE ACCENT + { 2518, 2, 2, 3, 0, -9 }, // U+0301 COMBINING ACUTE ACCENT + { 2519, 4, 2, 5, 0, -9 }, // U+0302 COMBINING CIRCUMFLEX ACCENT + { 2520, 5, 2, 6, 0, -9 }, // U+0303 COMBINING TILDE + { 2522, 3, 1, 4, 0, -9 }, // U+0304 COMBINING MACRON + { 2523, 5, 1, 6, 0, -9 }, // U+0305 COMBINING OVERLINE + { 2524, 4, 2, 5, 0, -9 }, // U+0306 COMBINING BREVE + { 2525, 1, 1, 2, 0, -9 }, // U+0307 COMBINING DOT ABOVE + { 2526, 3, 1, 4, 0, -9 }, // U+0308 COMBINING DIAERESIS + { 2527, 3, 3, 4, 0, -9 }, // U+0309 COMBINING HOOK ABOVE + { 2529, 3, 3, 4, 0, -9 }, // U+030A COMBINING RING ABOVE + { 2531, 5, 2, 6, 0, -9 }, // U+030B COMBINING DOUBLE ACUTE ACCENT + { 2533, 3, 2, 4, 0, -9 }, // U+030C COMBINING CARON + { 2534, 1, 2, 2, 0, -9 }, // U+030D COMBINING VERTICAL LINE ABOVE + { 2535, 3, 2, 4, 0, -9 }, // U+030E COMBINING DOUBLE VERTICAL LINE ABOVE + { 2536, 0, 0, 5, 0, 0 }, // U+030F (placeholder) + { 2536, 5, 3, 6, 0, -9 }, // U+0310 COMBINING CANDRABINDU + { 2538, 5, 2, 6, 0, -9 }, // U+0311 COMBINING INVERTED BREVE + { 2540, 2, 3, 3, 0, -9 }, // U+0312 COMBINING TURNED COMMA ABOVE + { 2541, 2, 3, 3, 0, -9 }, // U+0313 COMBINING COMMA ABOVE + { 2542, 2, 3, 3, 0, -9 }, // U+0314 COMBINING REVERSED COMMA ABOVE + { 2543, 2, 3, 3, 0, -9 }, // U+0315 COMBINING COMMA ABOVE RIGHT + { 2544, 0, 0, 5, 0, 0 }, // U+0316 (placeholder) + { 2544, 0, 0, 5, 0, 0 }, // U+0317 (placeholder) + { 2544, 0, 0, 5, 0, 0 }, // U+0318 (placeholder) + { 2544, 0, 0, 5, 0, 0 }, // U+0319 (placeholder) + { 2544, 4, 3, 5, 0, -9 }, // U+031A COMBINING LEFT ANGLE ABOVE + { 2546, 0, 0, 5, 0, 0 }, // U+031B (placeholder) + { 2546, 2, 3, 3, 0, 0 }, // U+031C COMBINING LEFT HALF RING BELOW + { 2547, 0, 0, 5, 0, 0 }, // U+031D (placeholder) + { 2547, 0, 0, 5, 0, 0 }, // U+031E (placeholder) + { 2547, 3, 3, 4, 0, 0 }, // U+031F COMBINING PLUS SIGN BELOW + { 2549, 3, 1, 4, 0, 1 }, // U+0320 COMBINING MINUS SIGN BELOW + { 2550, 0, 0, 5, 0, 0 }, // U+0321 (placeholder) + { 2550, 0, 0, 5, 0, 0 }, // U+0322 (placeholder) + { 2550, 0, 0, 5, 0, 0 }, // U+0323 (placeholder) + { 2550, 4, 1, 5, 0, 1 }, // U+0324 COMBINING DIAERESIS BELOW + { 2551, 4, 3, 5, 0, 0 }, // U+0325 COMBINING RING BELOW + { 2553, 0, 0, 5, 0, 0 }, // U+0326 (placeholder) + { 2553, 0, 0, 5, 0, 0 }, // U+0327 (placeholder) + { 2553, 0, 0, 5, 0, 0 }, // U+0328 (placeholder) + { 2553, 0, 0, 5, 0, 0 }, // U+0329 (placeholder) + { 2553, 3, 2, 4, 0, 0 }, // U+032A COMBINING BRIDGE BELOW + { 2554, 0, 0, 5, 0, 0 }, // U+032B (placeholder) + { 2554, 3, 2, 4, 0, 0 }, // U+032C COMBINING CARON BELOW + { 2555, 0, 0, 5, 0, 0 }, // U+032D (placeholder) + { 2555, 0, 0, 5, 0, 0 }, // U+032E (placeholder) + { 2555, 0, 0, 5, 0, 0 }, // U+032F (placeholder) + { 2555, 4, 2, 5, 0, 1 }, // U+0330 COMBINING TILDE BELOW + { 2556, 0, 0, 5, 0, 0 }, // U+0331 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0332 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0333 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0334 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0335 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0336 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0337 (placeholder) + { 2556, 0, 0, 5, 0, 0 }, // U+0338 (placeholder) + { 2556, 4, 3, 5, 0, 0 }, // U+0339 COMBINING RIGHT HALF RING BELOW + { 2558, 3, 2, 4, 0, 1 }, // U+033A COMBINING INVERTED BRIDGE BELOW + { 2559, 4, 3, 5, 0, 0 }, // U+033B COMBINING SQUARE BELOW + { 2561, 5, 2, 6, 0, 1 }, // U+033C COMBINING SEAGULL BELOW + { 2563, 0, 0, 5, 0, 0 }, // U+033D (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+033E (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+033F (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+0340 (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+0341 (placeholder) + { 2563, 0, 0, 5, 0, 0 }, // U+0342 (placeholder) + { 2563, 2, 4, 3, 0, -7 }, // U+0343 COMBINING GREEK KORONIS + { 2564, 3, 2, 4, 0, -4 }, // U+0344 COMBINING GREEK DIALYTIKA TONOS + { 2565, 0, 0, 5, 0, 0 }, // U+0345 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0346 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0347 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0348 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0349 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034A (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034B (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034C (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034D (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034E (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+034F (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0350 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0351 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0352 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0353 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0354 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0355 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0356 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0357 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0358 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+0359 (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+035A (placeholder) + { 2565, 0, 0, 5, 0, 0 }, // U+035B (placeholder) + { 2565, 5, 2, 6, 0, -1 }, // U+035C COMBINING DOUBLE BREVE BELOW + { 2567, 0, 0, 5, 0, 0 }, // U+035D (placeholder) + { 2567, 0, 0, 5, 0, 0 }, // U+035E (placeholder) + { 2567, 0, 0, 5, 0, 0 }, // U+035F (placeholder) + { 2567, 0, 0, 5, 0, 0 }, // U+0360 (placeholder) + { 2567, 5, 2, 6, 0, -9 }, // U+0361 COMBINING DOUBLE INVERTED BREVE + { 2569, 0, 0, 5, 0, 0 }, // U+0362 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0363 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0364 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0365 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0366 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0367 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0368 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+0369 (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036A (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036B (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036C (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036D (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036E (placeholder) + { 2569, 0, 0, 5, 0, 0 }, // U+036F (placeholder) + { 2569, 4, 7, 5, 0, -7 }, // U+0370 GREEK CAPITAL LETTER HETA + { 2573, 5, 6, 6, 0, -6 }, // U+0371 GREEK SMALL LETTER HETA + { 2577, 5, 6, 6, 0, -6 }, // U+0372 GREEK CAPITAL LETTER ARCHAIC SAMPI + { 2581, 5, 5, 6, 0, -5 }, // U+0373 GREEK SMALL LETTER ARCHAIC SAMPI + { 2585, 3, 2, 4, 0, -8 }, // U+0374 GREEK NUMERAL SIGN + { 2586, 4, 3, 5, 0, -2 }, // U+0375 GREEK LOWER NUMERAL SIGN + { 2588, 4, 6, 5, 0, -6 }, // U+0376 uni004E + { 2591, 5, 6, 6, 0, -5 }, // U+0377 GREEK SMALL LETTER PAMPHYLIAN DIGAMMA + { 2595, 0, 0, 5, 0, 0 }, // U+0378 (placeholder) + { 2595, 0, 0, 5, 0, 0 }, // U+0379 (placeholder) + { 2595, 5, 2, 6, 0, 0 }, // U+037A GREEK YPOGEGRAMMENI + { 2597, 5, 5, 6, 0, -5 }, // U+037B GREEK SMALL REVERSED LUNATE SIGMA SYMBOL + { 2601, 5, 5, 6, 0, -5 }, // U+037C GREEK SMALL DOTTED LUNATE SIGMA SYMBOL + { 2605, 5, 5, 6, 0, -5 }, // U+037D GREEK SMALL REVERSED DOTTED LUNATE SIGMA SYMBOL + { 2609, 2, 6, 3, 0, -5 }, // U+037E uni003B + { 2611, 6, 3, 7, 0, -4 }, // U+037F uni037F + { 2614, 5, 3, 6, 0, -3 }, // U+0380 uni037F + { 2616, 5, 3, 6, 0, -3 }, // U+0381 uni0380 + { 2618, 0, 0, 5, 0, 0 }, // U+0382 (placeholder) + { 2618, 0, 0, 5, 0, 0 }, // U+0383 (placeholder) + { 2618, 2, 3, 3, 0, -8 }, // U+0384 GREEK TONOS + { 2619, 6, 3, 7, 0, -8 }, // U+0385 GREEK DIALYTIKA TONOS + { 2622, 4, 9, 5, 0, -9 }, // U+0386 GREEK CAPITAL LETTER ALPHA WITH TONOS + { 2627, 2, 2, 3, 0, -5 }, // U+0387 GREEK ANO TELEIA + { 2628, 4, 8, 5, 0, -8 }, // U+0388 char201 + { 2632, 5, 7, 6, 0, -7 }, // U+0389 GREEK CAPITAL LETTER ETA WITH TONOS + { 2637, 4, 7, 5, 0, -7 }, // U+038A GREEK CAPITAL LETTER IOTA WITH TONOS + { 2641, 0, 0, 5, 0, 0 }, // U+038B (placeholder) + { 2641, 5, 8, 6, 0, -8 }, // U+038C GREEK CAPITAL LETTER OMICRON WITH TONOS + { 2646, 0, 0, 5, 0, 0 }, // U+038D (placeholder) + { 2646, 5, 7, 6, 0, -7 }, // U+038E GREEK CAPITAL LETTER UPSILON WITH TONOS + { 2651, 5, 8, 6, 0, -8 }, // U+038F GREEK CAPITAL LETTER OMEGA WITH TONOS + { 2656, 6, 9, 7, 0, -9 }, // U+0390 GREEK SMALL LETTER IOTA WITH DIALYTIKA AND TONOS + { 2663, 4, 6, 5, 0, -6 }, // U+0391 GREEK CAPITAL LETTER ALPHA + { 2666, 4, 6, 5, 0, -6 }, // U+0392 uni0042 + { 2669, 4, 5, 5, 0, -5 }, // U+0393 GREEK CAPITAL LETTER GAMMA + { 2672, 5, 6, 6, 0, -6 }, // U+0394 GREEK CAPITAL LETTER DELTA + { 2676, 4, 6, 5, 0, -6 }, // U+0395 uni0045 + { 2679, 4, 6, 5, 0, -6 }, // U+0396 uni005A + { 2682, 4, 6, 5, 0, -6 }, // U+0397 uni0048 + { 2685, 4, 6, 5, 0, -6 }, // U+0398 GREEK CAPITAL LETTER THETA + { 2688, 3, 6, 4, 0, -6 }, // U+0399 GREEK CAPITAL LETTER IOTA + { 2691, 4, 6, 5, 0, -6 }, // U+039A uni004B + { 2694, 5, 6, 6, 0, -6 }, // U+039B GREEK CAPITAL LETTER LAMDA + { 2698, 4, 6, 5, 0, -6 }, // U+039C uni004D + { 2701, 4, 6, 5, 0, -6 }, // U+039D uni004E + { 2704, 5, 5, 6, 0, -5 }, // U+039E GREEK CAPITAL LETTER XI + { 2708, 4, 6, 5, 0, -6 }, // U+039F GREEK CAPITAL LETTER OMICRON + { 2711, 5, 6, 6, 0, -6 }, // U+03A0 char1055 + { 2715, 4, 6, 5, 0, -6 }, // U+03A1 uni0050 + { 2718, 0, 0, 5, 0, 0 }, // U+03A2 (placeholder) + { 2718, 5, 6, 6, 0, -6 }, // U+03A3 GREEK CAPITAL LETTER SIGMA + { 2722, 5, 6, 6, 0, -6 }, // U+03A4 GREEK CAPITAL LETTER TAU + { 2726, 5, 6, 6, 0, -6 }, // U+03A5 uni0059 + { 2730, 5, 7, 6, 0, -7 }, // U+03A6 GREEK CAPITAL LETTER PHI + { 2735, 4, 6, 5, 0, -6 }, // U+03A7 uni0058 + { 2738, 5, 6, 6, 0, -6 }, // U+03A8 GREEK CAPITAL LETTER PSI + { 2742, 5, 6, 6, 0, -6 }, // U+03A9 GREEK CAPITAL LETTER OMEGA + { 2746, 3, 7, 4, 0, -7 }, // U+03AA GREEK CAPITAL LETTER IOTA WITH DIALYTIKA + { 2749, 5, 8, 6, 0, -8 }, // U+03AB GREEK CAPITAL LETTER UPSILON WITH DIALYTIKA + { 2754, 4, 8, 5, 0, -8 }, // U+03AC char225 + { 2758, 4, 8, 5, 0, -8 }, // U+03AD GREEK SMALL LETTER EPSILON WITH TONOS + { 2762, 4, 9, 5, 0, -8 }, // U+03AE uni006E + { 2767, 3, 8, 4, 0, -8 }, // U+03AF GREEK SMALL LETTER IOTA WITH TONOS + { 2770, 6, 8, 7, 0, -8 }, // U+03B0 GREEK SMALL LETTER UPSILON WITH DIALYTIKA AND TONOS + { 2776, 4, 5, 5, 0, -5 }, // U+03B1 uni0061 + { 2779, 4, 7, 5, 0, -6 }, // U+03B2 char223 + { 2783, 4, 7, 5, 0, -5 }, // U+03B3 uni0076 + { 2787, 4, 7, 5, 0, -7 }, // U+03B4 GREEK SMALL LETTER DELTA + { 2791, 4, 5, 5, 0, -5 }, // U+03B5 GREEK SMALL LETTER EPSILON + { 2794, 4, 7, 5, 0, -6 }, // U+03B6 GREEK SMALL LETTER ZETA + { 2798, 4, 6, 5, 0, -5 }, // U+03B7 GREEK SMALL LETTER ETA + { 2801, 4, 5, 5, 0, -5 }, // U+03B8 GREEK SMALL LETTER THETA + { 2804, 2, 5, 3, 0, -5 }, // U+03B9 GREEK SMALL LETTER IOTA + { 2806, 4, 5, 5, 0, -5 }, // U+03BA uni006B + { 2809, 4, 7, 5, 0, -7 }, // U+03BB GREEK SMALL LETTER LAMDA + { 2813, 4, 6, 5, 0, -5 }, // U+03BC uni0075 + { 2816, 4, 5, 5, 0, -5 }, // U+03BD uni0076 + { 2819, 4, 8, 5, 0, -6 }, // U+03BE GREEK SMALL LETTER XI + { 2823, 4, 5, 5, 0, -5 }, // U+03BF GREEK SMALL LETTER OMICRON + { 2826, 5, 5, 6, 0, -5 }, // U+03C0 char960 + { 2830, 4, 8, 5, 0, -5 }, // U+03C1 uni0070 + { 2834, 4, 7, 5, 0, -5 }, // U+03C2 char231 + { 2838, 5, 5, 6, 0, -5 }, // U+03C3 char240 + { 2842, 4, 5, 5, 0, -5 }, // U+03C4 GREEK SMALL LETTER TAU + { 2845, 4, 5, 5, 0, -5 }, // U+03C5 uni0075 + { 2848, 5, 7, 6, 0, -5 }, // U+03C6 GREEK SMALL LETTER PHI + { 2853, 4, 5, 5, 0, -5 }, // U+03C7 uni0078 + { 2856, 5, 7, 6, 0, -5 }, // U+03C8 GREEK SMALL LETTER PSI + { 2861, 5, 5, 6, 0, -5 }, // U+03C9 GREEK SMALL LETTER OMEGA + { 2865, 3, 7, 4, 0, -7 }, // U+03CA GREEK SMALL LETTER IOTA WITH DIALYTIKA + { 2868, 5, 7, 6, 0, -7 }, // U+03CB char252 + { 2873, 4, 8, 5, 0, -8 }, // U+03CC char243 + { 2877, 5, 8, 6, 0, -8 }, // U+03CD char250 + { 2882, 5, 8, 6, 0, -8 }, // U+03CE GREEK SMALL LETTER OMEGA WITH TONOS + { 2887, 4, 7, 5, 0, -6 }, // U+03CF uni004B + { 2891, 4, 6, 5, 0, -6 }, // U+03D0 GREEK BETA SYMBOL + { 2894, 6, 6, 7, 0, -6 }, // U+03D1 GREEK THETA SYMBOL + { 2899, 6, 6, 7, 0, -6 }, // U+03D2 uni0059 + { 2904, 6, 7, 7, 0, -7 }, // U+03D3 uni0059 + { 2910, 6, 8, 7, 0, -8 }, // U+03D4 uni0059 + { 2916, 5, 9, 6, 0, -7 }, // U+03D5 GREEK PHI SYMBOL + { 2922, 5, 5, 6, 0, -5 }, // U+03D6 GREEK PI SYMBOL + { 2926, 5, 6, 6, 0, -5 }, // U+03D7 uni0078 + { 2930, 5, 8, 6, 0, -6 }, // U+03D8 GREEK LETTER ARCHAIC KOPPA + { 2935, 5, 7, 6, 0, -5 }, // U+03D9 GREEK SMALL LETTER ARCHAIC KOPPA + { 2940, 5, 8, 6, 0, -6 }, // U+03DA GREEK LETTER STIGMA + { 2945, 5, 7, 6, 0, -5 }, // U+03DB GREEK SMALL LETTER STIGMA + { 2950, 4, 6, 5, 0, -6 }, // U+03DC uni0046 + { 2953, 4, 6, 5, 0, -5 }, // U+03DD uni0046 + { 2956, 6, 6, 7, 0, -5 }, // U+03DE GREEK LETTER KOPPA + { 2961, 3, 7, 4, 0, -6 }, // U+03DF GREEK SMALL LETTER KOPPA + { 2964, 4, 7, 5, 0, -6 }, // U+03E0 GREEK LETTER SAMPI + { 2968, 4, 7, 5, 0, -6 }, // U+03E1 GREEK SMALL LETTER SAMPI + { 2972, 5, 7, 6, 0, -6 }, // U+03E2 COPTIC CAPITAL LETTER SHEI + { 2977, 5, 6, 6, 0, -4 }, // U+03E3 COPTIC SMALL LETTER SHEI + { 2981, 4, 7, 5, 0, -7 }, // U+03E4 COPTIC CAPITAL LETTER FEI + { 2985, 4, 6, 5, 0, -6 }, // U+03E5 COPTIC SMALL LETTER FEI + { 2988, 4, 9, 5, 0, -7 }, // U+03E6 COPTIC CAPITAL LETTER KHEI + { 2993, 5, 7, 6, 0, -6 }, // U+03E7 COPTIC SMALL LETTER KHEI + { 2998, 4, 6, 5, 0, -6 }, // U+03E8 uni0053 + { 3001, 4, 6, 5, 0, -6 }, // U+03E9 uni0053 + { 3004, 5, 7, 6, 0, -7 }, // U+03EA COPTIC CAPITAL LETTER GANGIA + { 3009, 5, 5, 6, 0, -5 }, // U+03EB COPTIC SMALL LETTER GANGIA + { 3013, 4, 7, 5, 0, -7 }, // U+03EC COPTIC CAPITAL LETTER SHIMA + { 3017, 4, 6, 5, 0, -6 }, // U+03ED COPTIC SMALL LETTER SHIMA + { 3020, 5, 6, 6, 0, -6 }, // U+03EE COPTIC CAPITAL LETTER DEI + { 3024, 5, 6, 6, 0, -6 }, // U+03EF COPTIC SMALL LETTER DEI + { 3028, 4, 5, 5, 0, -5 }, // U+03F0 uni0078 + { 3031, 4, 8, 5, 0, -5 }, // U+03F1 GREEK RHO SYMBOL + { 3035, 4, 5, 5, 0, -5 }, // U+03F2 uni0063 + { 3038, 3, 9, 4, 0, -7 }, // U+03F3 uni006A + { 3042, 4, 6, 5, 0, -6 }, // U+03F4 GREEK CAPITAL LETTER THETA + { 3045, 4, 5, 5, 0, -5 }, // U+03F5 GREEK LUNATE EPSILON SYMBOL + { 3048, 4, 5, 5, 0, -5 }, // U+03F6 GREEK REVERSED LUNATE EPSILON SYMBOL + { 3051, 4, 6, 5, 0, -6 }, // U+03F7 GREEK CAPITAL LETTER SHO + { 3054, 4, 7, 5, 0, -6 }, // U+03F8 GREEK SMALL LETTER SHO + { 3058, 4, 6, 5, 0, -6 }, // U+03F9 uni0043 + { 3061, 4, 6, 5, 0, -6 }, // U+03FA uni004D + { 3064, 4, 6, 5, 0, -4 }, // U+03FB GREEK SMALL LETTER SAN + { 3067, 4, 8, 5, 0, -5 }, // U+03FC uni0070 + { 3071, 5, 6, 6, 0, -6 }, // U+03FD uni0043 + { 3075, 4, 6, 5, 0, -6 }, // U+03FE uni0043 + { 3078, 4, 6, 5, 0, -6 }, // U+03FF uni0043 + { 3081, 4, 8, 5, 0, -8 }, // U+0400 char203 + { 3085, 4, 8, 5, 0, -8 }, // U+0401 char203 + { 3089, 5, 8, 6, 0, -7 }, // U+0402 char1026 + { 3094, 4, 8, 5, 0, -8 }, // U+0403 char1027 + { 3098, 4, 6, 5, 0, -6 }, // U+0404 char1028 + { 3101, 5, 6, 6, 0, -6 }, // U+0405 char1029 + { 3105, 3, 6, 4, 0, -6 }, // U+0406 char1030 + { 3108, 3, 7, 4, 0, -7 }, // U+0407 char1031 + { 3111, 5, 6, 6, 0, -6 }, // U+0408 char1032 + { 3115, 6, 6, 7, 0, -6 }, // U+0409 char1033 + { 3120, 5, 6, 6, 0, -6 }, // U+040A char1034 + { 3124, 5, 7, 6, 0, -7 }, // U+040B char1035 + { 3129, 5, 7, 6, 0, -7 }, // U+040C char1036 + { 3134, 4, 8, 5, 0, -8 }, // U+040D char1080 + { 3138, 4, 8, 5, 0, -8 }, // U+040E char1038 + { 3142, 5, 7, 6, 0, -6 }, // U+040F char1039 + { 3147, 4, 6, 5, 0, -6 }, // U+0410 char1040 + { 3150, 5, 7, 6, 0, -7 }, // U+0411 char1041 + { 3155, 4, 6, 5, 0, -6 }, // U+0412 char1042 + { 3158, 4, 6, 5, 0, -6 }, // U+0413 char1043 + { 3161, 5, 7, 6, 0, -6 }, // U+0414 char1044 + { 3166, 4, 6, 5, 0, -6 }, // U+0415 uni0045 + { 3169, 5, 6, 6, 0, -6 }, // U+0416 uni0058 + { 3173, 4, 6, 5, 0, -6 }, // U+0417 uni0033 + { 3176, 5, 6, 6, 0, -6 }, // U+0418 char1080 + { 3180, 4, 8, 5, 0, -8 }, // U+0419 char1080 + { 3184, 4, 6, 5, 0, -6 }, // U+041A uni004B + { 3187, 4, 6, 5, 0, -6 }, // U+041B char1051 + { 3190, 4, 6, 5, 0, -6 }, // U+041C uni004D + { 3193, 4, 6, 5, 0, -6 }, // U+041D uni0048 + { 3196, 4, 6, 5, 0, -6 }, // U+041E uni004F + { 3199, 5, 6, 6, 0, -6 }, // U+041F char1055 + { 3203, 5, 6, 6, 0, -6 }, // U+0420 char1104 + { 3207, 4, 6, 5, 0, -6 }, // U+0421 uni0043 + { 3210, 3, 6, 4, 0, -6 }, // U+0422 uni0054 + { 3213, 4, 6, 5, 0, -6 }, // U+0423 uni0079 + { 3216, 5, 8, 6, 0, -7 }, // U+0424 char1060 + { 3221, 4, 6, 5, 0, -6 }, // U+0425 uni0058 + { 3224, 4, 7, 5, 0, -6 }, // U+0426 char1062 + { 3228, 4, 6, 5, 0, -6 }, // U+0427 char1063 + { 3231, 5, 6, 6, 0, -6 }, // U+0428 char1064 + { 3235, 6, 7, 7, 0, -6 }, // U+0429 char1065 + { 3241, 4, 6, 5, 0, -6 }, // U+042A char1066 + { 3244, 5, 6, 6, 0, -6 }, // U+042B char1067 + { 3248, 5, 6, 6, 0, -6 }, // U+042C char1068 + { 3252, 4, 6, 5, 0, -6 }, // U+042D uni0033 + { 3255, 5, 6, 6, 0, -6 }, // U+042E char1070 + { 3259, 4, 6, 5, 0, -6 }, // U+042F char1071 + { 3262, 4, 5, 5, 0, -5 }, // U+0430 uni0061 + { 3265, 4, 7, 5, 0, -7 }, // U+0431 char1073 + { 3269, 4, 5, 5, 0, -5 }, // U+0432 char1074 + { 3272, 4, 5, 5, 0, -5 }, // U+0433 char1075 + { 3275, 5, 7, 6, 0, -6 }, // U+0434 char1044 + { 3280, 4, 5, 5, 0, -5 }, // U+0435 uni0065 + { 3283, 5, 5, 6, 0, -5 }, // U+0436 uni0058 + { 3287, 4, 5, 5, 0, -5 }, // U+0437 uni0033 + { 3290, 4, 5, 5, 0, -5 }, // U+0438 char1080 + { 3293, 4, 7, 5, 0, -7 }, // U+0439 char1080 + { 3297, 4, 5, 5, 0, -5 }, // U+043A uni006B + { 3300, 4, 5, 5, 0, -5 }, // U+043B char1051 + { 3303, 4, 5, 5, 0, -5 }, // U+043C uni006D + { 3306, 4, 5, 5, 0, -5 }, // U+043D uni006D + { 3309, 4, 5, 5, 0, -5 }, // U+043E uni006F + { 3312, 4, 5, 5, 0, -5 }, // U+043F uni006E + { 3315, 4, 7, 5, 0, -5 }, // U+0440 uni0070 + { 3319, 4, 5, 5, 0, -5 }, // U+0441 uni0063 + { 3322, 3, 5, 4, 0, -5 }, // U+0442 uni0074 + { 3324, 4, 6, 5, 0, -4 }, // U+0443 uni0079 + { 3327, 5, 7, 6, 0, -6 }, // U+0444 char1060 + { 3332, 4, 5, 5, 0, -5 }, // U+0445 uni0078 + { 3335, 4, 6, 5, 0, -5 }, // U+0446 char1062 + { 3338, 4, 5, 5, 0, -5 }, // U+0447 char1063 + { 3341, 5, 5, 6, 0, -5 }, // U+0448 char1064 + { 3345, 6, 6, 7, 0, -5 }, // U+0449 char1065 + { 3350, 4, 5, 5, 0, -5 }, // U+044A char1066 + { 3353, 4, 5, 5, 0, -5 }, // U+044B char1067 + { 3356, 4, 5, 5, 0, -5 }, // U+044C char1068 + { 3359, 4, 5, 5, 0, -5 }, // U+044D uni0033 + { 3362, 5, 5, 6, 0, -5 }, // U+044E char1070 + { 3366, 4, 5, 5, 0, -5 }, // U+044F char1071 + { 3369, 4, 8, 5, 0, -8 }, // U+0450 uni0065 + { 3373, 4, 7, 5, 0, -7 }, // U+0451 uni0065 + { 3377, 5, 7, 6, 0, -6 }, // U+0452 char1026 + { 3382, 4, 7, 5, 0, -7 }, // U+0453 char1027 + { 3386, 4, 5, 5, 0, -5 }, // U+0454 char1028 + { 3389, 4, 5, 5, 0, -5 }, // U+0455 uni0073 + { 3392, 2, 7, 3, 0, -7 }, // U+0456 uni0069 + { 3394, 3, 7, 4, 0, -7 }, // U+0457 uni0069 + { 3397, 3, 9, 4, 0, -7 }, // U+0458 uni006A + { 3401, 6, 5, 7, 0, -5 }, // U+0459 CYRILLIC SMALL LETTER LJE + { 3405, 5, 5, 6, 0, -5 }, // U+045A CYRILLIC SMALL LETTER NJE + { 3409, 5, 7, 6, 0, -7 }, // U+045B CYRILLIC SMALL LETTER TSHE + { 3414, 4, 8, 5, 0, -8 }, // U+045C CYRILLIC SMALL LETTER KJE + { 3418, 4, 8, 5, 0, -8 }, // U+045D CYRILLIC SMALL LETTER I WITH GRAVE + { 3422, 4, 10, 5, 0, -8 }, // U+045E uni0079 + { 3427, 5, 7, 6, 0, -5 }, // U+045F uni0079 + { 3432, 5, 7, 6, 0, -7 }, // U+0460 CYRILLIC CAPITAL LETTER OMEGA + { 3437, 5, 5, 6, 0, -5 }, // U+0461 CYRILLIC SMALL LETTER OMEGA + { 3441, 5, 7, 6, 0, -7 }, // U+0462 char1122 + { 3446, 5, 6, 6, 0, -6 }, // U+0463 char1123 + { 3450, 5, 7, 6, 0, -7 }, // U+0464 CYRILLIC CAPITAL LETTER IOTIFIED E + { 3455, 5, 5, 6, 0, -5 }, // U+0465 CYRILLIC SMALL LETTER IOTIFIED E + { 3459, 5, 6, 6, 0, -6 }, // U+0466 char1126 + { 3463, 5, 5, 6, 0, -5 }, // U+0467 char1127 + { 3467, 5, 6, 6, 0, -6 }, // U+0468 char1128 + { 3471, 5, 5, 6, 0, -5 }, // U+0469 char1129 + { 3475, 5, 7, 6, 0, -7 }, // U+046A CYRILLIC CAPITAL LETTER BIG YUS + { 3480, 5, 5, 6, 0, -5 }, // U+046B CYRILLIC SMALL LETTER BIG YUS + { 3484, 5, 7, 6, 0, -7 }, // U+046C CYRILLIC CAPITAL LETTER IOTIFIED BIG YUS + { 3489, 5, 5, 6, 0, -5 }, // U+046D CYRILLIC SMALL LETTER IOTIFIED BIG YUS + { 3493, 4, 11, 5, 0, -9 }, // U+046E CYRILLIC CAPITAL LETTER KSI + { 3499, 4, 10, 5, 0, -8 }, // U+046F CYRILLIC SMALL LETTER KSI + { 3504, 5, 7, 6, 0, -7 }, // U+0470 CYRILLIC CAPITAL LETTER PSI + { 3509, 5, 6, 6, 0, -6 }, // U+0471 CYRILLIC SMALL LETTER PSI + { 3513, 4, 7, 5, 0, -7 }, // U+0472 CYRILLIC CAPITAL LETTER FITA + { 3517, 4, 5, 5, 0, -5 }, // U+0473 CYRILLIC SMALL LETTER FITA + { 3520, 5, 6, 6, 0, -6 }, // U+0474 char1140 + { 3524, 5, 5, 6, 0, -5 }, // U+0475 char1141 + { 3528, 6, 8, 7, 0, -8 }, // U+0476 char1142 + { 3534, 5, 7, 6, 0, -7 }, // U+0477 char1143 + { 3539, 5, 9, 6, 0, -7 }, // U+0478 CYRILLIC CAPITAL LETTER UK + { 3545, 5, 7, 6, 0, -5 }, // U+0479 CYRILLIC SMALL LETTER UK + { 3550, 5, 9, 6, 0, -8 }, // U+047A CYRILLIC CAPITAL LETTER ROUND OMEGA + { 3556, 5, 7, 6, 0, -6 }, // U+047B CYRILLIC SMALL LETTER ROUND OMEGA + { 3561, 5, 9, 6, 0, -9 }, // U+047C CYRILLIC CAPITAL LETTER OMEGA WITH TITLO + { 3567, 5, 8, 6, 0, -8 }, // U+047D CYRILLIC SMALL LETTER OMEGA WITH TITLO + { 3572, 5, 9, 6, 0, -9 }, // U+047E CYRILLIC CAPITAL LETTER OT + { 3578, 5, 8, 6, 0, -8 }, // U+047F CYRILLIC SMALL LETTER OT + { 3583, 4, 8, 5, 0, -6 }, // U+0480 uni0043 + { 3587, 4, 7, 5, 0, -5 }, // U+0481 uni0063 + { 3591, 4, 8, 5, 0, -8 }, // U+0482 CYRILLIC THOUSANDS SIGN + { 3595, 4, 3, 5, 0, -7 }, // U+0483 char1155 + { 3597, 4, 8, 5, 0, -8 }, // U+0484 uni006F + { 3601, 4, 8, 5, 0, -8 }, // U+0485 uni006F + { 3605, 4, 8, 5, 0, -8 }, // U+0486 uni006F + { 3609, 4, 8, 5, 0, -8 }, // U+0487 uni006F + { 3613, 4, 8, 5, 0, -8 }, // U+0488 uni006F + { 3617, 7, 5, 8, 0, -5 }, // U+0489 char1161 + { 3622, 7, 5, 8, 0, -5 }, // U+048A char1161 + { 3627, 5, 9, 6, 0, -8 }, // U+048B char1080 + { 3633, 5, 8, 6, 0, -7 }, // U+048C char1080 + { 3638, 5, 6, 6, 0, -6 }, // U+048D char1068 + { 3642, 5, 6, 6, 0, -6 }, // U+048E uni0050 + { 3646, 5, 7, 6, 0, -5 }, // U+048F uni0070 + { 3651, 4, 7, 5, 0, -7 }, // U+0490 char1027 + { 3655, 4, 6, 5, 0, -6 }, // U+0491 char1075 + { 3658, 5, 6, 6, 0, -6 }, // U+0492 uni0046 + { 3662, 5, 5, 6, 0, -5 }, // U+0493 uni0046 + { 3666, 4, 7, 5, 0, -6 }, // U+0494 char1026 + { 3670, 4, 6, 5, 0, -5 }, // U+0495 char1026 + { 3673, 6, 7, 7, 0, -6 }, // U+0496 uni0058 + { 3679, 6, 6, 7, 0, -5 }, // U+0497 uni0058 + { 3684, 4, 8, 5, 0, -6 }, // U+0498 uni0033 + { 3688, 4, 7, 5, 0, -5 }, // U+0499 uni0033 + { 3692, 5, 7, 6, 0, -6 }, // U+049A uni004B + { 3697, 5, 6, 6, 0, -5 }, // U+049B uni006B + { 3701, 4, 6, 5, 0, -6 }, // U+049C uni004B + { 3704, 4, 5, 5, 0, -5 }, // U+049D uni006B + { 3707, 5, 6, 6, 0, -6 }, // U+049E uni004B + { 3711, 5, 5, 6, 0, -5 }, // U+049F uni006B + { 3715, 5, 6, 6, 0, -6 }, // U+04A0 uni004B + { 3719, 5, 5, 6, 0, -5 }, // U+04A1 uni006B + { 3723, 5, 7, 6, 0, -6 }, // U+04A2 uni0048 + { 3728, 5, 6, 6, 0, -5 }, // U+04A3 uni006D + { 3732, 5, 6, 6, 0, -6 }, // U+04A4 uni0048 + { 3736, 5, 5, 6, 0, -5 }, // U+04A5 uni006D + { 3740, 6, 8, 7, 0, -6 }, // U+04A6 char1055 + { 3746, 6, 7, 7, 0, -5 }, // U+04A7 uni006E + { 3752, 5, 7, 6, 0, -6 }, // U+04A8 char1060 + { 3757, 5, 6, 6, 0, -5 }, // U+04A9 char1060 + { 3761, 4, 8, 5, 0, -6 }, // U+04AA uni0043 + { 3765, 4, 7, 5, 0, -5 }, // U+04AB uni0063 + { 3769, 5, 7, 6, 0, -6 }, // U+04AC uni0054 + { 3774, 5, 6, 6, 0, -5 }, // U+04AD uni0054 + { 3778, 5, 6, 6, 0, -6 }, // U+04AE uni0059 + { 3782, 5, 6, 6, 0, -4 }, // U+04AF uni0059 + { 3786, 5, 6, 6, 0, -6 }, // U+04B0 uni0059 + { 3790, 5, 6, 6, 0, -4 }, // U+04B1 uni0059 + { 3794, 5, 7, 6, 0, -6 }, // U+04B2 uni0058 + { 3799, 5, 6, 6, 0, -5 }, // U+04B3 uni0078 + { 3803, 6, 7, 7, 0, -6 }, // U+04B4 uni0054 + { 3809, 6, 6, 7, 0, -5 }, // U+04B5 uni0054 + { 3814, 5, 7, 6, 0, -6 }, // U+04B6 char1063 + { 3819, 5, 6, 6, 0, -5 }, // U+04B7 char1063 + { 3823, 4, 6, 5, 0, -6 }, // U+04B8 char1063 + { 3826, 4, 5, 5, 0, -5 }, // U+04B9 char1063 + { 3829, 4, 7, 5, 0, -7 }, // U+04BA uni0068 + { 3833, 4, 7, 5, 0, -7 }, // U+04BB uni0068 + { 3837, 5, 6, 6, 0, -6 }, // U+04BC uni0065 + { 3841, 5, 5, 6, 0, -5 }, // U+04BD uni0065 + { 3845, 5, 7, 6, 0, -6 }, // U+04BE uni0065 + { 3850, 5, 6, 6, 0, -5 }, // U+04BF uni0065 + { 3854, 3, 6, 4, 0, -6 }, // U+04C0 char1030 + { 3857, 5, 8, 6, 0, -8 }, // U+04C1 uni0058 + { 3862, 5, 8, 6, 0, -8 }, // U+04C2 uni0058 + { 3867, 4, 7, 5, 0, -6 }, // U+04C3 uni004B + { 3871, 4, 6, 5, 0, -5 }, // U+04C4 uni006B + { 3874, 5, 7, 6, 0, -6 }, // U+04C5 char1051 + { 3879, 5, 6, 6, 0, -5 }, // U+04C6 char1051 + { 3883, 4, 7, 5, 0, -6 }, // U+04C7 uni0048 + { 3887, 4, 6, 5, 0, -5 }, // U+04C8 uni006D + { 3890, 5, 7, 6, 0, -6 }, // U+04C9 uni0048 + { 3895, 5, 6, 6, 0, -5 }, // U+04CA uni006D + { 3899, 4, 7, 5, 0, -6 }, // U+04CB char1063 + { 3903, 4, 6, 5, 0, -5 }, // U+04CC char1063 + { 3906, 5, 7, 6, 0, -6 }, // U+04CD uni004D + { 3911, 5, 6, 6, 0, -5 }, // U+04CE uni006D + { 3915, 3, 6, 4, 0, -6 }, // U+04CF char1030 + { 3918, 4, 8, 5, 0, -8 }, // U+04D0 char1040 + { 3922, 4, 8, 5, 0, -8 }, // U+04D1 uni0061 + { 3926, 4, 8, 5, 0, -8 }, // U+04D2 char1040 + { 3930, 4, 7, 5, 0, -7 }, // U+04D3 uni0061 + { 3934, 6, 6, 7, 0, -6 }, // U+04D4 char198 + { 3939, 5, 5, 6, 0, -5 }, // U+04D5 uni0061 + { 3943, 4, 8, 5, 0, -8 }, // U+04D6 uni0045 + { 3947, 4, 8, 5, 0, -8 }, // U+04D7 uni0065 + { 3951, 4, 6, 5, 0, -6 }, // U+04D8 uni0065 + { 3954, 4, 5, 5, 0, -5 }, // U+04D9 uni0065 + { 3957, 4, 8, 5, 0, -8 }, // U+04DA uni0065 + { 3961, 4, 7, 5, 0, -7 }, // U+04DB uni0065 + { 3965, 5, 8, 6, 0, -8 }, // U+04DC uni0058 + { 3970, 5, 7, 6, 0, -7 }, // U+04DD uni0058 + { 3975, 4, 8, 5, 0, -8 }, // U+04DE uni0033 + { 3979, 4, 7, 5, 0, -7 }, // U+04DF uni0033 + { 3983, 4, 8, 5, 0, -8 }, // U+04E0 uni0033 + { 3987, 4, 7, 5, 0, -7 }, // U+04E1 uni0033 + { 3991, 4, 8, 5, 0, -8 }, // U+04E2 char1080 + { 3995, 4, 7, 5, 0, -7 }, // U+04E3 char1080 + { 3999, 4, 8, 5, 0, -8 }, // U+04E4 char1080 + { 4003, 4, 7, 5, 0, -7 }, // U+04E5 char1080 + { 4007, 4, 8, 5, 0, -8 }, // U+04E6 uni004F + { 4011, 4, 7, 5, 0, -7 }, // U+04E7 uni006F + { 4015, 4, 6, 5, 0, -6 }, // U+04E8 uni004F + { 4018, 4, 5, 5, 0, -5 }, // U+04E9 uni006F + { 4021, 4, 8, 5, 0, -8 }, // U+04EA uni004F + { 4025, 4, 5, 5, 0, -5 }, // U+04EB uni006F + { 4028, 4, 8, 5, 0, -8 }, // U+04EC uni0033 + { 4032, 4, 7, 5, 0, -7 }, // U+04ED uni0033 + { 4036, 5, 8, 6, 0, -8 }, // U+04EE uni0059 + { 4041, 5, 8, 6, 0, -6 }, // U+04EF uni0059 + { 4046, 4, 8, 5, 0, -8 }, // U+04F0 uni0079 + { 4050, 4, 8, 5, 0, -6 }, // U+04F1 uni0079 + { 4054, 4, 8, 5, 0, -8 }, // U+04F2 uni0079 + { 4058, 4, 8, 5, 0, -6 }, // U+04F3 uni0079 + { 4062, 4, 8, 5, 0, -8 }, // U+04F4 char1063 + { 4066, 4, 7, 5, 0, -7 }, // U+04F5 char1063 + { 4070, 4, 7, 5, 0, -6 }, // U+04F6 char1043 + { 4074, 5, 6, 6, 0, -5 }, // U+04F7 char1075 + { 4078, 4, 8, 5, 0, -8 }, // U+04F8 char1067 + { 4082, 4, 7, 5, 0, -7 }, // U+04F9 char1067 + { 4086, 5, 8, 6, 0, -6 }, // U+04FA char1043 + { 4091, 5, 7, 6, 0, -5 }, // U+04FB char1075 + { 4096, 5, 7, 6, 0, -6 }, // U+04FC uni0058 + { 4101, 5, 6, 6, 0, -5 }, // U+04FD uni0078 + { 4105, 4, 6, 5, 0, -6 }, // U+04FE uni0058 + { 4108, 4, 5, 5, 0, -5 }, // U+04FF uni0078 +}; + +static const GFXfont Lemon PROGMEM = { + (uint8_t*)lemonBitmaps, + (GFXglyph*)lemonGlyphs, + 0x0020, 0x04FF, + 10 // yAdvance +}; diff --git a/src/helpers/ui/LemonIcons.h b/src/helpers/ui/LemonIcons.h new file mode 100644 index 00000000..949d3203 --- /dev/null +++ b/src/helpers/ui/LemonIcons.h @@ -0,0 +1,19 @@ +// Lemon font icon glyphs (Private Use Area), converted manually from lemon.bdf +// Add new icons by appending to the bitmap array, lemonIconCPs, and lemonIconGlyphs. +#pragma once +#include + +static const uint8_t lemonIconBitmaps[] PROGMEM = { + 0xEE, 0xBA, 0x2B, 0xA3, 0xB0, // U+E03B mute: 6×6, yoff=0 +}; + +static const uint32_t lemonIconCPs[] PROGMEM = { + 0xE03B, // mute +}; + +static const GFXglyph lemonIconGlyphs[] PROGMEM = { + // off w h adv xo yo + { 0, 6, 6, 5, 0, -6 }, // U+E03B mute +}; + +static const uint8_t lemonIconCount = sizeof(lemonIconCPs) / sizeof(lemonIconCPs[0]); diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 72c8c2a5..25913c20 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -1,6 +1,8 @@ #include "SH1106Display.h" #include #include "Adafruit_SH110X.h" +#include "LemonFont.h" +#include "LemonIcons.h" bool SH1106Display::i2c_probe(TwoWire &wire, uint8_t addr) { @@ -60,9 +62,98 @@ void SH1106Display::setCursor(int x, int y) display.setCursor(x, y); } +uint32_t SH1106Display::decodeUtf8(const uint8_t*& p) { + uint8_t c = *p++; + if (c < 0x80) return c; + if ((c & 0xE0) == 0xC0) { + uint32_t cp = c & 0x1F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + return cp; + } + if ((c & 0xF0) == 0xE0) { + uint32_t cp = c & 0x0F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + return cp; + } + if ((c & 0xF8) == 0xF0) { + uint32_t cp = c & 0x07; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + return cp; + } + while (*p && (*p & 0xC0) == 0x80) p++; + return 0xFFFD; +} + +int16_t SH1106Display::drawLemonChar(int16_t x, int16_t y, uint32_t cp) { + for (uint8_t i = 0; i < lemonIconCount; i++) { + if (pgm_read_dword(&lemonIconCPs[i]) == cp) { + const GFXglyph* g = &lemonIconGlyphs[i]; + uint8_t w = pgm_read_byte(&g->width), h = pgm_read_byte(&g->height); + int8_t xo = (int8_t)pgm_read_byte(&g->xOffset), yo = (int8_t)pgm_read_byte(&g->yOffset); + uint8_t xa = pgm_read_byte(&g->xAdvance); + uint16_t bo = pgm_read_word(&g->bitmapOffset); + uint8_t bits = 0, bit = 0; + for (uint8_t row = 0; row < h; row++) + for (uint8_t col = 0; col < w; col++) { + if (!bit) { bits = pgm_read_byte(&lemonIconBitmaps[bo++]); bit = 0x80; } + if (bits & bit) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + bit >>= 1; + } + return x + xa; + } + } + + if (cp < Lemon.first || cp > Lemon.last) { + if (cp >= 0x20) display.fillRect(x + 1, y - 1, 4, 6, _color); + return x + 6; + } + const GFXglyph* g = &lemonGlyphs[cp - Lemon.first]; + uint8_t w = pgm_read_byte(&g->width), h = pgm_read_byte(&g->height); + int8_t xo = (int8_t)pgm_read_byte(&g->xOffset), yo = (int8_t)pgm_read_byte(&g->yOffset); + uint8_t xa = pgm_read_byte(&g->xAdvance); + uint16_t bo = pgm_read_word(&g->bitmapOffset); + uint8_t bits = 0, bit = 0; + for (uint8_t row = 0; row < h; row++) + for (uint8_t col = 0; col < w; col++) { + if (!bit) { bits = pgm_read_byte(&lemonBitmaps[bo++]); bit = 0x80; } + if (bits & bit) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + bit >>= 1; + } + return x + xa; +} + +uint8_t SH1106Display::lemonXAdvance(uint32_t cp) { + if (cp < Lemon.first || cp > Lemon.last) return 6; + return pgm_read_byte(&lemonGlyphs[cp - Lemon.first].xAdvance); +} + +void SH1106Display::translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { + if (_use_lemon) { + size_t n = strlen(src); + if (n >= dest_size) n = dest_size - 1; + memcpy(dest, src, n); + dest[n] = '\0'; + } else { + DisplayDriver::translateUTF8ToBlocks(dest, src, dest_size); + } +} + void SH1106Display::print(const char *str) { - display.print(str); + if (!_use_lemon) { display.print(str); return; } + + int16_t cx = display.getCursorX(); + int16_t cy = display.getCursorY(); + const uint8_t* p = (const uint8_t*)str; + while (*p) { + uint32_t cp = decodeUtf8(p); + if (cp == '\n') { cy += Lemon.yAdvance; cx = 0; } + else { cx = drawLemonChar(cx, cy, cp); } + } + display.setCursor(cx, cy); } void SH1106Display::fillRect(int x, int y, int w, int h) @@ -82,6 +173,12 @@ void SH1106Display::drawXbm(int x, int y, const uint8_t *bits, int w, int h) uint16_t SH1106Display::getTextWidth(const char *str) { + if (_use_lemon) { + uint16_t width = 0; + const uint8_t* p = (const uint8_t*)str; + while (*p) width += lemonXAdvance(decodeUtf8(p)); + return width; + } int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 3855f3d6..5e92b0a8 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -21,11 +21,17 @@ class SH1106Display : public DisplayDriver uint8_t _color; uint8_t _contrast; uint8_t _precharge; + bool _use_lemon; bool i2c_probe(TwoWire &wire, uint8_t addr); + static uint32_t decodeUtf8(const uint8_t*& p); + int16_t drawLemonChar(int16_t x, int16_t y, uint32_t cp); + uint8_t lemonXAdvance(uint32_t cp); public: - SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { _isOn = false; _contrast = 255; _precharge = 0x1F; } + SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { + _isOn = false; _contrast = 255; _precharge = 0x1F; _use_lemon = false; + } bool begin(); bool isOn() override { return _isOn; } @@ -41,6 +47,10 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t *bits, int w, int h) override; uint16_t getTextWidth(const char *str) override; + int getCharWidth() const override { return _use_lemon ? 5 : 6; } + int getLineHeight() const override { return _use_lemon ? 9 : 8; } + void setLemonFont(bool enabled) override { _use_lemon = enabled; } + void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override; void setBrightness(uint8_t level) override; void endFrame() override; }; From 4161951d19897f0b304a7a40075e46f734e05a9d Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 09:36:27 +0200 Subject: [PATCH 161/183] fix: force immediate redraw after font switch in applyFont() Without _next_refresh = 0 the Settings screen would continue using its 2-second refresh interval after a font change, showing stale rendering for up to 2 seconds. Mirrors the pattern used by setBrightnessLevel(). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c2a59b27..ecf78738 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1561,6 +1561,7 @@ void UITask::applyBrightness() { void UITask::applyFont() { if (_display != NULL && _node_prefs != NULL) { _display->setLemonFont(_node_prefs->use_lemon_font != 0); + _next_refresh = 0; } } From ca5eb221e7bb3a4d87b226cf879d6bd7b39ee42c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 09:49:04 +0200 Subject: [PATCH 162/183] fix: persist use_lemon_font setting across reboots The font preference was not saved to or loaded from flash, so it would reset to default (Adafruit font) on every boot. Added to the versioned DataStore read/write chain in the same pattern as other recently added fields (auto_lock, dm_melody etc.). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index f8f274f4..afb9c748 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -287,6 +287,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); if (file.available()) { file.read((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); + if (file.available()) { + file.read((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + } } } } @@ -379,6 +382,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); file.write((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); + file.write((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); file.close(); } From 319428c7c678fa052b740ecb1406a04e7daa5de1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 09:50:36 +0200 Subject: [PATCH 163/183] chore: remove unused FS_START_Y constant from FullscreenMsgView startY is computed dynamically from header_h, making the constant redundant. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/FullscreenMsgView.h | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index d23aa996..bff73713 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -4,7 +4,6 @@ #include static const int FS_CHARS_MAX = 80; // max bytes per wrapped line -static const int FS_START_Y = 12; struct FullscreenMsgView { int scroll; From fa842b202ab2df16cbf27b762f9637d7ebcccaef Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 10:32:26 +0200 Subject: [PATCH 164/183] fix: scale Lemon font glyphs with setTextSize on lock screen clock Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/SH1106Display.cpp | 27 +++++++++++++++++++-------- src/helpers/ui/SH1106Display.h | 7 ++++--- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/helpers/ui/SH1106Display.cpp b/src/helpers/ui/SH1106Display.cpp index 25913c20..79455b04 100644 --- a/src/helpers/ui/SH1106Display.cpp +++ b/src/helpers/ui/SH1106Display.cpp @@ -43,11 +43,13 @@ void SH1106Display::startFrame(Color bkg) _color = SH110X_WHITE; display.setTextColor(_color); display.setTextSize(1); + _text_sz = 1; display.cp437(true); // Use full 256 char 'Code Page 437' font } void SH1106Display::setTextSize(int sz) { + _text_sz = sz; display.setTextSize(sz); } @@ -88,6 +90,7 @@ uint32_t SH1106Display::decodeUtf8(const uint8_t*& p) { } int16_t SH1106Display::drawLemonChar(int16_t x, int16_t y, uint32_t cp) { + int sz = _text_sz; for (uint8_t i = 0; i < lemonIconCount; i++) { if (pgm_read_dword(&lemonIconCPs[i]) == cp) { const GFXglyph* g = &lemonIconGlyphs[i]; @@ -99,16 +102,19 @@ int16_t SH1106Display::drawLemonChar(int16_t x, int16_t y, uint32_t cp) { for (uint8_t row = 0; row < h; row++) for (uint8_t col = 0; col < w; col++) { if (!bit) { bits = pgm_read_byte(&lemonIconBitmaps[bo++]); bit = 0x80; } - if (bits & bit) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + if (bits & bit) { + if (sz == 1) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + else display.fillRect(x + xo*sz + col*sz, y + 6*sz + yo*sz + row*sz, sz, sz, _color); + } bit >>= 1; } - return x + xa; + return x + xa * sz; } } if (cp < Lemon.first || cp > Lemon.last) { - if (cp >= 0x20) display.fillRect(x + 1, y - 1, 4, 6, _color); - return x + 6; + if (cp >= 0x20) display.fillRect(x + sz, y - sz, 4*sz, 6*sz, _color); + return x + 6 * sz; } const GFXglyph* g = &lemonGlyphs[cp - Lemon.first]; uint8_t w = pgm_read_byte(&g->width), h = pgm_read_byte(&g->height); @@ -119,15 +125,20 @@ int16_t SH1106Display::drawLemonChar(int16_t x, int16_t y, uint32_t cp) { for (uint8_t row = 0; row < h; row++) for (uint8_t col = 0; col < w; col++) { if (!bit) { bits = pgm_read_byte(&lemonBitmaps[bo++]); bit = 0x80; } - if (bits & bit) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + if (bits & bit) { + if (sz == 1) display.drawPixel(x + xo + col, y + 6 + yo + row, _color); + else display.fillRect(x + xo*sz + col*sz, y + 6*sz + yo*sz + row*sz, sz, sz, _color); + } bit >>= 1; } - return x + xa; + return x + xa * sz; } uint8_t SH1106Display::lemonXAdvance(uint32_t cp) { - if (cp < Lemon.first || cp > Lemon.last) return 6; - return pgm_read_byte(&lemonGlyphs[cp - Lemon.first].xAdvance); + uint8_t xa; + if (cp < Lemon.first || cp > Lemon.last) xa = 6; + else xa = pgm_read_byte(&lemonGlyphs[cp - Lemon.first].xAdvance); + return xa * _text_sz; } void SH1106Display::translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { diff --git a/src/helpers/ui/SH1106Display.h b/src/helpers/ui/SH1106Display.h index 5e92b0a8..53900804 100644 --- a/src/helpers/ui/SH1106Display.h +++ b/src/helpers/ui/SH1106Display.h @@ -22,6 +22,7 @@ class SH1106Display : public DisplayDriver uint8_t _contrast; uint8_t _precharge; bool _use_lemon; + int _text_sz; bool i2c_probe(TwoWire &wire, uint8_t addr); static uint32_t decodeUtf8(const uint8_t*& p); @@ -30,7 +31,7 @@ class SH1106Display : public DisplayDriver public: SH1106Display() : DisplayDriver(128, 64), display(128, 64, &Wire, PIN_OLED_RESET) { - _isOn = false; _contrast = 255; _precharge = 0x1F; _use_lemon = false; + _isOn = false; _contrast = 255; _precharge = 0x1F; _use_lemon = false; _text_sz = 1; } bool begin(); @@ -47,8 +48,8 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t *bits, int w, int h) override; uint16_t getTextWidth(const char *str) override; - int getCharWidth() const override { return _use_lemon ? 5 : 6; } - int getLineHeight() const override { return _use_lemon ? 9 : 8; } + int getCharWidth() const override { return (_use_lemon ? 5 : 6) * _text_sz; } + int getLineHeight() const override { return (_use_lemon ? 9 : 8) * _text_sz; } void setLemonFont(bool enabled) override { _use_lemon = enabled; } void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override; void setBrightness(uint8_t level) override; From 67bccd3dd414cf49e0c46da3b5f445fa3c36bad8 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 09:47:32 +0200 Subject: [PATCH 165/183] ci: merge font-switcher build into single wio-tracker-l1 firmware job Font-switcher is now part of wio-tracker-l1-improvements, so there is no longer a separate branch to check out. Drop the build-font-switcher job and its download step in release; a single build produces the combined dual-BLE/USB + font-switcher firmware. Co-Authored-By: Claude Sonnet 4.6 --- .../build-wio-tracker-l1-firmwares.yml | 35 ++----------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index c1fa554f..5fb9d633 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -14,7 +14,7 @@ env: jobs: - build-standard: + build: runs-on: ubuntu-latest steps: @@ -35,31 +35,8 @@ jobs: name: wio-tracker-l1-firmwares path: out - build-font-switcher: - runs-on: ubuntu-latest - steps: - - - name: Clone Repo (font-switcher branch) - uses: actions/checkout@v4 - with: - ref: font-switcher - - - name: Setup Build Environment - uses: ./.github/actions/setup-build-environment - - - name: Build Font Switcher Firmwares - env: - FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }}-font-switcher - run: /usr/bin/env bash build.sh build-wio-tracker-l1-firmwares - - - name: Upload Workflow Artifacts - uses: actions/upload-artifact@v4 - with: - name: wio-tracker-l1-font-switcher-firmwares - path: out - release: - needs: [build-standard, build-font-switcher] + needs: [build] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: @@ -67,18 +44,12 @@ jobs: - name: Extract Version from Git Tag run: echo "GIT_TAG_VERSION=v${GITHUB_REF_NAME#*-v}" >> $GITHUB_ENV - - name: Download Standard Firmwares + - name: Download Firmwares uses: actions/download-artifact@v4 with: name: wio-tracker-l1-firmwares path: out - - name: Download Font Switcher Firmwares - uses: actions/download-artifact@v4 - with: - name: wio-tracker-l1-font-switcher-firmwares - path: out - - name: Create Release uses: softprops/action-gh-release@v2 with: From cffe960f3f30230e6b9d660887b592384ee4b800 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 19:12:17 +0200 Subject: [PATCH 166/183] feat: rework GxEPDDisplay for rotation support and integrate e-ink build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove fixed 128×128 virtual space and scaling; use 1:1 pixel mapping - Display dimensions derived from panel native size + DISPLAY_ROTATION - setCursor compensates for GFX font baseline offset (ascender per font size) - Add getLineHeight() / getCharWidth() overrides for FreeSans fonts - New envs: landscape (250×122) and portrait (122×250), BLE and dual variants - Integrate all wio-tracker-l1-improvements features into e-ink build Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/GxEPDDisplay.cpp | 80 +++++++----------- src/helpers/ui/GxEPDDisplay.h | 49 ++++++++--- variants/wio-tracker-l1-eink/platformio.ini | 93 ++++++++++++--------- 3 files changed, 120 insertions(+), 102 deletions(-) diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ad47754b..c1bcfaa4 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -1,4 +1,3 @@ - #include "GxEPDDisplay.h" #ifdef EXP_PIN_BACKLIGHT @@ -7,13 +6,22 @@ #endif #ifndef DISPLAY_ROTATION - #define DISPLAY_ROTATION 3 + #define DISPLAY_ROTATION 0 #endif #ifdef ESP32 SPIClass SPI1 = SPIClass(FSPI); #endif +// GFX fonts use the baseline as the cursor origin. UI code assumes top-of-cell +// coordinates (same convention as the OLED driver). Add the font ascender so +// the two conventions match. +static int fontAscender(int sz) { + if (sz == 3) return 26; // FreeSans18pt7b + if (sz == 2) return 17; // FreeSansBold12pt7b + return 13; // FreeSans9pt7b (sz == 1) +} + bool GxEPDDisplay::begin() { display.epd2.selectSPI(SPI1, SPISettings(4000000, MSBFIRST, SPI_MODE0)); #ifdef ESP32 @@ -23,15 +31,14 @@ bool GxEPDDisplay::begin() { #endif display.init(115200, true, 2, false); display.setRotation(DISPLAY_ROTATION); - setTextSize(1); // Default to size 1 + setTextSize(1); display.setPartialWindow(0, 0, display.width(), display.height()); - display.fillScreen(GxEPD_WHITE); display.display(true); - #if DISP_BACKLIGHT +#if DISP_BACKLIGHT digitalWrite(DISP_BACKLIGHT, LOW); pinMode(DISP_BACKLIGHT, OUTPUT); - #endif +#endif _init = true; return true; } @@ -64,30 +71,24 @@ void GxEPDDisplay::clear() { void GxEPDDisplay::startFrame(Color bkg) { display.fillScreen(GxEPD_WHITE); display.setTextColor(_curr_color = GxEPD_BLACK); + _text_sz = 1; + display.setFont(&FreeSans9pt7b); display_crc.reset(); } void GxEPDDisplay::setTextSize(int sz) { + _text_sz = sz; display_crc.update(sz); - switch(sz) { - case 1: // Small - display.setFont(&FreeSans9pt7b); - break; - case 2: // Medium Bold - display.setFont(&FreeSansBold12pt7b); - break; - case 3: // Large - display.setFont(&FreeSans18pt7b); - break; - default: - display.setFont(&FreeSans9pt7b); - break; + switch (sz) { + case 2: display.setFont(&FreeSansBold12pt7b); break; + case 3: display.setFont(&FreeSans18pt7b); break; + default: display.setFont(&FreeSans9pt7b); break; } } void GxEPDDisplay::setColor(Color c) { - display_crc.update (c); - // colours need to be inverted for epaper displays + display_crc.update(c); + // e-ink: DARK background = white paper, LIGHT foreground = black ink if (c == DARK) { display.setTextColor(_curr_color = GxEPD_WHITE); } else { @@ -98,7 +99,9 @@ void GxEPDDisplay::setColor(Color c) { void GxEPDDisplay::setCursor(int x, int y) { display_crc.update(x); display_crc.update(y); - display.setCursor((x+offset_x)*scale_x, (y+offset_y)*scale_y); + // Offset y by the font ascender: callers pass top-of-cell y, GFX fonts + // expect baseline y. Without this, text would be clipped at the top. + display.setCursor(x, y + fontAscender(_text_sz)); } void GxEPDDisplay::print(const char* str) { @@ -111,7 +114,7 @@ void GxEPDDisplay::fillRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display.fillRect(x*scale_x, y*scale_y, w*scale_x, h*scale_y, _curr_color); + display.fillRect(x, y, w, h, _curr_color); } void GxEPDDisplay::drawRect(int x, int y, int w, int h) { @@ -119,7 +122,7 @@ void GxEPDDisplay::drawRect(int x, int y, int w, int h) { display_crc.update(y); display_crc.update(w); display_crc.update(h); - display.drawRect(x*scale_x, y*scale_y, w*scale_x, h*scale_y, _curr_color); + display.drawRect(x, y, w, h, _curr_color); } void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { @@ -128,36 +131,13 @@ void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { display_crc.update(w); display_crc.update(h); display_crc.update(bits, w * h / 8); - // Calculate the base position in display coordinates - uint16_t startX = x * scale_x; - uint16_t startY = y * scale_y; - - // Width in bytes for bitmap processing uint16_t widthInBytes = (w + 7) / 8; - - // Process the bitmap row by row for (uint16_t by = 0; by < h; by++) { - // Calculate the target y-coordinates for this logical row - int y1 = startY + (int)(by * scale_y); - int y2 = startY + (int)((by + 1) * scale_y); - int block_h = y2 - y1; - - // Scan across the row bit by bit for (uint16_t bx = 0; bx < w; bx++) { - // Calculate the target x-coordinates for this logical column - int x1 = startX + (int)(bx * scale_x); - int x2 = startX + (int)((bx + 1) * scale_x); - int block_w = x2 - x1; - - // Get the current bit uint16_t byteOffset = (by * widthInBytes) + (bx / 8); uint8_t bitMask = 0x80 >> (bx & 7); - bool bitSet = pgm_read_byte(bits + byteOffset) & bitMask; - - // If the bit is set, draw a block of pixels - if (bitSet) { - // Draw the block as a filled rectangle - display.fillRect(x1, y1, block_w, block_h, _curr_color); + if (pgm_read_byte(bits + byteOffset) & bitMask) { + display.drawPixel(x + bx, y + by, _curr_color); } } } @@ -167,7 +147,7 @@ uint16_t GxEPDDisplay::getTextWidth(const char* str) { int16_t x1, y1; uint16_t w, h; display.getTextBounds(str, 0, 0, &x1, &y1, &w, &h); - return ceil((w + 1) / scale_x); + return w; } void GxEPDDisplay::endFrame() { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 1a04cc24..b7f53e43 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -16,37 +16,62 @@ #include "DisplayDriver.h" -class GxEPDDisplay : public DisplayDriver { +#ifndef DISPLAY_ROTATION + #define DISPLAY_ROTATION 0 +#endif +// Panel native dimensions before rotation. Derived from the model class when +// EINK_DISPLAY_MODEL is set; override with EINK_PANEL_W/H for other panels. +#if defined(EINK_DISPLAY_MODEL) + #ifndef EINK_PANEL_W + #define EINK_PANEL_W EINK_DISPLAY_MODEL::WIDTH + #endif + #ifndef EINK_PANEL_H + #define EINK_PANEL_H EINK_DISPLAY_MODEL::HEIGHT + #endif +#else + #ifndef EINK_PANEL_W + #define EINK_PANEL_W 200 + #define EINK_PANEL_H 200 + #endif +#endif + +// Odd rotations (1, 3) swap width and height. +#define EINK_DISP_W ((DISPLAY_ROTATION & 1) ? EINK_PANEL_H : EINK_PANEL_W) +#define EINK_DISP_H ((DISPLAY_ROTATION & 1) ? EINK_PANEL_W : EINK_PANEL_H) + +class GxEPDDisplay : public DisplayDriver { #if defined(EINK_DISPLAY_MODEL) GxEPD2_BW display; - const float scale_x = EINK_SCALE_X; - const float scale_y = EINK_SCALE_Y; - const float offset_x = EINK_X_OFFSET; - const float offset_y = EINK_Y_OFFSET; #else GxEPD2_BW display; - const float scale_x = 1.5625f; - const float scale_y = 1.5625f; - const float offset_x = 0; - const float offset_y = 10; #endif bool _init = false; bool _isOn = false; uint16_t _curr_color; CRC32 display_crc; int last_display_crc_value = 0; + int _text_sz = 1; public: #if defined(EINK_DISPLAY_MODEL) - GxEPDDisplay() : DisplayDriver(128, 128), display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {} + GxEPDDisplay() : DisplayDriver(EINK_DISP_W, EINK_DISP_H), + display(EINK_DISPLAY_MODEL(PIN_DISPLAY_CS, PIN_DISPLAY_DC, PIN_DISPLAY_RST, PIN_DISPLAY_BUSY)) {} #else - GxEPDDisplay() : DisplayDriver(128, 128), display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {} + GxEPDDisplay() : DisplayDriver(EINK_DISP_W, EINK_DISP_H), + display(GxEPD2_150_BN(DISP_CS, DISP_DC, DISP_RST, DISP_BUSY)) {} #endif + // Line height and approx. char width for each font size: + // 1 = FreeSans9pt (lineH=16, charW≈9) + // 2 = FreeSansBold12pt (lineH=20, charW≈12) + // 3 = FreeSans18pt (lineH=28, charW≈17) + int getCharWidth() const override { return _text_sz == 3 ? 17 : _text_sz == 2 ? 12 : 9; } + int getLineHeight() const override { return _text_sz == 3 ? 28 : _text_sz == 2 ? 20 : 16; } + bool begin(); - bool isOn() override {return _isOn;}; + bool isOn() override { return _isOn; } void turnOn() override; void turnOff() override; void clear() override; diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 0afe327f..04a0df6e 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -1,12 +1,14 @@ [WioTrackerL1Eink] extends = nrf52_base board = seeed-wio-tracker-l1 -board_build.ldscript = boards/nrf52840_s140_v7.ld +board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld +board_upload.maximum_size = 708608 build_flags = ${nrf52_base.build_flags} ${sensor_base.build_flags} -I lib/nrf52/s140_nrf52_7.3.0_API/include -I lib/nrf52/s140_nrf52_7.3.0_API/include/nrf52 -I variants/wio-tracker-l1 + -I examples/companion_radio/ui-new -D WIO_TRACKER_L1 -D WIO_TRACKER_L1_EINK -D USE_SX1262 @@ -15,57 +17,68 @@ build_flags = ${nrf52_base.build_flags} -D LORA_TX_POWER=22 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 - -D PIN_OLED_RESET=-1 -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 - -D EINK_SCALE_X=1.953125f - -D EINK_SCALE_Y=1.28f - -D EINK_X_OFFSET=0 - -D EINK_Y_OFFSET=10 - -D DISPLAY_ROTATION=1 - -D DISABLE_DIAGNOSTIC_OUTPUT - -D AUTO_OFF_MILLIS=0 -D GPS_BAUD_RATE=9600 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL + -D DISABLE_DIAGNOSTIC_OUTPUT + -D AUTO_OFF_MILLIS=0 + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=256 + -D DISPLAY_CLASS=GxEPDDisplay + -D UI_HAS_JOYSTICK=1 + -D UI_HAS_JOYSTICK_UPDOWN=1 + -D PIN_BUZZER=12 + -D QSPIFLASH=1 + -D FIRMWARE_PLUS_BUILD=1 + -D UI_SENSORS_PAGE=1 + -D BLE_PIN_CODE=123456 build_src_filter = ${nrf52_base.build_src_filter} + +<../variants/wio-tracker-l1> + - + -lib_deps= ${nrf52_base.lib_deps} - ${sensor_base.lib_deps} - adafruit/Adafruit GFX Library @ ^1.12.1 - zinggjm/GxEPD2 @ 1.6.2 - bakercp/CRC32 @ ^2.0.0 - -[env:WioTrackerL1Eink_companion_radio_ble] -extends = WioTrackerL1Eink -board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld -board_upload.maximum_size = 708608 -build_flags = ${WioTrackerL1Eink.build_flags} - -I examples/companion_radio/ui-new - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 - -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 - -D OFFLINE_QUEUE_SIZE=256 - -D DISPLAY_CLASS=GxEPDDisplay - -D UI_HAS_JOYSTICK=1 - -D PIN_BUZZER=12 - -D QSPIFLASH=1 - ; -D MESH_PACKET_LOGGING=1 - ; -D MESH_DEBUG=1 - -D UI_RECENT_LIST_SIZE=6 - -D UI_SENSORS_PAGE=1 - -D DUAL_SERIAL -build_src_filter = ${WioTrackerL1Eink.build_src_filter} - + + + + + + + +<../examples/companion_radio/*.cpp> +<../examples/companion_radio/ui-new/*.cpp> -lib_deps = ${WioTrackerL1Eink.lib_deps} +lib_deps = ${nrf52_base.lib_deps} + ${sensor_base.lib_deps} + adafruit/Adafruit GFX Library @ ^1.12.1 adafruit/RTClib @ ^2.1.3 + zinggjm/GxEPD2 @ 1.6.2 + bakercp/CRC32 @ ^2.0.0 densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 -debug_tool=stlink + +; Landscape: 250×122 px (physical panel rotated 90°) +[env:WioTrackerL1Eink_companion_radio_ble_landscape] +extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D DISPLAY_ROTATION=1 +extra_scripts = post:create-uf2.py + +; Portrait: 122×250 px (physical panel in native orientation) +[env:WioTrackerL1Eink_companion_radio_ble_portrait] +extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D DISPLAY_ROTATION=0 +extra_scripts = post:create-uf2.py + +; Dual BLE+USB — landscape +[env:WioTrackerL1Eink_companion_radio_dual_landscape] +extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D DISPLAY_ROTATION=1 + -D DUAL_SERIAL=1 +extra_scripts = post:create-uf2.py + +; Dual BLE+USB — portrait +[env:WioTrackerL1Eink_companion_radio_dual_portrait] +extends = WioTrackerL1Eink +build_flags = ${WioTrackerL1Eink.build_flags} + -D DISPLAY_ROTATION=0 + -D DUAL_SERIAL=1 +extra_scripts = post:create-uf2.py From 6a3b8e31352b9f6d83a0d75784d26b180fc12e09 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 20 May 2026 21:53:40 +0200 Subject: [PATCH 167/183] feat: add runtime display rotation setting for e-ink builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Settings > Display > Rotation (0°/90°/180°/270°) that persists to NodePrefs and is applied on startup. Falls back to compile-time DISPLAY_ROTATION when the field is missing from an older prefs file. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 12 ++++++++++++ examples/companion_radio/NodePrefs.h | 3 ++- .../companion_radio/ui-new/SettingsScreen.h | 19 +++++++++++++++++++ examples/companion_radio/ui-new/UITask.cpp | 8 ++++++++ examples/companion_radio/ui-new/UITask.h | 1 + src/helpers/ui/DisplayDriver.h | 2 ++ src/helpers/ui/GxEPDDisplay.cpp | 6 ++++++ src/helpers/ui/GxEPDDisplay.h | 1 + 8 files changed, 51 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index afb9c748..e7bcab66 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -289,7 +289,18 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); if (file.available()) { file.read((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + if (file.available()) { + file.read((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation)); + } else { +#ifdef DISPLAY_ROTATION + _prefs.display_rotation = DISPLAY_ROTATION; +#endif + } } + } else { +#ifdef DISPLAY_ROTATION + _prefs.display_rotation = DISPLAY_ROTATION; +#endif } } } @@ -383,6 +394,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); file.write((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); file.write((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + file.write((uint8_t *)&_prefs.display_rotation, sizeof(_prefs.display_rotation)); file.close(); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4f5b878a..0e1ba678 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -78,5 +78,6 @@ struct NodePrefs { // persisted to file struct DmMelodyEntry { uint8_t prefix[4]; uint8_t slot; }; // slot: 0=global,1=melody1,2=melody2 static const int DM_MELODY_TABLE_MAX = 16; DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; - uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) + uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) + uint8_t display_rotation; // 0-3; only used on e-ink displays }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 5102c8b1..93f08fdd 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -15,6 +15,9 @@ class SettingsScreen : public UIScreen { CLOCK_SECONDS, CLOCK_FORMAT, FONT, +#if defined(EINK_DISPLAY_MODEL) + ROTATION, +#endif // Sound section SECTION_SOUND, BUZZER, @@ -321,6 +324,14 @@ class SettingsScreen : public UIScreen { display.print("Font"); display.setCursor(VAL_X, y); display.print((p && p->use_lemon_font) ? "Lemon" : "Default"); +#if defined(EINK_DISPLAY_MODEL) + } else if (item == ROTATION) { + display.print("Rotation"); + display.setCursor(VAL_X, y); + { static const char* ROT_LABELS[] = { "0deg", "90deg", "180deg", "270deg" }; + uint8_t r = p ? (p->display_rotation & 3) : 0; + display.print(ROT_LABELS[r]); } +#endif } else if (item == DM_FILTER) { display.print("DM"); display.setCursor(VAL_X, y); @@ -517,6 +528,14 @@ public: _dirty = true; return true; } +#if defined(EINK_DISPLAY_MODEL) + if (_selected == ROTATION && p && (left || right || enter)) { + p->display_rotation = (p->display_rotation + (left ? 3 : 1)) & 3; + _task->applyRotation(); + _dirty = true; + return true; + } +#endif if (_selected == DM_FILTER && p && (left || right || enter)) { p->dm_show_all = p->dm_show_all ? 0 : 1; _dirty = true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ecf78738..b081b577 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -817,6 +817,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no applyBrightness(); applyFont(); + applyRotation(); } void UITask::gotoSettingsScreen() { @@ -1565,6 +1566,13 @@ void UITask::applyFont() { } } +void UITask::applyRotation() { + if (_display != NULL && _node_prefs != NULL) { + _display->setDisplayRotation(_node_prefs->display_rotation); + _next_refresh = 0; + } +} + void UITask::setBrightnessLevel(uint8_t level) { if (_node_prefs == NULL) return; if (level > 4) level = 4; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 80b57c43..806f8401 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -159,6 +159,7 @@ public: void applyTxPower(); void applyGPSInterval(); void applyFont(); + void applyRotation(); uint32_t autoOffMillis() const { if (!_node_prefs || _node_prefs->auto_off_secs == 0) return 0; return (uint32_t)_node_prefs->auto_off_secs * 1000UL; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index ffda2a12..d272a9db 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -7,6 +7,7 @@ class DisplayDriver { int _w, _h; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } + void setDimensions(int w, int h) { _w = w; _h = h; } public: enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light @@ -196,5 +197,6 @@ public: } virtual void setBrightness(uint8_t level) { } // level 0-4 (min to max), no-op default + virtual void setDisplayRotation(uint8_t rot) { } // 0-3, no-op for fixed-orientation displays virtual void endFrame() = 0; }; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index c1bcfaa4..7c564aec 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -150,6 +150,12 @@ uint16_t GxEPDDisplay::getTextWidth(const char* str) { return w; } +void GxEPDDisplay::setDisplayRotation(uint8_t rot) { + display.setRotation(rot & 3); + setDimensions(display.width(), display.height()); + last_display_crc_value = -1; // force redraw on next endFrame +} + void GxEPDDisplay::endFrame() { uint32_t crc = display_crc.finalize(); if (crc != last_display_crc_value) { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index b7f53e43..a5e7931b 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -84,5 +84,6 @@ public: void drawRect(int x, int y, int w, int h) override; void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override; uint16_t getTextWidth(const char* str) override; + void setDisplayRotation(uint8_t rot) override; void endFrame() override; }; From 33ad70221e48c4fbe220f5853cad89f35de6d67e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 10:47:19 +0200 Subject: [PATCH 168/183] feat(eink): suppress clock seconds to prevent per-second panel refreshes E-ink panels take ~2 s for a full refresh and visible flicker on every update. Showing seconds caused the clock page to redraw every 1 s, triggering a real panel refresh each time the CRC changed. - Default clock_hide_seconds=1 for EINK_DISPLAY_MODEL builds so fresh installs don't flicker out of the box. - Remove the Seconds setting from SettingsScreen on e-ink so users can't accidentally re-enable it. - Clock page now returns 60 s refresh interval on e-ink (content changes at most once per minute); other home pages capped at 30 s (new messages still force immediate redraw via notify()). - Lock screen refresh interval raised from 1 s to 60 s on e-ink; button presses already reset _next_refresh=0, so unlock feedback is instant. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 3 +++ examples/companion_radio/ui-new/SettingsScreen.h | 6 ++++++ examples/companion_radio/ui-new/UITask.cpp | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4e43de86..f168bdce 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -952,6 +952,9 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.dm_show_all = 1; // show all contacts by default memset(_prefs.dm_notif, 0, sizeof(_prefs.dm_notif)); _prefs.auto_off_secs = 15; // 15 seconds auto-off by default +#ifdef EINK_DISPLAY_MODEL + _prefs.clock_hide_seconds = 1; // e-ink: seconds cause a panel refresh every second +#endif _prefs.tz_offset_hours = 0; // UTC by default _prefs.low_batt_mv = 3400; // auto-shutdown at 3.4V by default _prefs.batt_display_mode = 0; // icon by default diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 93f08fdd..2c18ddf3 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -12,7 +12,9 @@ class SettingsScreen : public UIScreen { AUTO_OFF, AUTO_LOCK, BATT_DISPLAY, +#if !defined(EINK_DISPLAY_MODEL) CLOCK_SECONDS, +#endif CLOCK_FORMAT, FONT, #if defined(EINK_DISPLAY_MODEL) @@ -312,10 +314,12 @@ class SettingsScreen : public UIScreen { display.setCursor(VAL_X, y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); +#if !defined(EINK_DISPLAY_MODEL) } else if (item == CLOCK_SECONDS) { display.print("Seconds"); display.setCursor(VAL_X, y); display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); +#endif } else if (item == CLOCK_FORMAT) { display.print("Format"); display.setCursor(VAL_X, y); @@ -512,11 +516,13 @@ public: if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } } +#if !defined(EINK_DISPLAY_MODEL) if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { p->clock_hide_seconds ^= 1; _dirty = true; return true; } +#endif if (_selected == CLOCK_FORMAT && p && (left || right || enter)) { p->clock_12h ^= 1; _dirty = true; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index b081b577..01b1620f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -692,11 +692,16 @@ public: } } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; +#ifdef EINK_DISPLAY_MODEL + if (_page == HomePage::CLOCK) return 60000; // no seconds on e-ink; content changes at most every minute + return 30000; // e-ink: limit base polling; new messages still force immediate refresh via notify() +#else if (_page == HomePage::CLOCK) { bool show_sec = !_node_prefs || !_node_prefs->clock_hide_seconds; return auto_adv ? 1000 : (show_sec ? 1000 : 60000); } return auto_adv ? 1000 : 5000; +#endif } bool handleInput(char c) override { @@ -1399,7 +1404,11 @@ void UITask::loop() { _display->setCursor(hx, hy); _display->print(hint); _display->endFrame(); +#ifdef EINK_DISPLAY_MODEL + _next_refresh = millis() + 60000; +#else _next_refresh = millis() + 1000; +#endif } else if (!_locked && millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); From 0b6aaff49b0bf7c427597c8e3b37c79251b5bd2a Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 17:04:12 +0200 Subject: [PATCH 169/183] refactor(eink): collapse landscape/portrait build targets into one per serial mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display rotation is now a runtime setting (Settings → Rotation), so separate build targets for landscape/portrait are redundant. Collapsed four envs into two: WioTrackerL1Eink_companion_radio_ble — BLE only WioTrackerL1Eink_companion_radio_dual — BLE + USB (DUAL_SERIAL) Default DISPLAY_ROTATION=1 (landscape, 250×122) moved to the shared base section; users adjust orientation via the in-app Settings screen. Co-Authored-By: Claude Sonnet 4.6 --- variants/wio-tracker-l1-eink/platformio.ini | 27 ++++----------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/variants/wio-tracker-l1-eink/platformio.ini b/variants/wio-tracker-l1-eink/platformio.ini index 04a0df6e..0bb891db 100644 --- a/variants/wio-tracker-l1-eink/platformio.ini +++ b/variants/wio-tracker-l1-eink/platformio.ini @@ -18,6 +18,7 @@ build_flags = ${nrf52_base.build_flags} -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 -D EINK_DISPLAY_MODEL=GxEPD2_213_B74 + -D DISPLAY_ROTATION=1 -D GPS_BAUD_RATE=9600 -D ENV_PIN_SDA=PIN_WIRE1_SDA -D ENV_PIN_SCL=PIN_WIRE1_SCL @@ -53,32 +54,14 @@ lib_deps = ${nrf52_base.lib_deps} densaugeo/base64 @ ~1.4.0 end2endzone/NonBlockingRTTTL@^1.3.0 -; Landscape: 250×122 px (physical panel rotated 90°) -[env:WioTrackerL1Eink_companion_radio_ble_landscape] +; BLE only +[env:WioTrackerL1Eink_companion_radio_ble] extends = WioTrackerL1Eink -build_flags = ${WioTrackerL1Eink.build_flags} - -D DISPLAY_ROTATION=1 extra_scripts = post:create-uf2.py -; Portrait: 122×250 px (physical panel in native orientation) -[env:WioTrackerL1Eink_companion_radio_ble_portrait] +; Dual BLE+USB +[env:WioTrackerL1Eink_companion_radio_dual] extends = WioTrackerL1Eink build_flags = ${WioTrackerL1Eink.build_flags} - -D DISPLAY_ROTATION=0 -extra_scripts = post:create-uf2.py - -; Dual BLE+USB — landscape -[env:WioTrackerL1Eink_companion_radio_dual_landscape] -extends = WioTrackerL1Eink -build_flags = ${WioTrackerL1Eink.build_flags} - -D DISPLAY_ROTATION=1 - -D DUAL_SERIAL=1 -extra_scripts = post:create-uf2.py - -; Dual BLE+USB — portrait -[env:WioTrackerL1Eink_companion_radio_dual_portrait] -extends = WioTrackerL1Eink -build_flags = ${WioTrackerL1Eink.build_flags} - -D DISPLAY_ROTATION=0 -D DUAL_SERIAL=1 extra_scripts = post:create-uf2.py From 4c37986737acc77d32252ae8391800597f8896fb Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 17:20:20 +0200 Subject: [PATCH 170/183] fix(eink): fix font layout and add Lemon font support to GxEPDDisplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout fix: GxEPDDisplay was using proportional GFX fonts (FreeSans9pt/12pt) for sizes 1 and 2, giving lineHeight=16/20 instead of the 8/16 the UI layout expects (designed for the OLED's bitmap font). Replaced with the GFX built-in 6×8 bitmap font (size 1) and its 2× scaled variant 12×16 (size 2). Size 3 keeps FreeSans18pt for large headings. fontAscender updated accordingly: built-in font cursor is top-left (offset=0), Lemon GFX font adds 8px, only FreeSans18pt still adds 26px. Lemon font: LemonFont.h already stores the font in GFXfont format, so GxEPDDisplay can use it directly via display.setFont(&Lemon). Added _use_lemon flag, setLemonFont() override, and size-1 font selection in setTextSize()/startFrame(). Adafruit GFX handles UTF-8 decoding for GFX fonts, so Unicode characters (Cyrillic etc.) render correctly. The FONT setting in SettingsScreen now works on e-ink the same as on OLED. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/GxEPDDisplay.cpp | 28 +++++++++++++++++++--------- src/helpers/ui/GxEPDDisplay.h | 10 ++++++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 7c564aec..664607f0 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -16,10 +16,10 @@ // GFX fonts use the baseline as the cursor origin. UI code assumes top-of-cell // coordinates (same convention as the OLED driver). Add the font ascender so // the two conventions match. -static int fontAscender(int sz) { - if (sz == 3) return 26; // FreeSans18pt7b - if (sz == 2) return 17; // FreeSansBold12pt7b - return 13; // FreeSans9pt7b (sz == 1) +static int fontAscender(int sz, bool use_lemon) { + if (sz == 3) return 26; // FreeSans18pt7b: proportional font, baseline origin + if (use_lemon) return 8; // Lemon GFX font: baseline origin, typical ascender 8px + return 0; // GFX built-in font: cursor is top-left of cell } bool GxEPDDisplay::begin() { @@ -72,7 +72,8 @@ void GxEPDDisplay::startFrame(Color bkg) { display.fillScreen(GxEPD_WHITE); display.setTextColor(_curr_color = GxEPD_BLACK); _text_sz = 1; - display.setFont(&FreeSans9pt7b); + display.setFont(_use_lemon ? &Lemon : NULL); + display.setTextSize(1); display_crc.reset(); } @@ -80,9 +81,18 @@ void GxEPDDisplay::setTextSize(int sz) { _text_sz = sz; display_crc.update(sz); switch (sz) { - case 2: display.setFont(&FreeSansBold12pt7b); break; - case 3: display.setFont(&FreeSans18pt7b); break; - default: display.setFont(&FreeSans9pt7b); break; + case 3: + display.setFont(&FreeSans18pt7b); + display.setTextSize(1); + break; + case 2: + display.setFont(NULL); + display.setTextSize(2); + break; + default: + display.setFont(_use_lemon ? &Lemon : NULL); + display.setTextSize(1); + break; } } @@ -101,7 +111,7 @@ void GxEPDDisplay::setCursor(int x, int y) { display_crc.update(y); // Offset y by the font ascender: callers pass top-of-cell y, GFX fonts // expect baseline y. Without this, text would be clipped at the top. - display.setCursor(x, y + fontAscender(_text_sz)); + display.setCursor(x, y + fontAscender(_text_sz, _use_lemon)); } void GxEPDDisplay::print(const char* str) { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index a5e7931b..278df610 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -15,6 +15,7 @@ #include #include "DisplayDriver.h" +#include "LemonFont.h" #ifndef DISPLAY_ROTATION #define DISPLAY_ROTATION 0 @@ -48,6 +49,7 @@ class GxEPDDisplay : public DisplayDriver { #endif bool _init = false; bool _isOn = false; + bool _use_lemon = false; uint16_t _curr_color; CRC32 display_crc; int last_display_crc_value = 0; @@ -66,8 +68,12 @@ public: // 1 = FreeSans9pt (lineH=16, charW≈9) // 2 = FreeSansBold12pt (lineH=20, charW≈12) // 3 = FreeSans18pt (lineH=28, charW≈17) - int getCharWidth() const override { return _text_sz == 3 ? 17 : _text_sz == 2 ? 12 : 9; } - int getLineHeight() const override { return _text_sz == 3 ? 28 : _text_sz == 2 ? 20 : 16; } + // Size 1: GFX built-in 6×8 (default) or Lemon 5×10 bitmap font — matches OLED metrics. + // Size 2: GFX built-in scaled 12×16 bitmap font. + // Size 3: FreeSans18pt proportional font for large headings. + int getCharWidth() const override { return _text_sz == 3 ? 17 : _text_sz == 2 ? 12 : (_use_lemon ? 5 : 6); } + int getLineHeight() const override { return _text_sz == 3 ? 28 : _text_sz == 2 ? 16 : (_use_lemon ? 10 : 8); } + void setLemonFont(bool enabled) override { _use_lemon = enabled; } bool begin(); From 18867a85c0caa744f77df9532ecb5f90afeafd57 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 17:26:03 +0200 Subject: [PATCH 171/183] feat(eink): scale size-1 font with display orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portrait mode (122px wide) matches the OLED's ~21 chars/line with a 1× font. Landscape mode (250px wide) was using the same small 6×8 font, leaving text physically tiny and the wide display underutilised. Size 1 now scales by orientation: 1× (6×8 / Lemon 5×10) in portrait, 2× (12×16 / Lemon 10×20) in landscape. Size 2 stays at 12×16 and size 3 at FreeSans18pt in both orientations — their layout Y-positions are hardcoded so they cannot be scaled without a full layout refactor. fontAscender() updated to accept a scale parameter; Lemon ascender becomes 8×scale (8px portrait, 16px landscape) to keep top-of-cell coordinate semantics consistent with the rest of the UI code. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/GxEPDDisplay.cpp | 20 +++++++++++++------- src/helpers/ui/GxEPDDisplay.h | 19 ++++++++++++++----- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 664607f0..01a50eb3 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -16,10 +16,10 @@ // GFX fonts use the baseline as the cursor origin. UI code assumes top-of-cell // coordinates (same convention as the OLED driver). Add the font ascender so // the two conventions match. -static int fontAscender(int sz, bool use_lemon) { - if (sz == 3) return 26; // FreeSans18pt7b: proportional font, baseline origin - if (use_lemon) return 8; // Lemon GFX font: baseline origin, typical ascender 8px - return 0; // GFX built-in font: cursor is top-left of cell +static int fontAscender(int sz, bool use_lemon, int scale) { + if (sz == 3) return 26; // FreeSans18pt7b: proportional, baseline origin + if (use_lemon) return 8 * scale; // Lemon GFX font: baseline origin, ascender 8px×scale + return 0; // GFX built-in font: cursor is top-left of cell } bool GxEPDDisplay::begin() { @@ -72,14 +72,19 @@ void GxEPDDisplay::startFrame(Color bkg) { display.fillScreen(GxEPD_WHITE); display.setTextColor(_curr_color = GxEPD_BLACK); _text_sz = 1; + int sc = (width() >= height()) ? 2 : 1; display.setFont(_use_lemon ? &Lemon : NULL); - display.setTextSize(1); + display.setTextSize(sc); display_crc.reset(); } void GxEPDDisplay::setTextSize(int sz) { _text_sz = sz; display_crc.update(sz); + // Size 1 scales with orientation: 1× in portrait (≈OLED width), 2× in landscape. + // Size 2 always uses 2× built-in (12×16) — fixed because layout Y-positions are hardcoded. + // Size 3 always uses FreeSans18pt for large headings. + int sc = (width() >= height()) ? 2 : 1; switch (sz) { case 3: display.setFont(&FreeSans18pt7b); @@ -91,7 +96,7 @@ void GxEPDDisplay::setTextSize(int sz) { break; default: display.setFont(_use_lemon ? &Lemon : NULL); - display.setTextSize(1); + display.setTextSize(sc); break; } } @@ -111,7 +116,8 @@ void GxEPDDisplay::setCursor(int x, int y) { display_crc.update(y); // Offset y by the font ascender: callers pass top-of-cell y, GFX fonts // expect baseline y. Without this, text would be clipped at the top. - display.setCursor(x, y + fontAscender(_text_sz, _use_lemon)); + int sc = (width() >= height()) ? 2 : 1; + display.setCursor(x, y + fontAscender(_text_sz, _use_lemon, sc)); } void GxEPDDisplay::print(const char* str) { diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index 278df610..c5fa53fc 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -68,11 +68,20 @@ public: // 1 = FreeSans9pt (lineH=16, charW≈9) // 2 = FreeSansBold12pt (lineH=20, charW≈12) // 3 = FreeSans18pt (lineH=28, charW≈17) - // Size 1: GFX built-in 6×8 (default) or Lemon 5×10 bitmap font — matches OLED metrics. - // Size 2: GFX built-in scaled 12×16 bitmap font. - // Size 3: FreeSans18pt proportional font for large headings. - int getCharWidth() const override { return _text_sz == 3 ? 17 : _text_sz == 2 ? 12 : (_use_lemon ? 5 : 6); } - int getLineHeight() const override { return _text_sz == 3 ? 28 : _text_sz == 2 ? 16 : (_use_lemon ? 10 : 8); } + // Size 1 scales with orientation (portrait 1×, landscape 2×); size 2 is always 12×16; + // size 3 is FreeSans18pt (~17×28). Landscape = width >= height. + int getCharWidth() const override { + int sc = (width() >= height()) ? 2 : 1; + if (_text_sz == 3) return 17; + if (_text_sz == 2) return 12; + return (_use_lemon ? 5 : 6) * sc; + } + int getLineHeight() const override { + int sc = (width() >= height()) ? 2 : 1; + if (_text_sz == 3) return 28; + if (_text_sz == 2) return 16; + return (_use_lemon ? 10 : 8) * sc; + } void setLemonFont(bool enabled) override { _use_lemon = enabled; } bool begin(); From 744f2639321a1dc7621914e94ca49a4ad41b682b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 18:05:09 +0200 Subject: [PATCH 172/183] refactor: replace all hardcoded pixel positions with layout helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add lineStep(), headerH(), listStart(), listVisible(), valCol() to DisplayDriver so layout derives from getLineHeight() instead of fixed OLED constants. Refactor all screens (SettingsScreen, NearbyScreen, FullscreenMsgView, QuickMsgScreen, BotScreen, DashboardConfigScreen, AutoAdvertScreen, RingtoneEditorScreen) and all HomeScreen pages plus the lock screen and splash screen in UITask.cpp. On OLED (lh=8) positions are unchanged; on landscape e-ink (lh=16) all text and widgets now scale correctly to the 250×122 display. Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/AutoAdvertScreen.h | 19 +- examples/companion_radio/ui-new/BotScreen.h | 23 +-- .../ui-new/DashboardConfigScreen.h | 15 +- .../ui-new/FullscreenMsgView.h | 15 +- .../companion_radio/ui-new/NearbyScreen.h | 117 ++++++------ .../companion_radio/ui-new/QuickMsgScreen.h | 171 +++++++++--------- .../ui-new/RingtoneEditorScreen.h | 61 ++++--- .../companion_radio/ui-new/SettingsScreen.h | 71 ++++---- examples/companion_radio/ui-new/UITask.cpp | 143 +++++++++------ src/helpers/ui/DisplayDriver.h | 10 + 10 files changed, 358 insertions(+), 287 deletions(-) diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index 114d0d02..4899f29d 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -25,22 +25,27 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT"); - display.fillRect(0, 10, display.width(), 1); + int label_y = display.listStart(); + int bar_y = label_y + display.lineStep(); + int bar_h = display.lineStep(); + int tip_y = bar_y + bar_h + display.lineStep(); - display.setCursor(2, 14); + display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT"); + display.fillRect(0, display.headerH() - 1, display.width(), 1); + + display.setCursor(2, label_y); display.print("Interval:"); int idx = currentIdx(); display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 24, display.width(), 12); + display.fillRect(0, bar_y, display.width(), bar_h); display.setColor(DisplayDriver::DARK); - display.drawTextCentered(display.width() / 2, 25, OPT_LABELS[idx]); + display.drawTextCentered(display.width() / 2, bar_y + 1, OPT_LABELS[idx]); display.setColor(DisplayDriver::LIGHT); - display.setCursor(2, 40); + display.setCursor(2, tip_y); display.print("< > to change"); - display.setCursor(2, 51); + display.setCursor(2, tip_y + display.lineStep()); display.print("[Esc] to save"); return 500; } diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index cf0b0ec8..027f7dfb 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -8,9 +8,6 @@ class BotScreen : public UIScreen { // Items: 0=Enable(DM), 1=Channel, 2=Trigger, 3=Reply DM, 4=Reply Ch static const int ITEM_COUNT = 5; - static const int ITEM_H = 11; - static const int START_Y = 12; - static const int VAL_X = 70; int _sel; bool _dirty; @@ -58,20 +55,24 @@ public: return _kb.render(display); } + int item_h = display.lineStep(); + int start_y = display.listStart(); + int val_x = display.valCol(); + display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply DM", "Reply Ch" }; for (int i = 0; i < ITEM_COUNT; i++) { - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; bool sel = (i == _sel); if (sel) { - display.fillRect(0, y - 1, display.width(), ITEM_H); + display.fillRect(0, y - 1, display.width(), item_h); display.setColor(DisplayDriver::DARK); } display.setCursor(2, y); display.print(labels[i]); - display.setCursor(VAL_X, y); + display.setCursor(val_x, y); if (i == 0) { display.print(_prefs->bot_enabled ? "ON" : "OFF"); @@ -81,19 +82,19 @@ public: } else { ChannelDetails ch; if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, ch.name); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, ch.name); else display.print("?"); } } else if (i == 2) { const char* tr = _prefs->bot_trigger; - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, tr[0] ? tr : "(none)"); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, tr[0] ? tr : "(none)"); } else if (i == 3) { const char* rp = _prefs->bot_reply_dm; - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)"); } else { const char* rp = _prefs->bot_reply_ch; - display.drawTextEllipsized(VAL_X, y, display.width() - VAL_X - 1, rp[0] ? rp : "(none)"); + display.drawTextEllipsized(val_x, y, display.width() - val_x - 1, rp[0] ? rp : "(none)"); } display.setColor(DisplayDriver::LIGHT); } diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index f587c6d5..b60011b3 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -21,9 +21,6 @@ class DashboardConfigScreen : public UIScreen { NodePrefs* _prefs; static const int FIELD_SLOTS = 3; - static const int ITEM_H = 13; - static const int START_Y = 13; - static const int VAL_X = 62; static const char* OPTION_NAMES[DASH_COUNT]; @@ -44,23 +41,27 @@ public: int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); + int item_h = display.lineStep(); + int start_y = display.listStart(); + int val_x = display.valCol(); + display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); static const char* labels[] = { "Field 1", "Field 2", "Field 3" }; for (int i = 0; i < FIELD_SLOTS; i++) { - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; bool sel = (i == _sel); if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H); + display.fillRect(0, y - 1, display.width(), item_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(2, y); display.print(labels[i]); - display.setCursor(VAL_X, y); + display.setCursor(val_x, y); uint8_t f = _prefs->dashboard_fields[i]; display.print(OPTION_NAMES[f < DASH_COUNT ? f : DASH_NONE]); display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index bff73713..f83b6359 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -82,7 +82,8 @@ struct FullscreenMsgView { } } - const int header_h = to_nick[0] ? 20 : 10; + const int cw = display.getCharWidth(); + const int header_h = to_nick[0] ? (lineH * 2 + 4) : (lineH + 2); const int startY = header_h + 2; const int visible = (display.height() - startY - lineH) / lineH; @@ -94,7 +95,7 @@ struct FullscreenMsgView { char trans_nick[32], to_label[36]; display.translateUTF8ToBlocks(trans_nick, to_nick, sizeof(trans_nick)); snprintf(to_label, sizeof(to_label), "To: %s", trans_nick); - display.drawTextEllipsized(2, 11, display.width() - 4, to_label); + display.drawTextEllipsized(2, lineH + 3, display.width() - 4, to_label); } display.setColor(DisplayDriver::LIGHT); @@ -111,16 +112,16 @@ struct FullscreenMsgView { } if (scroll > 0) { display.setColor(DisplayDriver::DARK); - display.fillRect(display.width() - 6, startY, 6, lineH); + display.fillRect(display.width() - cw, startY, cw, lineH); display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, startY); + display.setCursor(display.width() - cw, startY); display.print("^"); } if (scroll < max_scroll) { display.setColor(DisplayDriver::DARK); - display.fillRect(display.width() - 6, startY + (visible - 1) * lineH, 6, lineH); + display.fillRect(display.width() - cw, startY + (visible - 1) * lineH, cw, lineH); display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, startY + (visible - 1) * lineH); + display.setCursor(display.width() - cw, startY + (visible - 1) * lineH); display.print("v"); } const int nav_y = display.height() - lineH; @@ -129,7 +130,7 @@ struct FullscreenMsgView { display.print("<"); } if (has_prev) { - display.setCursor(display.width() - 6, nav_y); + display.setCursor(display.width() - cw, nav_y); display.print(">"); } return 2000; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 1f656d27..315f79cb 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -8,10 +8,7 @@ class NearbyScreen : public UIScreen { UITask* _task; - static const int VISIBLE = 4; - static const int ITEM_H = 12; - static const int START_Y = 12; - static const int DIST_COL = 86; + int _visible = 4; // updated each render; used by handleInput for scroll clamping static const int FILTER_COUNT = 6; static const char* FILTER_LABELS[FILTER_COUNT]; @@ -54,10 +51,7 @@ class NearbyScreen : public UIScreen { int _dsel; bool _ddetail; - static const int D_BOX_H = 19; - static const int D_ITEM_H = 21; - static const int D_VISIBLE = 2; - static const int D_START_Y = 11; + int _d_visible = 2; // updated each render; used by handleInputDiscover // ── helpers ────────────────────────────────────────────────────────────────── static void pubKeyToBase64(const uint8_t* key, char* out, int out_len) { @@ -193,6 +187,14 @@ class NearbyScreen : public UIScreen { } int renderDiscover(DisplayDriver& display) { + int lh = display.getLineHeight(); + int hdr = display.headerH(); + int d_box_h = 2 * lh + 3; // two text rows + padding + int d_item_h = d_box_h + 2; + int d_start_y = hdr; + _d_visible = display.listVisible(d_item_h); + if (_d_visible < 1) _d_visible = 1; + if (_ddetail) { // ── full-screen detail for selected node ────────────────────────────── const DiscoverResult& r = _dresults[_dsel]; @@ -207,29 +209,29 @@ class NearbyScreen : public UIScreen { char filtered[32]; display.translateUTF8ToBlocks(filtered, label, sizeof(filtered)); display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), 10); + display.fillRect(0, 0, display.width(), hdr - 1); display.setColor(DisplayDriver::DARK); display.drawTextEllipsized(2, 1, display.width() - 4, filtered); display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, hdr - 1, display.width(), 1); - // public key as base64, truncated with ... by drawTextEllipsized + // public key as base64 char b64[48]; pubKeyToBase64(r.pub_key, b64, sizeof(b64)); - display.drawTextEllipsized(2, 12, display.width() - 4, b64); + display.drawTextEllipsized(2, hdr, display.width() - 4, b64); + int step = lh + 1; char buf[32]; - snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); - display.setCursor(2, 21); display.print(buf); + display.setCursor(2, hdr + step); display.print(buf); snprintf(buf, sizeof(buf), "SNR: %d dB", (int)(r.snr_x4 / 4)); - display.setCursor(2, 30); display.print(buf); + display.setCursor(2, hdr + step * 2); display.print(buf); snprintf(buf, sizeof(buf), "Rem: %d dB", (int)(r.remote_snr_x4 / 4)); - display.setCursor(2, 39); display.print(buf); + display.setCursor(2, hdr + step * 3); display.print(buf); - display.setCursor(2, 48); + display.setCursor(2, hdr + step * 4); display.print(r.is_known ? "Status: known" : "Status: new"); return 5000; @@ -250,21 +252,22 @@ class NearbyScreen : public UIScreen { else snprintf(title, sizeof(title), "DISCOVER (%d found)", _dresult_count); display.drawTextCentered(display.width() / 2, 0, title); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, hdr - 1, display.width(), 1); if (_dresult_count == 0) { - display.drawTextCentered(display.width() / 2, 32, + display.drawTextCentered(display.width() / 2, display.height() / 2, _discovering ? "Waiting for replies..." : "No nodes found"); } else { if (_dsel >= _dresult_count) _dsel = _dresult_count - 1; - if (_dscroll > _dresult_count - D_VISIBLE) - _dscroll = _dresult_count > D_VISIBLE ? _dresult_count - D_VISIBLE : 0; + if (_dscroll > _dresult_count - _d_visible) + _dscroll = _dresult_count > _d_visible ? _dresult_count - _d_visible : 0; if (_dscroll < 0) _dscroll = 0; - for (int i = 0; i < D_VISIBLE && (_dscroll + i) < _dresult_count; i++) { + + for (int i = 0; i < _d_visible && (_dscroll + i) < _dresult_count; i++) { int idx = _dscroll + i; bool sel = (idx == _dsel); const DiscoverResult& r = _dresults[idx]; - int y = D_START_Y + i * D_ITEM_H; + int y = d_start_y + i * d_item_h; const char* typeStr = (r.type == ADV_TYPE_REPEATER) ? "Rpt" : (r.type == ADV_TYPE_SENSOR) ? "Snsr" : @@ -272,11 +275,11 @@ class NearbyScreen : public UIScreen { display.setColor(DisplayDriver::LIGHT); if (sel) { - display.fillRect(0, y, display.width(), D_BOX_H); // fully inverted when selected + display.fillRect(0, y, display.width(), d_box_h); display.setColor(DisplayDriver::DARK); } else { - display.drawRect(0, y, display.width(), D_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); + display.drawRect(0, y, display.width(), d_box_h); + display.fillRect(1, y + 1, display.width() - 2, lh); display.setColor(DisplayDriver::DARK); } @@ -300,14 +303,15 @@ class NearbyScreen : public UIScreen { display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); char sig[24]; snprintf(sig, sizeof(sig), "RSSI:%d SNR:%d", (int)r.rssi, (int)(r.snr_x4 / 4)); - display.drawTextEllipsized(3, y + 10, display.width() - 6, sig); + display.drawTextEllipsized(3, y + lh + 2, display.width() - 6, sig); } display.setColor(DisplayDriver::LIGHT); + int cw = display.getCharWidth(); if (_dscroll > 0) - { display.setCursor(display.width() - 6, D_START_Y + 1); display.print("^"); } - if (_dscroll + D_VISIBLE < _dresult_count) - { display.setCursor(display.width() - 6, D_START_Y + D_VISIBLE * D_ITEM_H - 10); display.print("v"); } + { display.setCursor(display.width() - cw, d_start_y); display.print("^"); } + if (_dscroll + _d_visible < _dresult_count) + { display.setCursor(display.width() - cw, d_start_y + (_d_visible - 1) * d_item_h); display.print("v"); } } return _discovering ? 200 : 2000; @@ -342,7 +346,7 @@ class NearbyScreen : public UIScreen { } if (c == KEY_DOWN && _dsel < _dresult_count - 1) { _dsel++; - if (_dsel >= _dscroll + D_VISIBLE) _dscroll = _dsel - D_VISIBLE + 1; + if (_dsel >= _dscroll + _d_visible) _dscroll = _dsel - _d_visible + 1; return true; } return true; @@ -390,9 +394,12 @@ public: // ── detail view ────────────────────────────────────────────────────────── if (_detail && _sel < _count) { const Entry& e = _entries[_sel]; + int lh = display.getLineHeight(); + int hdr = display.headerH(); + int step = lh + 1; display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, 0, display.width(), 10); + display.fillRect(0, 0, display.width(), hdr - 1); display.setColor(DisplayDriver::DARK); char filtered[32]; display.translateUTF8ToBlocks(filtered, e.name, sizeof(filtered)); @@ -401,55 +408,60 @@ public: char buf[32]; snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); - display.setCursor(2, 11); display.print(buf); + display.setCursor(2, hdr); display.print(buf); snprintf(buf, sizeof(buf), "Lon: %.5f", e.lon_e6 / 1e6); - display.setCursor(2, 20); display.print(buf); + display.setCursor(2, hdr + step); display.print(buf); if (e.dist_km >= 0.0f) { char dist[12]; fmtDist(dist, sizeof(dist), e.dist_km); int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); snprintf(buf, sizeof(buf), "Dist: %s", dist); - display.setCursor(2, 29); display.print(buf); + display.setCursor(2, hdr + step * 2); display.print(buf); snprintf(buf, sizeof(buf), "Az: %dd (%s)", az, bearingCardinal(az)); - display.setCursor(2, 38); display.print(buf); + display.setCursor(2, hdr + step * 3); display.print(buf); } else { - display.setCursor(2, 29); display.print("Dist: no own GPS"); - display.setCursor(2, 38); display.print("Az: unknown"); + display.setCursor(2, hdr + step * 2); display.print("Dist: no own GPS"); + display.setCursor(2, hdr + step * 3); display.print("Az: unknown"); } snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); - display.setCursor(2, 47); display.print(buf); + display.setCursor(2, hdr + step * 4); display.print(buf); char age[16]; fmtAge(age, sizeof(age), e.lastmod); snprintf(buf, sizeof(buf), "Seen: %s", age); - display.setCursor(2, 56); display.print(buf); + display.setCursor(2, hdr + step * 5); display.print(buf); return 2000; } // ── list view ──────────────────────────────────────────────────────────── + int item_h = display.lineStep(); + int start_y = display.listStart(); + int dist_col = display.width() - display.getCharWidth() * 7; + _visible = display.listVisible(item_h); + display.setColor(DisplayDriver::LIGHT); char title[22]; snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); display.drawTextCentered(display.width() / 2, 0, title); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); if (_count == 0) { - display.drawTextCentered(display.width() / 2, 28, "No contacts found"); - display.drawTextCentered(display.width() / 2, 40, "[Enter]=Discover"); + display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No contacts found"); + display.drawTextCentered(display.width() / 2, display.height() / 2 + display.lineStep() / 2, "[Enter]=Discover"); } else { - for (int i = 0; i < VISIBLE && (_scroll + i) < _count; i++) { + for (int i = 0; i < _visible && (_scroll + i) < _count; i++) { int idx = _scroll + i; bool sel = (idx == _sel); - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; const Entry& e = _entries[idx]; if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.fillRect(0, y - 1, display.width(), item_h - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); @@ -457,21 +469,22 @@ public: char filt[32]; display.translateUTF8ToBlocks(filt, e.name, sizeof(filt)); - display.drawTextEllipsized(2, y, DIST_COL - 4, filt); + display.drawTextEllipsized(2, y, dist_col - 4, filt); display.setColor(sel ? DisplayDriver::DARK : DisplayDriver::LIGHT); char dist[10]; if (e.dist_km >= 0.0f) fmtDist(dist, sizeof(dist), e.dist_km); else strncpy(dist, "?GPS", sizeof(dist)); - display.setCursor(DIST_COL, y); + display.setCursor(dist_col, y); display.print(dist); } display.setColor(DisplayDriver::LIGHT); + int cw = display.getCharWidth(); if (_scroll > 0) - { display.setCursor(display.width() - 6, START_Y); display.print("^"); } - if (_scroll + VISIBLE < _count) - { display.setCursor(display.width() - 6, START_Y + (VISIBLE - 1) * ITEM_H); display.print("v"); } + { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_scroll + _visible < _count) + { display.setCursor(display.width() - cw, start_y + (_visible - 1) * item_h); display.print("v"); } } if (_ctx_menu.active) { @@ -519,7 +532,7 @@ public: } if (c == KEY_DOWN && _sel < _count - 1) { _sel++; - if (_sel >= _scroll + VISIBLE) _scroll = _sel - VISIBLE + 1; + if (_sel >= _scroll + _visible) _scroll = _sel - _visible + 1; return true; } if (c == KEY_ENTER && _count == 0) { enterDiscoverMode(); return true; } diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 6febeba3..86c25d5d 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -61,13 +61,8 @@ class QuickMsgScreen : public UIScreen { int _dm_hist_sel, _dm_hist_scroll; FullscreenMsgView _dm_fs; - static const int VISIBLE = 4; - static const int ITEM_H = 12; - static const int START_Y = 12; - static const int HIST_VISIBLE = 2; // 2-line boxed history entries - static const int HIST_ITEM_H = 21; // box(19) + gap(2) - static const int HIST_BOX_H = 19; - static const int HIST_START_Y = 11; + int _visible = 4; // updated in render(); used by handleInput() for scroll clamping + int _hist_visible = 2; // updated in render(); for history list scroll clamping void expandMsg(const char* tmpl, char* out, int out_len) const { double lat = 0, lon = 0; @@ -128,17 +123,6 @@ class QuickMsgScreen : public UIScreen { } } - void renderScrollHints(DisplayDriver& display, int scroll, int count) { - display.setColor(DisplayDriver::LIGHT); - if (scroll > 0) { - display.setCursor(display.width() - 6, START_Y); - display.print("^"); - } - if (scroll + VISIBLE < count) { - display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); - display.print("v"); - } - } static void fmtMsgAge(char* buf, int n, uint32_t timestamp) { uint32_t now = rtc_clock.getCurrentTime(); @@ -476,9 +460,15 @@ public: display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); + int lh = display.getLineHeight(); + int item_h = display.lineStep(); + int start_y = display.listStart(); + int cw = display.getCharWidth(); + _visible = display.listVisible(item_h); + if (_phase == MODE_SELECT) { display.drawTextCentered(display.width()/2, 0, "MESSAGE"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); const char* opts[] = { "Direct message", "Channels", "Room Servers" }; int badges[3] = { getDMUnreadTotal(), @@ -486,18 +476,18 @@ public: _task->getRoomUnreadCount() }; for (int i = 0; i < 3; i++) { - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; bool sel = (i == _mode_sel); if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.fillRect(0, y - 1, display.width(), item_h - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); display.print(sel ? ">" : " "); - display.setCursor(8, y); + display.setCursor(cw + 2, y); display.print(opts[i]); if (badges[i] > 0) { char badge[5]; @@ -511,22 +501,22 @@ public: } else if (_phase == CONTACT_PICK) { display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); if (_num_contacts == 0) { - display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); + display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites"); return 5000; } - for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { + for (int i = 0; i < _visible && (_contact_scroll+i) < _num_contacts; i++) { int list_idx = _contact_scroll + i; int mesh_idx = _sorted[list_idx]; bool sel = (list_idx == _contact_sel); - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.fillRect(0, y - 1, display.width(), item_h - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); @@ -545,7 +535,7 @@ public: snprintf(badge, sizeof(badge), "%d", (int)dm_unread); bw = display.getTextWidth(badge) + 2; } - display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); + display.drawTextEllipsized(cw + 2, y, display.width() - cw - 2 - bw - 1, filtered); if (dm_unread > 0) { display.setCursor(display.width() - bw + 1, y); display.print(badge); @@ -553,28 +543,29 @@ public: } } display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _contact_scroll, _num_contacts); + if (_contact_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_contact_scroll + _visible < _num_contacts) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); } // Context menu overlay if (_ctx_menu.active) _ctx_menu.render(display); } else if (_phase == CHANNEL_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); if (_num_channels == 0) { - display.drawTextCentered(display.width()/2, 32, "No channels"); + display.drawTextCentered(display.width()/2, display.height()/2, "No channels"); return 5000; } - for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { + for (int i = 0; i < _visible && (_channel_scroll+i) < _num_channels; i++) { int list_idx = _channel_scroll + i; bool sel = (list_idx == _channel_sel); - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.fillRect(0, y - 1, display.width(), item_h - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); @@ -590,7 +581,7 @@ public: snprintf(badge, sizeof(badge), "%d", (int)unread); bw = display.getTextWidth(badge) + 2; } - display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); + display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4 - bw, ch.name); if (unread > 0) { display.setCursor(display.width() - bw, y); display.print(badge); @@ -598,7 +589,8 @@ public: } } display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _channel_scroll, _num_channels); + if (_channel_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_channel_scroll + _visible < _num_channels) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); } // Context menu overlay if (_ctx_menu.active) _ctx_menu.render(display); @@ -623,18 +615,25 @@ public: return 500; } + int hist_box_h = 2 * lh + 3; + int hist_item_h = hist_box_h + 2; + int hist_start_y = display.headerH(); + _hist_visible = (display.height() - hist_start_y) / hist_item_h; + if (_hist_visible < 1) _hist_visible = 1; + int cby = hist_start_y + _hist_visible * hist_item_h + 2; + char title[24]; display.setColor(DisplayDriver::LIGHT); snprintf(title, sizeof(title), "%.23s", filtered_name); display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 9, display.width(), 1); + display.fillRect(0, lh + 1, display.width(), 1); int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { + for (int i = 0; i < _hist_visible && (_dm_hist_scroll + i) < dm_count; i++) { int item = _dm_hist_scroll + i; bool sel = (item == _dm_hist_sel); - int y = HIST_START_Y + i * HIST_ITEM_H; + int y = hist_start_y + i * hist_item_h; int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); if (ring_pos < 0) continue; @@ -647,44 +646,44 @@ public: if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(0, y, display.width(), hist_box_h); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } - display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(e.text)); + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(e.text)); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), HIST_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); + display.drawRect(0, y, display.width(), hist_box_h); + display.fillRect(1, y + 1, display.width() - 2, lh); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(e.text)); + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(e.text)); } } if (dm_count == 0) { display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width()/2, 32, "No messages yet"); + display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet"); } display.setColor(DisplayDriver::LIGHT); if (_dm_hist_scroll > 0) { - display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.setCursor(display.width() - cw, hist_start_y + 1); display.print("^"); } - if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { - display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + if (_dm_hist_scroll + _hist_visible < dm_count) { + display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh); display.print("v"); } bool compose_sel = (_dm_hist_sel == -1); const char* ctxt = "[+ send]"; int ctw = display.getTextWidth(ctxt); - int cbx = 1, cby = 55; + int cbx = 1; if (compose_sel) { - display.fillRect(cbx, cby - 1, ctw + 4, 9); + display.fillRect(cbx, cby - 1, ctw + 4, lh + 1); display.setColor(DisplayDriver::DARK); } display.setCursor(cbx + 2, cby); @@ -718,19 +717,26 @@ public: return 2000; } + int hist_box_h = 2 * lh + 3; + int hist_item_h = hist_box_h + 2; + int hist_start_y = display.headerH(); + _hist_visible = (display.height() - hist_start_y) / hist_item_h; + if (_hist_visible < 1) _hist_visible = 1; + int cby = hist_start_y + _hist_visible * hist_item_h + 2; + ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); char title[24]; snprintf(title, sizeof(title), "%.23s", ch.name); display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 9, display.width(), 1); + display.fillRect(0, lh + 1, display.width(), 1); int ch_hist_count = histCountForChannel(_sel_channel_idx); - for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { + for (int i = 0; i < _hist_visible && (_hist_scroll + i) < ch_hist_count; i++) { int item = _hist_scroll + i; bool sel = (item == _hist_sel); - int y = HIST_START_Y + i * HIST_ITEM_H; + int y = hist_start_y + i * hist_item_h; int ring_pos = histEntryForChannel(_sel_channel_idx, item); if (ring_pos < 0) continue; @@ -754,36 +760,36 @@ public: if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(0, y, display.width(), hist_box_h); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } - display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(msg_part)); + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(msg_part)); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), HIST_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); + display.drawRect(0, y, display.width(), hist_box_h); + display.fillRect(1, y + 1, display.width() - 2, lh); display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6 - age_w, sender); + display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w, sender); if (age[0]) { display.setCursor(display.width() - 3 - display.getTextWidth(age), y + 1); display.print(age); } display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, skipReplyPrefix(msg_part)); + display.drawTextEllipsized(3, y + lh + 2, display.width() - cw - 2, skipReplyPrefix(msg_part)); } } if (ch_hist_count == 0) { display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width()/2, 32, "No messages yet"); + display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet"); } // scroll hints display.setColor(DisplayDriver::LIGHT); if (_hist_scroll > 0) { - display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.setCursor(display.width() - cw, hist_start_y + 1); display.print("^"); } - if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { - display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + if (_hist_scroll + _hist_visible < ch_hist_count) { + display.setCursor(display.width() - cw, hist_start_y + (_hist_visible - 1) * hist_item_h + hist_box_h - lh); display.print("v"); } @@ -791,14 +797,14 @@ public: bool compose_sel = (_hist_sel == -1); const char* ctxt = "[+ send]"; int ctw = display.getTextWidth(ctxt); - int cbx = 1, cby = 55; + int cbx = 1; if (compose_sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); + display.fillRect(cbx - 1, cby - 1, ctw + 4, lh + 2); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); + display.drawRect(cbx - 1, cby - 1, ctw + 4, lh + 2); } display.setCursor(cbx + 1, cby); display.print(ctxt); @@ -826,17 +832,17 @@ public: snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); } display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); int total_msg_items = 1 + _active_msg_count; - for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { + for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) { int idx = _msg_scroll + i; bool sel = (idx == _msg_sel); - int y = START_Y + i * ITEM_H; + int y = start_y + i * item_h; if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.fillRect(0, y - 1, display.width(), item_h - 1); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); @@ -845,17 +851,18 @@ public: display.print(sel ? ">" : " "); if (idx == 0) { - display.setCursor(8, y); + display.setCursor(cw + 2, y); display.print("Custom message..."); } else { NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[idx - 1]; const char* tmpl = p ? p->custom_msgs[slot] : ""; - display.drawTextEllipsized(8, y, display.width() - 10, tmpl); + display.drawTextEllipsized(cw + 2, y, display.width() - cw - 4, tmpl); } } display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _msg_scroll, total_msg_items); + if (_msg_scroll > 0) { display.setCursor(display.width() - cw, start_y); display.print("^"); } + if (_msg_scroll + _visible < total_msg_items) { display.setCursor(display.width() - cw, start_y + (_visible-1)*item_h); display.print("v"); } } return 2000; } @@ -911,7 +918,7 @@ public: } if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { _contact_sel++; - if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; + if (_contact_sel >= _contact_scroll + _visible) _contact_scroll = _contact_sel - _visible + 1; return true; } if (c == KEY_ENTER && _num_contacts > 0) { @@ -972,7 +979,7 @@ public: } if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { _channel_sel++; - if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; + if (_channel_sel >= _channel_scroll + _visible) _channel_scroll = _channel_sel - _visible + 1; return true; } if (c == KEY_ENTER && _num_channels > 0) { @@ -1055,8 +1062,8 @@ public: if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { _dm_hist_sel++; - if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) - _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; + if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible) + _dm_hist_scroll = _dm_hist_sel - _hist_visible + 1; } return true; } @@ -1129,7 +1136,7 @@ public: if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { _hist_sel++; - if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; + if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1; } updateChannelUnread(); return true; @@ -1183,7 +1190,7 @@ public: } if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { _msg_sel++; - if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; + if (_msg_sel >= _msg_scroll + _visible) _msg_scroll = _msg_sel - _visible + 1; return true; } if (c == KEY_ENTER) { diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 84cb09f4..7026d277 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -6,12 +6,11 @@ class RingtoneEditorScreen : public UIScreen { UITask* _task; NodePrefs* _prefs; - static const int MAX_NOTES = 32; - static const int VISIBLE_NOTES = 7; - static const int CELL_W = 18; - static const int NOTES_Y = 12; - static const int CELL_H = 14; - static const int MENU_VISIBLE = 5; + static const int MAX_NOTES = 32; + static const int CELL_W = 18; + static const int MENU_VISIBLE = 5; + + int _visible_notes = 7; // updated in render(); used by clampScroll() static const uint16_t BPM_OPTS[5]; static const uint8_t DUR_VALS[4]; @@ -43,8 +42,8 @@ class RingtoneEditorScreen : public UIScreen { } void clampScroll() { - if (_cursor < _scroll) _scroll = _cursor; - if (_cursor >= _scroll + VISIBLE_NOTES) _scroll = _cursor - VISIBLE_NOTES + 1; + if (_cursor < _scroll) _scroll = _cursor; + if (_cursor >= _scroll + _visible_notes) _scroll = _cursor - _visible_notes + 1; if (_scroll < 0) _scroll = 0; } @@ -93,13 +92,18 @@ public: display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); + const int lh = display.getLineHeight(); + const int notes_y = display.listStart(); + const int cell_h = lh + 6; + _visible_notes = display.width() / CELL_W; + char hdr[32]; snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES); display.setCursor(0, 0); display.print(hdr); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); - for (int i = 0; i < VISIBLE_NOTES; i++) { + for (int i = 0; i < _visible_notes; i++) { int ni = _scroll + i; int cx = i * CELL_W; bool sel = (ni == _cursor); @@ -116,50 +120,52 @@ public: } if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.fillRect(cx, notes_y, CELL_W - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.drawRect(cx, notes_y, CELL_W - 1, cell_h); } - display.setCursor(cx + 3, NOTES_Y + 3); + display.setCursor(cx + 3, notes_y + 3); display.print(label); display.setColor(DisplayDriver::LIGHT); } else if (ni == _len && _len < MAX_NOTES) { if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, NOTES_Y, CELL_W - 1, CELL_H); + display.fillRect(cx, notes_y, CELL_W - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } - display.setCursor(cx + 6, NOTES_Y + 3); + display.setCursor(cx + 6, notes_y + 3); display.print("+"); display.setColor(DisplayDriver::LIGHT); } } - int info_y = NOTES_Y + CELL_H + 2; + const int info_y = notes_y + cell_h + 2; + const int bottom_y = display.height() - display.lineStep(); if (_scroll > 0) { display.setCursor(0, info_y); display.print("<"); } - if (_scroll + VISIBLE_NOTES <= _len) { display.setCursor(display.width() - 6, info_y); display.print(">"); } + if (_scroll + _visible_notes <= _len) { display.setCursor(display.width() - display.getCharWidth(), info_y); display.print(">"); } if (_cursor < _len) { char info[24]; snprintf(info, sizeof(info), "oct:%d dur:%s", noteOctave(_notes[_cursor]), DUR_LABELS[noteDurIdx(_notes[_cursor])]); - display.setCursor(10, info_y); + display.setCursor(display.getCharWidth() + 2, info_y); display.print(info); } else if (_cursor == _len) { - display.setCursor(10, info_y); + display.setCursor(display.getCharWidth() + 2, info_y); display.print("U/D to add note"); } - display.setCursor(0, 54); + display.setCursor(0, bottom_y); display.print("ENT:oct MENU:opts"); if (_menu_open) { - static const int mw = 100, mh = MENU_VISIBLE * 10 + 4; - int mx = (128 - mw) / 2; - int my = (64 - mh) / 2; + const int menu_ih = display.lineStep(); + const int mw = 100, mh = MENU_VISIBLE * menu_ih + 4; + int mx = (display.width() - mw) / 2; + int my = (display.height() - mh) / 2; display.setColor(DisplayDriver::DARK); display.fillRect(mx, my, mw, mh); display.setColor(DisplayDriver::LIGHT); @@ -167,10 +173,10 @@ public: for (int i = 0; i < MENU_VISIBLE; i++) { int item = _menu_scroll + i; if (item >= M_COUNT) break; - int iy = my + 2 + i * 10; + int iy = my + 2 + i * menu_ih; bool sel = (item == _menu_sel); if (sel) { - display.fillRect(mx + 1, iy - 1, mw - 2, 10); + display.fillRect(mx + 1, iy - 1, mw - 2, menu_ih); display.setColor(DisplayDriver::DARK); } display.setCursor(mx + 4, iy); @@ -182,8 +188,9 @@ public: display.print(MENU_LABELS[item]); display.setColor(DisplayDriver::LIGHT); } - if (_menu_scroll > 0) { display.setCursor(mx + mw - 7, my + 2); display.print("^"); } - if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - 7, my + mh - 10); display.print("v"); } + int cw = display.getCharWidth(); + if (_menu_scroll > 0) { display.setCursor(mx + mw - cw - 1, my + 2); display.print("^"); } + if (_menu_scroll + MENU_VISIBLE < M_COUNT) { display.setCursor(mx + mw - cw - 1, my + mh - menu_ih); display.print("v"); } } return 200; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 2c18ddf3..d848d2a1 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -57,13 +57,9 @@ class SettingsScreen : public UIScreen { Count }; - static const int VISIBLE_ITEMS = 4; - static const int ITEM_H = 11; - static const int START_Y = 12; - static const int VAL_X = 80; - int _selected; int _scroll; + int _visible; // items fitting on screen; updated each render, used by handleInput bool _dirty; static const uint16_t AUTO_OFF_OPTS[5]; @@ -230,7 +226,7 @@ class SettingsScreen : public UIScreen { if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H); + display.fillRect(0, y - 1, display.width(), display.lineStep()); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); @@ -242,10 +238,10 @@ class SettingsScreen : public UIScreen { if (item == BRIGHTNESS) { display.print("Bright"); - renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); + renderBar(display, display.valCol(), y, (p ? p->display_brightness : 2) + 1, 5); } else if (item == BUZZER) { display.print("Buzzer"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); #ifdef PIN_BUZZER { static const char* labels[] = { "ON", "OFF", "Auto" }; int m = _task->getBuzzerMode(); @@ -256,45 +252,45 @@ class SettingsScreen : public UIScreen { } else if (item == BUZZER_VOLUME) { display.print("BzrVol"); #ifdef PIN_BUZZER - renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); + renderBar(display, display.valCol(), y, _task->getBuzzerVolume() + 1, 5); #else - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print("N/A"); #endif } else if (item == DM_MELODY) { display.print("DM sound"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); { static const char* L[] = { "built-in", "M1", "M2" }; uint8_t v = p ? p->notif_melody_dm : 0; display.print(L[v < 3 ? v : 0]); } } else if (item == CH_MELODY) { display.print("Ch sound"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); { static const char* L[] = { "built-in", "M1", "M2" }; uint8_t v = p ? p->notif_melody_ch : 0; display.print(L[v < 3 ? v : 0]); } } else if (isHomePage(item)) { display.print(homePageLabel(item)); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print(homePageVisible(item, p) ? "ON" : "OFF"); } else if (item == TX_POWER) { display.print("TX Pwr"); char buf[8]; sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print(buf); } else if (item == AUTO_OFF) { display.print("AutoOff"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print(AUTO_OFF_LABELS[autoOffIndex()]); } else if (item == AUTO_LOCK) { display.print("AutoLock"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print((p && p->auto_lock) ? "ON" : "OFF"); #if ENV_INCLUDE_GPS == 1 } else if (item == GPS_INTERVAL) { display.print("GPS upd"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); #endif } else if (item == TIMEZONE) { @@ -303,46 +299,46 @@ class SettingsScreen : public UIScreen { int8_t tz = p ? p->tz_offset_hours : 0; if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); else sprintf(buf, "UTC%d", (int)tz); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print(buf); } else if (item == LOW_BAT) { display.print("LowBat"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print(LOW_BAT_LABELS[lowBatIndex()]); } else if (item == BATT_DISPLAY) { display.print("BattDisp"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); uint8_t mode = p ? p->batt_display_mode : 0; display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); #if !defined(EINK_DISPLAY_MODEL) } else if (item == CLOCK_SECONDS) { display.print("Seconds"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); #endif } else if (item == CLOCK_FORMAT) { display.print("Format"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print((p && p->clock_12h) ? "12h" : "24h"); } else if (item == FONT) { display.print("Font"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print((p && p->use_lemon_font) ? "Lemon" : "Default"); #if defined(EINK_DISPLAY_MODEL) } else if (item == ROTATION) { display.print("Rotation"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); { static const char* ROT_LABELS[] = { "0deg", "90deg", "180deg", "270deg" }; uint8_t r = p ? (p->display_rotation & 3) : 0; display.print(ROT_LABELS[r]); } #endif } else if (item == DM_FILTER) { display.print("DM"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print((p && p->dm_show_all) ? "all" : "fav"); } else if (item == ROOM_FILTER) { display.print("Rooms"); - display.setCursor(VAL_X, y); + display.setCursor(display.valCol(), y); display.print((p && p->room_fav_only) ? "fav" : "all"); } else if (isMsgSlot(item)) { int slot = msgSlotIndex(item); @@ -350,7 +346,8 @@ class SettingsScreen : public UIScreen { snprintf(label, sizeof(label), "Q%d:", slot + 1); display.print(label); const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)"; - display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); + int xm = 8 + display.getCharWidth() * 4; + display.drawTextEllipsized(xm, y, display.width() - xm, tmpl); } } @@ -360,7 +357,7 @@ class SettingsScreen : public UIScreen { public: SettingsScreen(UITask* task) - : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} + : _task(task), _selected(BRIGHTNESS), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} void markClean() { _dirty = false; } @@ -372,23 +369,27 @@ public: return _kb.render(display); } + int item_h = display.lineStep(); + int start_y = display.listStart(); + _visible = display.listVisible(item_h); + display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 2, display.width(), 1); - for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { - renderItem(display, _scroll + i, START_Y + i * ITEM_H); + for (int i = 0; i < _visible && (_scroll + i) < SettingItem::Count; i++) { + renderItem(display, _scroll + i, start_y + i * item_h); } // scroll indicators if (_scroll > 0) { display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, START_Y); + display.setCursor(display.width() - display.getCharWidth(), start_y); display.print("^"); } - if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { + if (_scroll + _visible < SettingItem::Count) { display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); + display.setCursor(display.width() - display.getCharWidth(), start_y + (_visible - 1) * item_h); display.print("v"); } @@ -426,7 +427,7 @@ public: int next = nextSelectable(_selected); if (next != _selected) { _selected = next; - if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; + if (_selected >= _scroll + _visible) _scroll = _selected - _visible + 1; } return true; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 01b1620f..f72b4afd 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -65,27 +65,36 @@ public: } int render(DisplayDriver& display) override { + display.setTextSize(1); + const int lh = display.getLineHeight(); + const int step = display.lineStep(); + // meshcore logo display.setColor(DisplayDriver::LIGHT); int logoWidth = 128; - display.drawXbm((display.width() - logoWidth) / 2, 3, meshcore_logo, logoWidth, 13); + int logo_y = 3; + display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13); - // version info + // version info at sz2 + int ver_y = logo_y + 13 + 2; display.setTextSize(2); - display.drawTextCentered(display.width()/2, 22, _version_info); + display.drawTextCentered(display.width()/2, ver_y, _version_info); + // build date at sz1 — sz2 lh is always 16 + int date_y = ver_y + 16 + 2; display.setTextSize(1); - display.drawTextCentered(display.width()/2, 42, FIRMWARE_BUILD_DATE); + display.drawTextCentered(display.width()/2, date_y, FIRMWARE_BUILD_DATE); #ifdef FIRMWARE_PLUS_BUILD - display.fillRect(0, 53, display.width(), 10); + int plus_y = date_y + step; + display.fillRect(0, plus_y - 1, display.width(), lh + 2); display.setColor(DisplayDriver::DARK); char plus_label[24]; if (_plus_ver[0]) snprintf(plus_label, sizeof(plus_label), "Plus %s for Wio", _plus_ver); else snprintf(plus_label, sizeof(plus_label), "Plus for Wio"); - display.drawTextCentered(display.width()/2, 54, plus_label); + display.drawTextCentered(display.width()/2, plus_y, plus_label); display.setColor(DisplayDriver::LIGHT); #endif @@ -338,9 +347,14 @@ public: int render(DisplayDriver& display) override { char tmp[80]; + display.setTextSize(1); + const int lh = display.getLineHeight(); // line height at sz1 + const int step = display.lineStep(); // lh + 2 + const int dots_y = lh + 4; // page-dot row: just below header + const int content_y = dots_y + step; // first content row + // node name + battery — hidden on CLOCK page (full screen used for dashboard) if (_page != CLOCK) { - display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); char filtered_name[sizeof(_node_prefs->node_name)]; display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name)); @@ -360,13 +374,12 @@ public: if (i == _page) curr_vis = vis_count; vis_count++; } - int y = 14; int x = display.width() / 2 - 5 * (vis_count - 1); int vi = 0; for (int i = 0; i < (int)Count; i++) { if (!isPageVisible(i)) continue; - if (vi == curr_vis) display.fillRect(x-1, y-1, 3, 3); - else display.fillRect(x, y, 1, 1); + if (vi == curr_vis) display.fillRect(x-1, dots_y-1, 3, 3); + else display.fillRect(x, dots_y, 1, 1); x += 10; vi++; } } @@ -376,9 +389,10 @@ public: if (unix_ts < 1000000000UL) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 25, "! No time sync"); - display.drawTextCentered(display.width() / 2, 40, "Enable GPS or"); - display.drawTextCentered(display.width() / 2, 51, "connect app"); + int mid_y = display.height() / 2 - step; + display.drawTextCentered(display.width() / 2, mid_y, "! No time sync"); + display.drawTextCentered(display.width() / 2, mid_y + step, "Enable GPS or"); + display.drawTextCentered(display.width() / 2, mid_y + step * 2, "connect app"); } else { int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; unix_ts += (int32_t)tz * 3600; @@ -407,12 +421,14 @@ public: sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); display.drawTextCentered(display.width() / 2, 19, buf); - display.fillRect(0, 28, display.width(), 1); + int sep_y = 19 + lh + 1; + int dash0 = sep_y + 3; + display.fillRect(0, sep_y, display.width(), 1); // dashboard data fields if (_node_prefs) { refresh_sensors(); - static const int FIELD_Y[] = { 31, 41, 51 }; + const int FIELD_Y[3] = { dash0, dash0 + step, dash0 + step * 2 }; for (int fi = 0; fi < 3; fi++) { uint8_t field = _node_prefs->dashboard_fields[fi]; if (field == DASH_NONE) continue; @@ -491,8 +507,8 @@ public: } else if (_page == HomePage::RECENT) { the_mesh.getRecentlyHeard(recent, UI_RECENT_LIST_SIZE); display.setColor(DisplayDriver::LIGHT); - int y = 20; - for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += 11) { + int y = content_y; + for (int i = 0; i < UI_RECENT_LIST_SIZE; i++, y += step) { auto a = &recent[i]; if (a->name[0] == 0) continue; // empty slot int secs = _rtc->getCurrentTime() - a->recv_timestamp; @@ -515,44 +531,46 @@ public: } } else if (_page == HomePage::RADIO) { display.setColor(DisplayDriver::LIGHT); - display.setTextSize(1); // freq / sf - display.setCursor(0, 20); + display.setCursor(0, content_y); sprintf(tmp, "FQ: %06.3f SF: %d", _node_prefs->freq, _node_prefs->sf); display.print(tmp); - display.setCursor(0, 31); + display.setCursor(0, content_y + step); sprintf(tmp, "BW: %03.2f CR: %d", _node_prefs->bw, _node_prefs->cr); display.print(tmp); - // tx power, noise floor - display.setCursor(0, 42); + // tx power, noise floor + display.setCursor(0, content_y + step * 2); sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm); display.print(tmp); - display.setCursor(0, 53); + display.setCursor(0, content_y + step * 3); sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor()); display.print(tmp); } else if (_page == HomePage::BLUETOOTH) { display.setColor(DisplayDriver::LIGHT); - display.drawXbm((display.width() - 32) / 2, 14, + int icon_y = content_y; + display.drawXbm((display.width() - 32) / 2, icon_y, _task->isSerialEnabled() ? bluetooth_on : bluetooth_off, 32, 32); display.setTextSize(1); + int pin_y = icon_y + 32 + 3; + int hint_y = pin_y + step; if (_task->isSerialEnabled() && !_task->hasConnection() && the_mesh.getBLEPin() != 0) { char pin_buf[16]; snprintf(pin_buf, sizeof(pin_buf), "PIN: %d", the_mesh.getBLEPin()); - display.drawTextCentered(display.width() / 2, 49, pin_buf); + display.drawTextCentered(display.width() / 2, pin_y, pin_buf); } - display.drawTextCentered(display.width() / 2, 57, "toggle: " PRESS_LABEL); + display.drawTextCentered(display.width() / 2, hint_y, "toggle: " PRESS_LABEL); } else if (_page == HomePage::ADVERT) { display.setColor(DisplayDriver::LIGHT); - display.drawXbm((display.width() - 32) / 2, 18, advert_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "advert: " PRESS_LABEL); + display.drawXbm((display.width() - 32) / 2, content_y, advert_icon, 32, 32); + display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "advert: " PRESS_LABEL); #if ENV_INCLUDE_GPS == 1 } else if (_page == HomePage::GPS) { LocationProvider* nmea = sensors.getLocationProvider(); char buf[50]; - int y = 18; + int y = content_y; bool gps_state = _task->getGPSState(); #ifdef PIN_GPS_SWITCH bool hw_gps_state = digitalRead(PIN_GPS_SWITCH); @@ -566,30 +584,30 @@ public: #endif display.drawTextLeftAlign(0, y, buf); if (nmea == NULL) { - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "Can't access GPS"); } else { strcpy(buf, nmea->isValid()?"fix":"no fix"); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "sat"); sprintf(buf, "%d", nmea->satellitesCount()); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "pos"); - sprintf(buf, "%.4f %.4f", + sprintf(buf, "%.4f %.4f", nmea->getLatitude()/1000000., nmea->getLongitude()/1000000.); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; display.drawTextLeftAlign(0, y, "alt"); sprintf(buf, "%.2f", nmea->getAltitude()/1000.); display.drawTextRightAlign(display.width()-1, y, buf); - y = y + 12; + y += step; } #endif #if UI_SENSORS_PAGE == 1 } else if (_page == HomePage::SENSORS) { - int y = 18; + int y = content_y; refresh_sensors(); uint8_t avail_types[16]; @@ -655,7 +673,7 @@ public: display.print(name); display.setCursor(display.width() - display.getTextWidth(buf) - 1, y); display.print(buf); - y += 12; + y += step; } if (need_scroll) sensors_scroll_offset = (sensors_scroll_offset + 1) % avail_count; else sensors_scroll_offset = 0; @@ -663,32 +681,32 @@ public: } else if (_page == HomePage::SETTINGS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 22, "Settings"); - display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, content_y, "Settings"); + display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); } else if (_page == HomePage::TOOLS) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 22, "Tools"); - display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, content_y, "Tools"); + display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); } else if (_page == HomePage::QUICK_MSG) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); - display.drawTextCentered(display.width() / 2, 22, "Messages"); + display.drawTextCentered(display.width() / 2, content_y, "Messages"); int total_unread = _task->getDMUnreadTotal() + _task->getChannelUnreadCount() + _task->getRoomUnreadCount(); if (total_unread > 0) { char badge[20]; snprintf(badge, sizeof(badge), "%d unread", total_unread); - display.drawTextCentered(display.width() / 2, 35, badge); + display.drawTextCentered(display.width() / 2, content_y + step, badge); } - display.drawTextCentered(display.width() / 2, 50, PRESS_LABEL " to open"); + display.drawTextCentered(display.width() / 2, content_y + step * 2, PRESS_LABEL " to open"); } else if (_page == HomePage::SHUTDOWN) { display.setColor(DisplayDriver::LIGHT); display.setTextSize(1); if (_shutdown_init) { - display.drawTextCentered(display.width() / 2, 34, "hibernating..."); + display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); } else { - display.drawXbm((display.width() - 32) / 2, 18, power_icon, 32, 32); - display.drawTextCentered(display.width() / 2, 64 - 11, "hibernate:" PRESS_LABEL); + display.drawXbm((display.width() - 32) / 2, content_y, power_icon, 32, 32); + display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "hibernate:" PRESS_LABEL); } } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; @@ -1337,9 +1355,11 @@ void UITask::loop() { // Lock screen: clock + unlock hint popup uint32_t unix_ts = rtc_clock.getCurrentTime(); _display->setColor(DisplayDriver::LIGHT); + _display->setTextSize(1); + const int lk_lh = _display->getLineHeight(); + const int lk_step = _display->lineStep(); if (unix_ts < 1000000000UL) { - _display->setTextSize(1); - _display->drawTextCentered(_display->width() / 2, 20, "No time sync"); + _display->drawTextCentered(_display->width() / 2, _display->height() / 2 - lk_step, "No time sync"); } else { int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; unix_ts += (int32_t)tz * 3600; @@ -1347,18 +1367,21 @@ void UITask::loop() { struct tm* ti = gmtime(&t); char buf[12]; _display->setTextSize(2); + const int lh2 = _display->getLineHeight(); // sz2 line height + const int clk_y = 2; if (_node_prefs && _node_prefs->clock_12h) { int h = ti->tm_hour % 12; if (h == 0) h = 12; sprintf(buf, "%d:%02d %s", h, ti->tm_min, ti->tm_hour < 12 ? "AM" : "PM"); } else { sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); } - _display->drawTextCentered(_display->width() / 2, 8, buf); + _display->drawTextCentered(_display->width() / 2, clk_y, buf); _display->setTextSize(1); static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); - _display->drawTextCentered(_display->width() / 2, 26, buf); + int date_y = clk_y + lh2 + 2; + _display->drawTextCentered(_display->width() / 2, date_y, buf); // Two sensor values side by side (dashboard_fields[0] and [1]) if (_node_prefs) { @@ -1375,17 +1398,17 @@ void UITask::loop() { formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr); formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr); if (v0[0] || v1[0]) { - _display->setTextSize(1); + int sv_y = date_y + lk_step; _display->setColor(DisplayDriver::LIGHT); if (v0[0] && v1[0]) { - _display->setCursor(0, 37); + _display->setCursor(0, sv_y); _display->print(v0); int vw = _display->getTextWidth(v1); - _display->setCursor(_display->width() - vw, 37); + _display->setCursor(_display->width() - vw, sv_y); _display->print(v1); } else { const char* sv = v0[0] ? v0 : v1; - _display->drawTextCentered(_display->width() / 2, 37, sv); + _display->drawTextCentered(_display->width() / 2, sv_y, sv); } } } @@ -1395,11 +1418,11 @@ void UITask::loop() { const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" : _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more..."; int p = 3; - int hy = _display->height() - 13; + int hy = _display->height() - lk_lh - p * 2; int hw = _display->getTextWidth(hint); int hx = (_display->width() - hw) / 2; _display->setColor(DisplayDriver::LIGHT); - _display->fillRect(hx - p, hy - p, hw + p*2, 8 + p*2); + _display->fillRect(hx - p, hy - p, hw + p*2, lk_lh + p*2); _display->setColor(DisplayDriver::DARK); _display->setCursor(hx, hy); _display->print(hint); @@ -1457,8 +1480,10 @@ void UITask::loop() { _display->startFrame(); _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); - _display->drawTextCentered(_display->width() / 2, 24, "Low Battery"); - _display->drawTextCentered(_display->width() / 2, 36, "Shutting down"); + int mid = _display->height() / 2; + int step = _display->lineStep(); + _display->drawTextCentered(_display->width() / 2, mid - step, "Low Battery"); + _display->drawTextCentered(_display->width() / 2, mid, "Shutting down"); _display->endFrame(); delay(2000); } diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index d272a9db..c75baed9 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -31,6 +31,16 @@ public: virtual int getCharWidth() const { return 6; } // typical character advance width (px) virtual int getLineHeight() const { return 8; } // pixel rows per text line virtual void setLemonFont(bool) { } // no-op; overridden by displays that support Lemon + + // Layout helpers — derived from font metrics and screen size. + // Use these instead of hardcoded pixel values so layouts adapt to any display. + int lineStep() const { return getLineHeight() + 2; } // row pitch: text + gap + int headerH() const { return getLineHeight() + 3; } // title bar height + int listStart() const { return headerH(); } // y where list items begin + int listVisible(int itemH) const { return (height() - listStart()) / itemH; } + int listVisible() const { return listVisible(lineStep()); } + // x where a right-side value column starts (leaves ~8 chars for the value) + int valCol() const { return width() - getCharWidth() * 8; } virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise) int w = getTextWidth(str); setCursor(mid_x - w/2, y); From f8fb1dac99a631b263cd505cba93616002f20928 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 18:13:27 +0200 Subject: [PATCH 173/183] fix: align SettingsScreen item label to dynamic char width Replace hardcoded x=8 with getCharWidth()+2 so the label doesn't overlap the '>' selector on displays with larger fonts (e-ink sz1 scale=2). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/SettingsScreen.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index d848d2a1..17f96473 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -234,7 +234,7 @@ class SettingsScreen : public UIScreen { display.setCursor(0, y); display.print(sel ? ">" : " "); - display.setCursor(8, y); + display.setCursor(display.getCharWidth() + 2, y); if (item == BRIGHTNESS) { display.print("Bright"); @@ -328,7 +328,7 @@ class SettingsScreen : public UIScreen { } else if (item == ROTATION) { display.print("Rotation"); display.setCursor(display.valCol(), y); - { static const char* ROT_LABELS[] = { "0deg", "90deg", "180deg", "270deg" }; + { static const char* ROT_LABELS[] = { "0 deg", "90 deg", "180 deg", "270 deg" }; uint8_t r = p ? (p->display_rotation & 3) : 0; display.print(ROT_LABELS[r]); } #endif From 1d8ca2546978999c8f8fe535cb09488f11198400 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 18:34:20 +0200 Subject: [PATCH 174/183] fix: adaptive layout for e-ink landscape across all remaining screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GxEPDDisplay: sz2 uses 4× scale on landscape (clock 32px vs body 16px) - UITask: clock date_y and splash date_y computed from lh2 after setTextSize(2) - UITask: battery/mute/BT/advert indicators scale with lh; muted uses text 'M' - UITask: hibernate hint split into two lines to prevent wrapping - ToolsScreen: full refactor — headerH, listStart, lineStep, getCharWidth - KeyboardWidget: all cell sizes computed from display metrics (cell_w, cell_h, spec_w) - RingtoneEditorScreen: cell_w = charWidth×2+6 (was hardcoded 18) - SettingsScreen: BRIGHTNESS hidden on e-ink; renderBar boxes scale with lh Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/KeyboardWidget.h | 42 +++++++------ .../ui-new/RingtoneEditorScreen.h | 17 ++--- .../companion_radio/ui-new/SettingsScreen.h | 15 ++++- examples/companion_radio/ui-new/ToolsScreen.h | 12 ++-- examples/companion_radio/ui-new/UITask.cpp | 63 +++++++++++-------- src/helpers/ui/GxEPDDisplay.cpp | 2 +- src/helpers/ui/GxEPDDisplay.h | 4 +- 7 files changed, 93 insertions(+), 62 deletions(-) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 8ea7334b..8cfbfd1d 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -15,12 +15,6 @@ static const int KB_ROWS_CHAR = 4; static const int KB_COLS_CHAR = 10; static const int KB_SPECIAL = 5; // [^] [Sp] [De] [{}] [OK] static const int KB_MAX_LEN = 139; -static const int KB_CELL_W = 12; // 10 cols × 12px = 120px of 128px display -static const int KB_CELL_H = 9; -static const int KB_TEXT_Y = 0; -static const int KB_SEP_Y = 9; -static const int KB_CHARS_Y = 11; -static const int KB_SPECIAL_Y = 48; static const int KB_PH_MAX = 12; // max placeholders in list static const int KB_PH_LEN = 9; // max placeholder string length incl. null @@ -64,33 +58,44 @@ struct KeyboardWidget { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - // text preview: last 20 chars + cursor + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int cell_w = display.width() / KB_COLS_CHAR; + const int cell_h = lh + 1; + const int sep_y = lh + 1; + const int chars_y = sep_y + 2; + const int spec_y = chars_y + KB_ROWS_CHAR * cell_h; + const int spec_w = display.width() / KB_SPECIAL; + + // text preview: last N chars + cursor (fit within screen width) + int max_preview = display.width() / cw - 1; + if (max_preview > 30) max_preview = 30; const char* disp_start = buf; int disp_len = len; - if (disp_len > 20) { disp_start = buf + (disp_len - 20); disp_len = 20; } - char preview[24]; + if (disp_len > max_preview) { disp_start = buf + (disp_len - max_preview); disp_len = max_preview; } + char preview[32]; snprintf(preview, sizeof(preview), "%.*s_", disp_len, disp_start); - display.setCursor(0, KB_TEXT_Y); + display.setCursor(0, 0); display.print(preview); - display.fillRect(0, KB_SEP_Y, display.width(), 1); + display.fillRect(0, sep_y, display.width(), 1); // character grid for (int r = 0; r < KB_ROWS_CHAR; r++) { - int y = KB_CHARS_Y + r * KB_CELL_H; + int y = chars_y + r * cell_h; for (int c = 0; c < KB_COLS_CHAR; c++) { bool sel = (row == r && col == c); char ch = KB_CHARS[r][c]; if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; - int cx = c * KB_CELL_W; + int cx = c * cell_w; if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, y - 1, KB_CELL_W - 1, KB_CELL_H); + display.fillRect(cx, y - 1, cell_w - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } - display.setCursor(cx + 3, y); + display.setCursor(cx + (cell_w - cw) / 2, y); display.print(ch_buf); } } @@ -100,15 +105,16 @@ struct KeyboardWidget { for (int i = 0; i < KB_SPECIAL; i++) { bool sel = (row == KB_ROWS_CHAR && col == i); bool active = (i == 0 && caps); - int sx = i * 25; + int sx = i * spec_w; if (sel || active) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(sx, KB_SPECIAL_Y - 1, 24, KB_CELL_H); + display.fillRect(sx, spec_y - 1, spec_w - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } - display.setCursor(sx + 1, KB_SPECIAL_Y); + int tw = display.getTextWidth(spec[i]); + display.setCursor(sx + (spec_w - tw) / 2, spec_y); display.print(spec[i]); display.setColor(DisplayDriver::LIGHT); } diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 7026d277..66801e9a 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -7,7 +7,6 @@ class RingtoneEditorScreen : public UIScreen { NodePrefs* _prefs; static const int MAX_NOTES = 32; - static const int CELL_W = 18; static const int MENU_VISIBLE = 5; int _visible_notes = 7; // updated in render(); used by clampScroll() @@ -93,9 +92,11 @@ public: display.setColor(DisplayDriver::LIGHT); const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int cell_w = cw * 2 + 6; // fits 2-char label with margin const int notes_y = display.listStart(); const int cell_h = lh + 6; - _visible_notes = display.width() / CELL_W; + _visible_notes = display.width() / cell_w; char hdr[32]; snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES); @@ -105,7 +106,7 @@ public: for (int i = 0; i < _visible_notes; i++) { int ni = _scroll + i; - int cx = i * CELL_W; + int cx = i * cell_w; bool sel = (ni == _cursor); if (ni < _len) { uint8_t pitch = notePitch(_notes[ni]); @@ -120,24 +121,24 @@ public: } if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, notes_y, CELL_W - 1, cell_h); + display.fillRect(cx, notes_y, cell_w - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); - display.drawRect(cx, notes_y, CELL_W - 1, cell_h); + display.drawRect(cx, notes_y, cell_w - 1, cell_h); } - display.setCursor(cx + 3, notes_y + 3); + display.setCursor(cx + (cell_w - cw * 2) / 2, notes_y + 3); display.print(label); display.setColor(DisplayDriver::LIGHT); } else if (ni == _len && _len < MAX_NOTES) { if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(cx, notes_y, CELL_W - 1, cell_h); + display.fillRect(cx, notes_y, cell_w - 1, cell_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } - display.setCursor(cx + 6, notes_y + 3); + display.setCursor(cx + (cell_w - cw) / 2, notes_y + 3); display.print("+"); display.setColor(DisplayDriver::LIGHT); } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 17f96473..8f390ee2 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -8,7 +8,9 @@ class SettingsScreen : public UIScreen { enum SettingItem { // Display section SECTION_DISPLAY, +#if !defined(EINK_DISPLAY_MODEL) BRIGHTNESS, +#endif AUTO_OFF, AUTO_LOCK, BATT_DISPLAY, @@ -85,7 +87,9 @@ class SettingsScreen : public UIScreen { } void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { - const int box_w = 7, box_h = 7, gap = 2; + const int box_h = display.getLineHeight() - 2; + const int box_w = box_h; + const int gap = 2; for (int i = 0; i < max_val; i++) { int bx = x + i * (box_w + gap); display.drawRect(bx, y, box_w, box_h); @@ -236,10 +240,13 @@ class SettingsScreen : public UIScreen { display.print(sel ? ">" : " "); display.setCursor(display.getCharWidth() + 2, y); +#if !defined(EINK_DISPLAY_MODEL) if (item == BRIGHTNESS) { display.print("Bright"); renderBar(display, display.valCol(), y, (p ? p->display_brightness : 2) + 1, 5); - } else if (item == BUZZER) { + } else +#endif + if (item == BUZZER) { display.print("Buzzer"); display.setCursor(display.valCol(), y); #ifdef PIN_BUZZER @@ -357,7 +364,7 @@ class SettingsScreen : public UIScreen { public: SettingsScreen(UITask* task) - : _task(task), _selected(BRIGHTNESS), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} + : _task(task), _selected(AUTO_OFF), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} void markClean() { _dirty = false; } @@ -441,12 +448,14 @@ public: bool left = (c == KEY_LEFT || c == KEY_PREV); bool enter = (c == KEY_ENTER); +#if !defined(EINK_DISPLAY_MODEL) if (_selected == BRIGHTNESS) { uint8_t lvl = _task->getBrightnessLevel(); if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; } if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; } return right || left; } +#endif if (_selected == BUZZER && (left || right || enter)) { _task->cycleBuzzerMode(); _dirty = true; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index f738718a..316ccc9b 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -16,21 +16,25 @@ public: display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "TOOLS"); - display.fillRect(0, 10, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), 1); + + int item_h = display.lineStep(); + int start_y = display.listStart(); + int cw = display.getCharWidth(); for (int i = 0; i < ITEM_COUNT; i++) { - int y = 12 + i * 12; + int y = start_y + i * item_h; bool sel = (i == _sel); if (sel) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), 11); + display.fillRect(0, y - 1, display.width(), item_h); display.setColor(DisplayDriver::DARK); } else { display.setColor(DisplayDriver::LIGHT); } display.setCursor(0, y); display.print(sel ? ">" : " "); - display.setCursor(8, y); + display.setCursor(cw + 2, y); display.print(ITEMS[i]); } display.setColor(DisplayDriver::LIGHT); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index f72b4afd..6d95f059 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -78,10 +78,11 @@ public: // version info at sz2 int ver_y = logo_y + 13 + 2; display.setTextSize(2); + int lh2 = display.getLineHeight(); display.drawTextCentered(display.width()/2, ver_y, _version_info); - // build date at sz1 — sz2 lh is always 16 - int date_y = ver_y + 16 + 2; + // build date at sz1, below sz2 version + int date_y = ver_y + lh2 + 2; display.setTextSize(1); display.drawTextCentered(display.width()/2, date_y, FIRMWARE_BUILD_DATE); @@ -239,6 +240,10 @@ class HomeScreen : public UIScreen { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int ind = cw + 2; // single-char indicator width + int battLeftX; if (mode == 1) { // percent char buf[6]; @@ -252,52 +257,54 @@ class HomeScreen : public UIScreen { battLeftX = display.width() - display.getTextWidth(buf) - 1; display.setCursor(battLeftX, 0); display.print(buf); - } else { // icon - const int iconWidth = 24, iconHeight = 8; - battLeftX = display.width() - iconWidth - 5; - display.drawRect(battLeftX, 0, iconWidth, iconHeight); - display.fillRect(battLeftX + iconWidth, iconHeight / 4, 3, iconHeight / 2); - int fillWidth = (pct * (iconWidth - 4)) / 100; - display.fillRect(battLeftX + 2, 2, fillWidth, iconHeight - 4); + } else { // icon — scales with lh + const int iconH = lh; + const int iconW = iconH * 3; + battLeftX = display.width() - iconW - 3; + display.drawRect(battLeftX, 0, iconW, iconH); + display.fillRect(battLeftX + iconW, iconH / 4, 2, iconH / 2); + int fillW = (pct * (iconW - 4)) / 100; + display.fillRect(battLeftX + 2, 2, fillW, iconH - 4); } #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { display.setColor(DisplayDriver::LIGHT); - display.drawXbm(battLeftX - 9, 0, muted_icon, 8, 8); + int mx = battLeftX - ind - 1; + display.fillRect(mx, 0, ind, lh); + display.setColor(DisplayDriver::DARK); + display.setCursor(mx + 1, 0); + display.print("M"); + display.setColor(DisplayDriver::LIGHT); + battLeftX = mx; } #endif // BT connection indicator (left of muted/battery icons) int leftmostX = battLeftX; if (_task->isSerialEnabled()) { -#ifdef PIN_BUZZER - int btX = battLeftX - 18; -#else - int btX = battLeftX - 9; -#endif + int btX = battLeftX - ind - 1; if (_task->hasConnection()) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(btX - 1, 0, 7, 7); + display.fillRect(btX, 0, ind, lh); display.setColor(DisplayDriver::DARK); - display.setCursor(btX, 0); + display.setCursor(btX + 1, 0); display.print("B"); display.setColor(DisplayDriver::LIGHT); } else { - display.setColor(DisplayDriver::LIGHT); - display.setCursor(btX, 0); + display.setCursor(btX + 1, 0); display.print("b"); } leftmostX = btX - 1; - // "A" indicator — left of BT, blinks 50% duty at 1s period + // "A" indicator — left of BT, blinks 50% duty at 4s period if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { - int aX = leftmostX - 8; + int aX = leftmostX - ind; if ((millis() % 4000) < 2000) { display.setColor(DisplayDriver::LIGHT); - display.fillRect(aX - 1, 0, 7, 7); + display.fillRect(aX, 0, ind, lh); display.setColor(DisplayDriver::DARK); - display.setCursor(aX, 0); + display.setCursor(aX + 1, 0); display.print("A"); display.setColor(DisplayDriver::LIGHT); } @@ -413,15 +420,17 @@ public: if (show_sec) sprintf(buf, "%02d:%02d:%02d", ti->tm_hour, ti->tm_min, ti->tm_sec); else sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); } + int lh2 = display.getLineHeight(); // sz2 height: 16 (OLED) or 32 (landscape) display.drawTextCentered(display.width() / 2, 0, buf); display.setTextSize(1); static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s %d", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon], 1900 + ti->tm_year); - display.drawTextCentered(display.width() / 2, 19, buf); + int date_y = lh2 + 2; + display.drawTextCentered(display.width() / 2, date_y, buf); - int sep_y = 19 + lh + 1; + int sep_y = date_y + lh + 1; int dash0 = sep_y + 3; display.fillRect(0, sep_y, display.width(), 1); @@ -706,7 +715,9 @@ public: display.drawTextCentered(display.width() / 2, content_y + step, "hibernating..."); } else { display.drawXbm((display.width() - 32) / 2, content_y, power_icon, 32, 32); - display.drawTextCentered(display.width() / 2, content_y + 32 + 3, "hibernate:" PRESS_LABEL); + int hint_base = content_y + 32 + 3; + display.drawTextCentered(display.width() / 2, hint_base, "hibernate:"); + display.drawTextCentered(display.width() / 2, hint_base + step, PRESS_LABEL); } } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 01a50eb3..17481946 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -92,7 +92,7 @@ void GxEPDDisplay::setTextSize(int sz) { break; case 2: display.setFont(NULL); - display.setTextSize(2); + display.setTextSize((width() >= height()) ? 4 : 2); break; default: display.setFont(_use_lemon ? &Lemon : NULL); diff --git a/src/helpers/ui/GxEPDDisplay.h b/src/helpers/ui/GxEPDDisplay.h index c5fa53fc..6cffc323 100644 --- a/src/helpers/ui/GxEPDDisplay.h +++ b/src/helpers/ui/GxEPDDisplay.h @@ -73,13 +73,13 @@ public: int getCharWidth() const override { int sc = (width() >= height()) ? 2 : 1; if (_text_sz == 3) return 17; - if (_text_sz == 2) return 12; + if (_text_sz == 2) return 12 * sc; return (_use_lemon ? 5 : 6) * sc; } int getLineHeight() const override { int sc = (width() >= height()) ? 2 : 1; if (_text_sz == 3) return 28; - if (_text_sz == 2) return 16; + if (_text_sz == 2) return 16 * sc; return (_use_lemon ? 10 : 8) * sc; } void setLemonFont(bool enabled) override { _use_lemon = enabled; } From 118f29b0a1cccc25d188713c374aafe38a77183f Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 19:11:25 +0200 Subject: [PATCH 175/183] =?UTF-8?q?fix:=20landscape=20e-ink=20layout=20cor?= =?UTF-8?q?rections=20=E2=80=94=20clock=20overlap,=20keyboard=20overflow,?= =?UTF-8?q?=20bar=20sizing,=20nearby=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GxEPDDisplay: fontAscender only applies to sz=1 with Lemon; sz=2 (NULL font) was incorrectly getting a 16px cursor offset, causing clock text to render 16px lower than expected and overlap the date line - KeyboardWidget: compute cell_h from available screen height instead of lh+1; tighten sep_y to lh so the grid fits within 122px even with Lemon font (lh=20) - SettingsScreen renderBar: constrain box size to available width so the 5th buzzer-volume square doesn't overflow past the right edge with Lemon font - NearbyScreen discover detail: manually truncate b64 public key by charWidth to guarantee one-line rendering; use dynamic step = (height-hdr)/5 so Status line doesn't fall off the bottom of the screen - NearbyScreen contacts detail: merge dist+az into one line and use dynamic step to fit 5 lines within the display; removes the off-screen Seen: row - AutoAdvertScreen: replace lineStep() gap before hints with 4px fixed gap so both hint lines fit within the display with large fonts Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/AutoAdvertScreen.h | 2 +- .../companion_radio/ui-new/KeyboardWidget.h | 6 +-- .../companion_radio/ui-new/NearbyScreen.h | 38 ++++++++++++------- .../companion_radio/ui-new/SettingsScreen.h | 9 +++-- src/helpers/ui/GxEPDDisplay.cpp | 6 +-- 5 files changed, 38 insertions(+), 23 deletions(-) diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index 4899f29d..c8dc1bfa 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -28,7 +28,7 @@ public: int label_y = display.listStart(); int bar_y = label_y + display.lineStep(); int bar_h = display.lineStep(); - int tip_y = bar_y + bar_h + display.lineStep(); + int tip_y = bar_y + bar_h + 4; display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT"); display.fillRect(0, display.headerH() - 1, display.width(), 1); diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 8cfbfd1d..ec1c89c6 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -61,9 +61,9 @@ struct KeyboardWidget { const int lh = display.getLineHeight(); const int cw = display.getCharWidth(); const int cell_w = display.width() / KB_COLS_CHAR; - const int cell_h = lh + 1; - const int sep_y = lh + 1; - const int chars_y = sep_y + 2; + const int sep_y = lh; // tight separator: preview row height only + const int chars_y = sep_y + 1; + const int cell_h = (display.height() - chars_y) / (KB_ROWS_CHAR + 1); const int spec_y = chars_y + KB_ROWS_CHAR * cell_h; const int spec_w = display.width() / KB_SPECIAL; diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index 315f79cb..b8e4e71d 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -215,12 +215,27 @@ class NearbyScreen : public UIScreen { display.setColor(DisplayDriver::LIGHT); display.fillRect(0, hdr - 1, display.width(), 1); - // public key as base64 + // public key as base64 — truncate to one line via charWidth char b64[48]; pubKeyToBase64(r.pub_key, b64, sizeof(b64)); - display.drawTextEllipsized(2, hdr, display.width() - 4, b64); + { + int max_chars = (display.width() - 4) / display.getCharWidth(); + int b64_len = strlen(b64); + char b64_line[48]; + if (b64_len > max_chars) { + strncpy(b64_line, b64, max_chars - 3); + b64_line[max_chars - 3] = '\0'; + strcat(b64_line, "..."); + } else { + strncpy(b64_line, b64, sizeof(b64_line) - 1); + b64_line[sizeof(b64_line) - 1] = '\0'; + } + display.setCursor(2, hdr); + display.print(b64_line); + } - int step = lh + 1; + // distribute 4 remaining lines across available height below b64 + int step = (display.height() - hdr) / 5; char buf[32]; snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); display.setCursor(2, hdr + step); display.print(buf); @@ -394,9 +409,7 @@ public: // ── detail view ────────────────────────────────────────────────────────── if (_detail && _sel < _count) { const Entry& e = _entries[_sel]; - int lh = display.getLineHeight(); int hdr = display.headerH(); - int step = lh + 1; display.setColor(DisplayDriver::LIGHT); display.fillRect(0, 0, display.width(), hdr - 1); @@ -406,6 +419,8 @@ public: display.drawTextEllipsized(2, 1, display.width() - 4, filtered); display.setColor(DisplayDriver::LIGHT); + // 5 lines: lat, lon, dist+bearing, type, seen — distributed across available height + int step = (display.height() - hdr) / 5; char buf[32]; snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); display.setCursor(2, hdr); display.print(buf); @@ -417,22 +432,19 @@ public: char dist[12]; fmtDist(dist, sizeof(dist), e.dist_km); int az = bearingDeg(_own_lat, _own_lon, e.lat_e6, e.lon_e6); - snprintf(buf, sizeof(buf), "Dist: %s", dist); - display.setCursor(2, hdr + step * 2); display.print(buf); - snprintf(buf, sizeof(buf), "Az: %dd (%s)", az, bearingCardinal(az)); - display.setCursor(2, hdr + step * 3); display.print(buf); + snprintf(buf, sizeof(buf), "Dist: %s %s", dist, bearingCardinal(az)); } else { - display.setCursor(2, hdr + step * 2); display.print("Dist: no own GPS"); - display.setCursor(2, hdr + step * 3); display.print("Az: unknown"); + snprintf(buf, sizeof(buf), "Dist: no GPS"); } + display.setCursor(2, hdr + step * 2); display.print(buf); snprintf(buf, sizeof(buf), "Type: %s", typeName(e.type)); - display.setCursor(2, hdr + step * 4); display.print(buf); + display.setCursor(2, hdr + step * 3); display.print(buf); char age[16]; fmtAge(age, sizeof(age), e.lastmod); snprintf(buf, sizeof(buf), "Seen: %s", age); - display.setCursor(2, hdr + step * 5); display.print(buf); + display.setCursor(2, hdr + step * 4); display.print(buf); return 2000; } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 8f390ee2..49aef7ed 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -87,9 +87,12 @@ class SettingsScreen : public UIScreen { } void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { - const int box_h = display.getLineHeight() - 2; - const int box_w = box_h; - const int gap = 2; + const int gap = 2; + const int avail = display.width() - x; + const int raw = (avail - (max_val - 1) * gap) / max_val; + const int cap = display.getLineHeight() - 2; + const int box_h = raw < cap ? (raw < 2 ? 2 : raw) : cap; + const int box_w = box_h; for (int i = 0; i < max_val; i++) { int bx = x + i * (box_w + gap); display.drawRect(bx, y, box_w, box_h); diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index 17481946..ff4fff28 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -17,9 +17,9 @@ // coordinates (same convention as the OLED driver). Add the font ascender so // the two conventions match. static int fontAscender(int sz, bool use_lemon, int scale) { - if (sz == 3) return 26; // FreeSans18pt7b: proportional, baseline origin - if (use_lemon) return 8 * scale; // Lemon GFX font: baseline origin, ascender 8px×scale - return 0; // GFX built-in font: cursor is top-left of cell + if (sz == 3) return 26; // FreeSans18pt7b: proportional, baseline origin + if (sz == 1 && use_lemon) return 8 * scale; // Lemon GFX font: baseline origin, ascender 8px×scale + return 0; // GFX built-in font: cursor is top-left of cell } bool GxEPDDisplay::begin() { From 9c671891d942236784482bed6170effe7ae18d54 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 23:13:27 +0200 Subject: [PATCH 176/183] =?UTF-8?q?fix:=20e-ink=20portrait/landscape=20lay?= =?UTF-8?q?out=20=E2=80=94=20separators,=20battery,=20keyboard,=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DisplayDriver: add sepH() (2px landscape, 1px OLED); fix transliterateCodepoint fallback '?' (was '\xDB' which broke UTF-8 word-wrap and rendered tiny block char) - GxEPDDisplay: drawRect draws double border on landscape for visible 2px weight - UITask: navigation dots scale with lh; content_y saves 16px by using dots_y+6; battery iconW=lh*2, fill margin bm scales with orientation; indicator spacing fixed; clock separator uses sepH(); alert popup tight and vertically centred - KeyboardWidget: compact cell height (no stretch), multi-line preview, cursor always on last visible line - BotScreen / NearbyScreen detail: use lineStep() as natural item height, shrink only when items don't fit (fixes portrait stretch) - QuickMsgScreen: send button pinned to display bottom (height-lh-2), not floating - SettingsScreen: AUTO_OFF guarded with #if AUTO_OFF_MILLIS>0; default sel=AUTO_LOCK - All screens: separators updated to sepH() Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/AutoAdvertScreen.h | 2 +- examples/companion_radio/ui-new/BotScreen.h | 4 +- .../ui-new/DashboardConfigScreen.h | 2 +- .../companion_radio/ui-new/KeyboardWidget.h | 39 ++++++++++----- .../companion_radio/ui-new/NearbyScreen.h | 12 +++-- .../companion_radio/ui-new/QuickMsgScreen.h | 22 +++++---- .../ui-new/RingtoneEditorScreen.h | 2 +- .../companion_radio/ui-new/SettingsScreen.h | 16 ++++++- examples/companion_radio/ui-new/ToolsScreen.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 47 +++++++++++-------- src/helpers/ui/DisplayDriver.h | 4 +- src/helpers/ui/GxEPDDisplay.cpp | 2 + 12 files changed, 100 insertions(+), 54 deletions(-) diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index c8dc1bfa..50ca594c 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -31,7 +31,7 @@ public: int tip_y = bar_y + bar_h + 4; display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); display.setCursor(2, label_y); display.print("Interval:"); diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 027f7dfb..1d14d76d 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -55,12 +55,14 @@ public: return _kb.render(display); } + int avail_h = display.height() - display.listStart(); int item_h = display.lineStep(); + if (item_h * ITEM_COUNT > avail_h) item_h = avail_h / ITEM_COUNT; int start_y = display.listStart(); int val_x = display.valCol(); display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply DM", "Reply Ch" }; for (int i = 0; i < ITEM_COUNT; i++) { diff --git a/examples/companion_radio/ui-new/DashboardConfigScreen.h b/examples/companion_radio/ui-new/DashboardConfigScreen.h index b60011b3..15957c5d 100644 --- a/examples/companion_radio/ui-new/DashboardConfigScreen.h +++ b/examples/companion_radio/ui-new/DashboardConfigScreen.h @@ -46,7 +46,7 @@ public: int val_x = display.valCol(); display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); static const char* labels[] = { "Field 1", "Field 2", "Field 3" }; for (int i = 0; i < FIELD_SLOTS; i++) { diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index ec1c89c6..81560b7b 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -61,22 +61,39 @@ struct KeyboardWidget { const int lh = display.getLineHeight(); const int cw = display.getCharWidth(); const int cell_w = display.width() / KB_COLS_CHAR; - const int sep_y = lh; // tight separator: preview row height only + // compact: don't stretch cells beyond lh; freed vertical space goes to preview lines + const int kb_h = (KB_ROWS_CHAR + 1) * lh; + const int preview_h = display.height() - kb_h - 1; + const int prev_lines = (preview_h / lh) > 1 ? (preview_h / lh) : 1; + const int sep_y = prev_lines * lh; const int chars_y = sep_y + 1; const int cell_h = (display.height() - chars_y) / (KB_ROWS_CHAR + 1); const int spec_y = chars_y + KB_ROWS_CHAR * cell_h; const int spec_w = display.width() / KB_SPECIAL; - // text preview: last N chars + cursor (fit within screen width) - int max_preview = display.width() / cw - 1; - if (max_preview > 30) max_preview = 30; - const char* disp_start = buf; - int disp_len = len; - if (disp_len > max_preview) { disp_start = buf + (disp_len - max_preview); disp_len = max_preview; } - char preview[32]; - snprintf(preview, sizeof(preview), "%.*s_", disp_len, disp_start); - display.setCursor(0, 0); - display.print(preview); + // multi-line text preview: cursor always on last preview line + int cpl = display.width() / cw; // chars per preview line + if (cpl < 1) cpl = 1; + int cursor_line = len / cpl; + int first_line = (cursor_line >= prev_lines) ? (cursor_line - prev_lines + 1) : 0; + int start = first_line * cpl; + for (int pl = 0; pl < prev_lines; pl++) { + int ps = start + pl * cpl; + int pe = ps + cpl; + bool cursor_here = (ps <= len && (len < pe || pl == prev_lines - 1)); + char linebuf[32]; + if (cursor_here) { + int nc = len - ps; if (nc < 0) nc = 0; if (nc > cpl - 1) nc = cpl - 1; + snprintf(linebuf, sizeof(linebuf), "%.*s_", nc, buf + ps); + } else if (len > ps) { + int nc = (len < pe) ? (len - ps) : cpl; + snprintf(linebuf, sizeof(linebuf), "%.*s", nc, buf + ps); + } else { + linebuf[0] = '\0'; + } + display.setCursor(0, pl * lh); + display.print(linebuf); + } display.fillRect(0, sep_y, display.width(), 1); // character grid diff --git a/examples/companion_radio/ui-new/NearbyScreen.h b/examples/companion_radio/ui-new/NearbyScreen.h index b8e4e71d..699de8c2 100644 --- a/examples/companion_radio/ui-new/NearbyScreen.h +++ b/examples/companion_radio/ui-new/NearbyScreen.h @@ -234,8 +234,9 @@ class NearbyScreen : public UIScreen { display.print(b64_line); } - // distribute 4 remaining lines across available height below b64 - int step = (display.height() - hdr) / 5; + // distribute 4 remaining lines below b64 — compact step, shrink only if needed + int step = display.lineStep(); + if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; char buf[32]; snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi); display.setCursor(2, hdr + step); display.print(buf); @@ -419,8 +420,9 @@ public: display.drawTextEllipsized(2, 1, display.width() - 4, filtered); display.setColor(DisplayDriver::LIGHT); - // 5 lines: lat, lon, dist+bearing, type, seen — distributed across available height - int step = (display.height() - hdr) / 5; + // 5 lines: lat, lon, dist+bearing, type, seen — compact step, shrink only if needed + int step = display.lineStep(); + if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5; char buf[32]; snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6); display.setCursor(2, hdr); display.print(buf); @@ -459,7 +461,7 @@ public: char title[22]; snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]); display.drawTextCentered(display.width() / 2, 0, title); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); if (_count == 0) { display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No contacts found"); diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 86c25d5d..6b8957aa 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -468,7 +468,7 @@ public: if (_phase == MODE_SELECT) { display.drawTextCentered(display.width()/2, 0, "MESSAGE"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); const char* opts[] = { "Direct message", "Channels", "Room Servers" }; int badges[3] = { getDMUnreadTotal(), @@ -501,7 +501,7 @@ public: } else if (_phase == CONTACT_PICK) { display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); if (_num_contacts == 0) { display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites"); @@ -551,7 +551,7 @@ public: } else if (_phase == CHANNEL_PICK) { display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); if (_num_channels == 0) { display.drawTextCentered(display.width()/2, display.height()/2, "No channels"); @@ -618,15 +618,16 @@ public: int hist_box_h = 2 * lh + 3; int hist_item_h = hist_box_h + 2; int hist_start_y = display.headerH(); - _hist_visible = (display.height() - hist_start_y) / hist_item_h; + int btn_h = lh + 4; + _hist_visible = (display.height() - hist_start_y - btn_h) / hist_item_h; if (_hist_visible < 1) _hist_visible = 1; - int cby = hist_start_y + _hist_visible * hist_item_h + 2; + int cby = display.height() - lh - 2; char title[24]; display.setColor(DisplayDriver::LIGHT); snprintf(title, sizeof(title), "%.23s", filtered_name); display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, lh + 1, display.width(), 1); + display.fillRect(0, lh + 1, display.width(), display.sepH()); int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); @@ -720,16 +721,17 @@ public: int hist_box_h = 2 * lh + 3; int hist_item_h = hist_box_h + 2; int hist_start_y = display.headerH(); - _hist_visible = (display.height() - hist_start_y) / hist_item_h; + int btn_h = lh + 4; + _hist_visible = (display.height() - hist_start_y - btn_h) / hist_item_h; if (_hist_visible < 1) _hist_visible = 1; - int cby = hist_start_y + _hist_visible * hist_item_h + 2; + int cby = display.height() - lh - 2; ChannelDetails ch; the_mesh.getChannel(_sel_channel_idx, ch); char title[24]; snprintf(title, sizeof(title), "%.23s", ch.name); display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, lh + 1, display.width(), 1); + display.fillRect(0, lh + 1, display.width(), display.sepH()); int ch_hist_count = histCountForChannel(_sel_channel_idx); @@ -832,7 +834,7 @@ public: snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); } display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); int total_msg_items = 1 + _active_msg_count; for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) { diff --git a/examples/companion_radio/ui-new/RingtoneEditorScreen.h b/examples/companion_radio/ui-new/RingtoneEditorScreen.h index 66801e9a..ddd30d8c 100644 --- a/examples/companion_radio/ui-new/RingtoneEditorScreen.h +++ b/examples/companion_radio/ui-new/RingtoneEditorScreen.h @@ -102,7 +102,7 @@ public: snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES); display.setCursor(0, 0); display.print(hdr); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); for (int i = 0; i < _visible_notes; i++) { int ni = _scroll + i; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 49aef7ed..b01bf548 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -11,7 +11,9 @@ class SettingsScreen : public UIScreen { #if !defined(EINK_DISPLAY_MODEL) BRIGHTNESS, #endif +#if AUTO_OFF_MILLIS > 0 AUTO_OFF, +#endif AUTO_LOCK, BATT_DISPLAY, #if !defined(EINK_DISPLAY_MODEL) @@ -64,9 +66,11 @@ class SettingsScreen : public UIScreen { int _visible; // items fitting on screen; updated each render, used by handleInput bool _dirty; +#if AUTO_OFF_MILLIS > 0 static const uint16_t AUTO_OFF_OPTS[5]; static const char* AUTO_OFF_LABELS[5]; static const int AUTO_OFF_COUNT = 5; +#endif #if ENV_INCLUDE_GPS == 1 static const uint32_t GPS_INTERVAL_OPTS[6]; static const char* GPS_INTERVAL_LABELS[6]; @@ -101,6 +105,7 @@ class SettingsScreen : public UIScreen { } } +#if AUTO_OFF_MILLIS > 0 int autoOffIndex() { NodePrefs* p = _task->getNodePrefs(); if (!p) return 1; @@ -108,6 +113,7 @@ class SettingsScreen : public UIScreen { if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i; return 1; } +#endif #if ENV_INCLUDE_GPS == 1 int gpsIntervalIndex() { @@ -289,10 +295,12 @@ class SettingsScreen : public UIScreen { sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); display.setCursor(display.valCol(), y); display.print(buf); +#if AUTO_OFF_MILLIS > 0 } else if (item == AUTO_OFF) { display.print("AutoOff"); display.setCursor(display.valCol(), y); display.print(AUTO_OFF_LABELS[autoOffIndex()]); +#endif } else if (item == AUTO_LOCK) { display.print("AutoLock"); display.setCursor(display.valCol(), y); @@ -367,7 +375,7 @@ class SettingsScreen : public UIScreen { public: SettingsScreen(UITask* task) - : _task(task), _selected(AUTO_OFF), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} + : _task(task), _selected(AUTO_LOCK), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {} void markClean() { _dirty = false; } @@ -385,7 +393,7 @@ public: display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); - display.fillRect(0, display.headerH() - 2, display.width(), 1); + display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH()); for (int i = 0; i < _visible && (_scroll + i) < SettingItem::Count; i++) { renderItem(display, _scroll + i, start_y + i * item_h); @@ -489,12 +497,14 @@ public: if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; } } +#if AUTO_OFF_MILLIS > 0 if (_selected == AUTO_OFF && p) { int idx = autoOffIndex(); if (right) idx = (idx + 1) % AUTO_OFF_COUNT; if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } } +#endif if (_selected == AUTO_LOCK && p && (left || right || enter)) { p->auto_lock ^= 1; _dirty = true; @@ -576,8 +586,10 @@ public: } }; +#if AUTO_OFF_MILLIS > 0 const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; +#endif #if ENV_INCLUDE_GPS == 1 const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 316ccc9b..cde36181 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -16,7 +16,7 @@ public: display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, 0, "TOOLS"); - display.fillRect(0, display.headerH() - 1, display.width(), 1); + display.fillRect(0, display.headerH() - 1, display.width(), display.sepH()); int item_h = display.lineStep(); int start_y = display.listStart(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6d95f059..5fe4e73f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -240,9 +240,10 @@ class HomeScreen : public UIScreen { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); - const int lh = display.getLineHeight(); - const int cw = display.getCharWidth(); - const int ind = cw + 2; // single-char indicator width + const int lh = display.getLineHeight(); + const int cw = display.getCharWidth(); + const int ind = cw + 2; // single-char indicator width + const int ind_gap = (lh >= 16) ? 3 : 1; // gap between indicator boxes int battLeftX; if (mode == 1) { // percent @@ -259,18 +260,19 @@ class HomeScreen : public UIScreen { display.print(buf); } else { // icon — scales with lh const int iconH = lh; - const int iconW = iconH * 3; + const int iconW = lh * 2; + const int bm = (lh >= 16) ? 3 : 2; // inner margin: 3px on landscape (2px border), 2px on OLED battLeftX = display.width() - iconW - 3; display.drawRect(battLeftX, 0, iconW, iconH); display.fillRect(battLeftX + iconW, iconH / 4, 2, iconH / 2); - int fillW = (pct * (iconW - 4)) / 100; - display.fillRect(battLeftX + 2, 2, fillW, iconH - 4); + int fillW = (pct * (iconW - 2 * bm)) / 100; + display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm); } #ifdef PIN_BUZZER if (_task->isBuzzerQuiet()) { display.setColor(DisplayDriver::LIGHT); - int mx = battLeftX - ind - 1; + int mx = battLeftX - ind - ind_gap; display.fillRect(mx, 0, ind, lh); display.setColor(DisplayDriver::DARK); display.setCursor(mx + 1, 0); @@ -283,7 +285,7 @@ class HomeScreen : public UIScreen { // BT connection indicator (left of muted/battery icons) int leftmostX = battLeftX; if (_task->isSerialEnabled()) { - int btX = battLeftX - ind - 1; + int btX = battLeftX - ind - ind_gap; if (_task->hasConnection()) { display.setColor(DisplayDriver::LIGHT); display.fillRect(btX, 0, ind, lh); @@ -295,7 +297,7 @@ class HomeScreen : public UIScreen { display.setCursor(btX + 1, 0); display.print("b"); } - leftmostX = btX - 1; + leftmostX = btX - ind_gap; // "A" indicator — left of BT, blinks 50% duty at 4s period if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { @@ -358,7 +360,7 @@ public: const int lh = display.getLineHeight(); // line height at sz1 const int step = display.lineStep(); // lh + 2 const int dots_y = lh + 4; // page-dot row: just below header - const int content_y = dots_y + step; // first content row + const int content_y = dots_y + 6; // first content row (6px gap keeps dots visible) // node name + battery — hidden on CLOCK page (full screen used for dashboard) if (_page != CLOCK) { @@ -385,8 +387,9 @@ public: int vi = 0; for (int i = 0; i < (int)Count; i++) { if (!isPageVisible(i)) continue; - if (vi == curr_vis) display.fillRect(x-1, dots_y-1, 3, 3); - else display.fillRect(x, dots_y, 1, 1); + int ds = (lh >= 16) ? 2 : 1; + if (vi == curr_vis) display.fillRect(x-ds, dots_y-ds, 2*ds+1, 2*ds+1); + else display.fillRect(x-ds+1, dots_y-ds+1, 2*ds-1, 2*ds-1); x += 10; vi++; } } @@ -431,8 +434,8 @@ public: display.drawTextCentered(display.width() / 2, date_y, buf); int sep_y = date_y + lh + 1; - int dash0 = sep_y + 3; - display.fillRect(0, sep_y, display.width(), 1); + int dash0 = sep_y + display.sepH() + 2; + display.fillRect(0, sep_y, display.width(), display.sepH()); // dashboard data fields if (_node_prefs) { @@ -1448,13 +1451,17 @@ void UITask::loop() { int delay_millis = curr->render(*_display); if (millis() < _alert_expiry && curr == home) { // render alert only on home screen _display->setTextSize(1); - int y = _display->height() / 3; - int p = _display->height() / 32; + int lh = _display->getLineHeight(); + int pad = 3; + int box_h = lh + pad * 2; + int box_w = _display->width() - 8; + int box_x = 4; + int box_y = (_display->height() - box_h) / 2; _display->setColor(DisplayDriver::DARK); - _display->fillRect(p, y, _display->width() - p*2, y); - _display->setColor(DisplayDriver::LIGHT); // draw box border - _display->drawRect(p, y, _display->width() - p*2, y); - _display->drawTextCentered(_display->width() / 2, y + p*3, _alert); + _display->fillRect(box_x, box_y, box_w, box_h); + _display->setColor(DisplayDriver::LIGHT); + _display->drawRect(box_x, box_y, box_w, box_h); + _display->drawTextCentered(_display->width() / 2, box_y + pad, _alert); _next_refresh = _alert_expiry; // will need refresh when alert is dismissed } else { _next_refresh = millis() + delay_millis; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index c75baed9..c489f9cb 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -41,6 +41,8 @@ public: int listVisible() const { return listVisible(lineStep()); } // x where a right-side value column starts (leaves ~8 chars for the value) int valCol() const { return width() - getCharWidth() * 8; } + // separator line thickness: 2px on landscape e-ink, 1px on OLED + int sepH() const { return (width() >= height()) ? 2 : 1; } virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise) int w = getTextWidth(str); setCursor(mid_x - w/2, y); @@ -135,7 +137,7 @@ public: case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý - default: return '\xDB'; // CP437 full block █ + default: return '?'; } } diff --git a/src/helpers/ui/GxEPDDisplay.cpp b/src/helpers/ui/GxEPDDisplay.cpp index ff4fff28..7d337a3a 100644 --- a/src/helpers/ui/GxEPDDisplay.cpp +++ b/src/helpers/ui/GxEPDDisplay.cpp @@ -139,6 +139,8 @@ void GxEPDDisplay::drawRect(int x, int y, int w, int h) { display_crc.update(w); display_crc.update(h); display.drawRect(x, y, w, h, _curr_color); + if (width() >= height() && w > 2 && h > 2) + display.drawRect(x + 1, y + 1, w - 2, h - 2, _curr_color); } void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) { From 7519740d72ea5a501947893264dc9f517b49bd99 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 23:20:06 +0200 Subject: [PATCH 177/183] ci: build dual OLED and dual e-ink firmwares in parallel Split the single build job into two independent jobs: - build-oled: checks out wio-tracker-l1-improvements, builds WioTrackerL1_companion_radio_dual_settings - build-eink: checks out eink-ui, builds WioTrackerL1Eink_companion_radio_dual Both artifacts are merged into a single draft release. Co-Authored-By: Claude Sonnet 4.6 --- .../build-wio-tracker-l1-firmwares.yml | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-wio-tracker-l1-firmwares.yml b/.github/workflows/build-wio-tracker-l1-firmwares.yml index 5fb9d633..caf5545d 100644 --- a/.github/workflows/build-wio-tracker-l1-firmwares.yml +++ b/.github/workflows/build-wio-tracker-l1-firmwares.yml @@ -14,29 +14,54 @@ env: jobs: - build: + build-oled: runs-on: ubuntu-latest steps: - name: Clone Repo uses: actions/checkout@v4 + with: + ref: wio-tracker-l1-improvements - name: Setup Build Environment uses: ./.github/actions/setup-build-environment - - name: Build Firmwares + - name: Build Dual OLED Firmware env: FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} run: /usr/bin/env bash build.sh build-wio-tracker-l1-firmwares - - name: Upload Workflow Artifacts + - name: Upload OLED Firmware uses: actions/upload-artifact@v4 with: - name: wio-tracker-l1-firmwares + name: wio-tracker-l1-oled-firmware + path: out + + build-eink: + runs-on: ubuntu-latest + steps: + + - name: Clone Repo + uses: actions/checkout@v4 + with: + ref: eink-ui + + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + + - name: Build Dual E-ink Firmware + env: + FIRMWARE_VERSION: ${{ env.GIT_TAG_VERSION }} + run: /usr/bin/env bash build.sh build-firmware WioTrackerL1Eink_companion_radio_dual + + - name: Upload E-ink Firmware + uses: actions/upload-artifact@v4 + with: + name: wio-tracker-l1-eink-firmware path: out release: - needs: [build] + needs: [build-oled, build-eink] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: @@ -44,10 +69,16 @@ jobs: - name: Extract Version from Git Tag run: echo "GIT_TAG_VERSION=v${GITHUB_REF_NAME#*-v}" >> $GITHUB_ENV - - name: Download Firmwares + - name: Download OLED Firmware uses: actions/download-artifact@v4 with: - name: wio-tracker-l1-firmwares + name: wio-tracker-l1-oled-firmware + path: out + + - name: Download E-ink Firmware + uses: actions/download-artifact@v4 + with: + name: wio-tracker-l1-eink-firmware path: out - name: Create Release From bb94a6f81f12afef0fea435494b3897ba2e44777 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 23:33:06 +0200 Subject: [PATCH 178/183] =?UTF-8?q?feat:=20home=20screen=20ordering=20?= =?UTF-8?q?=E2=80=94=20reorder=20all=20screens=20from=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodePrefs: add page_order[11] (1-indexed bit-index, 0=end/legacy). DataStore: persist page_order appended after use_lemon_font. UITask/HomeScreen: - bitToPage(): maps stable bit indices 0-10 to HomePage enum values - buildVisibleOrder(): respects page_order when initialised, else falls back to default enum sequence - navPage() and navigation dots use buildVisibleOrder() SettingsScreen Home Pages section: - Add Settings (bit 9) and Messages (bit 10) — show as "always" - LEFT/RIGHT on any home page item moves it earlier/later in order - ENTER toggles ON/OFF for pages that can be disabled - Position number shown before label once custom order is active - ensurePageOrderInit() seeds default order on first reorder action Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/NodePrefs.h | 3 + .../companion_radio/ui-new/SettingsScreen.h | 144 +++++++++++++++--- examples/companion_radio/ui-new/UITask.cpp | 79 +++++++--- 4 files changed, 191 insertions(+), 39 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index afb9c748..e879dba7 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -289,6 +289,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); if (file.available()) { file.read((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + if (file.available()) { + file.read((uint8_t *)_prefs.page_order, sizeof(_prefs.page_order)); + } } } } @@ -383,6 +386,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); file.write((uint8_t *)&_prefs.clock_12h, sizeof(_prefs.clock_12h)); file.write((uint8_t *)&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font)); + file.write((uint8_t *)_prefs.page_order, sizeof(_prefs.page_order)); file.close(); } diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4f5b878a..086b70a4 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -79,4 +79,7 @@ struct NodePrefs { // persisted to file static const int DM_MELODY_TABLE_MAX = 16; DmMelodyEntry dm_melody[DM_MELODY_TABLE_MAX]; uint8_t use_lemon_font; // 0=default Adafruit font, 1=Lemon font (Unicode, pixel-accurate wrap) + // Home screen page order: each byte = bit-index+1 (see HP_* bit positions + 9=Settings, 10=Messages). + // 0 = end of list (also uninitialized legacy). hasCustomOrder iff page_order[0] in 1..11. + uint8_t page_order[11]; }; \ No newline at end of file diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 5102c8b1..27fc45cb 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -30,6 +30,7 @@ class SettingsScreen : public UIScreen { #if UI_SENSORS_PAGE == 1 HOME_SENSORS, #endif + HOME_SETTINGS, HOME_QUICK_MSG, HOME_TOOLS, HOME_SHUTDOWN, // Radio section SECTION_RADIO, @@ -137,9 +138,9 @@ class SettingsScreen : public UIScreen { } bool isHomePage(int item) const { - return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || - item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || - item == HOME_SHUTDOWN + return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || + item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || + item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG #if ENV_INCLUDE_GPS == 1 || item == HOME_GPS #endif @@ -155,41 +156,126 @@ class SettingsScreen : public UIScreen { if (item == HOME_RADIO) return HP_RADIO; if (item == HOME_BT) return HP_BLUETOOTH; if (item == HOME_ADVERT) return HP_ADVERT; - if (item == HOME_TOOLS) return HP_TOOLS; - if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; + if (item == HOME_TOOLS) return HP_TOOLS; + if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; + // HOME_SETTINGS and HOME_QUICK_MSG have no mask bit — always visible #if ENV_INCLUDE_GPS == 1 - if (item == HOME_GPS) return HP_GPS; + if (item == HOME_GPS) return HP_GPS; #endif #if UI_SENSORS_PAGE == 1 - if (item == HOME_SENSORS) return HP_SENSORS; + if (item == HOME_SENSORS) return HP_SENSORS; #endif return 0; } const char* homePageLabel(int item) const { - if (item == HOME_CLOCK) return "Clock"; - if (item == HOME_RECENT) return "Recent"; - if (item == HOME_RADIO) return "Radio"; - if (item == HOME_BT) return "Bluetooth"; - if (item == HOME_ADVERT) return "Advert"; - if (item == HOME_TOOLS) return "Tools"; - if (item == HOME_SHUTDOWN) return "Shutdown"; + if (item == HOME_CLOCK) return "Clock"; + if (item == HOME_RECENT) return "Recent"; + if (item == HOME_RADIO) return "Radio"; + if (item == HOME_BT) return "Bluetooth"; + if (item == HOME_ADVERT) return "Advert"; + if (item == HOME_TOOLS) return "Tools"; + if (item == HOME_SHUTDOWN) return "Shutdown"; + if (item == HOME_SETTINGS) return "Settings"; + if (item == HOME_QUICK_MSG) return "Messages"; #if ENV_INCLUDE_GPS == 1 - if (item == HOME_GPS) return "GPS"; + if (item == HOME_GPS) return "GPS"; #endif #if UI_SENSORS_PAGE == 1 - if (item == HOME_SENSORS) return "Sensors"; + if (item == HOME_SENSORS) return "Sensors"; #endif return ""; } bool homePageVisible(int item, const NodePrefs* p) const { + if (item == HOME_SETTINGS || item == HOME_QUICK_MSG) return true; uint16_t bit = homePageBit(item); if (!bit) return false; uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; return (mask & bit) != 0; } + bool homePageToggleable(int item) const { + return item != HOME_SETTINGS && item != HOME_QUICK_MSG; + } + + // Returns the bit-index (0-10) used in page_order for this SettingItem, or -1. + int homePageBitIndex(int item) const { + if (item == HOME_CLOCK) return 0; + if (item == HOME_RECENT) return 1; + if (item == HOME_RADIO) return 2; + if (item == HOME_BT) return 3; + if (item == HOME_ADVERT) return 4; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return 5; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return 6; +#endif + if (item == HOME_TOOLS) return 7; + if (item == HOME_SHUTDOWN) return 8; + if (item == HOME_SETTINGS) return 9; + if (item == HOME_QUICK_MSG) return 10; + return -1; + } + + // Returns 1-based position of item in page_order, or 0 if no custom order / not found. + int homePagePosition(int item, const NodePrefs* p) const { + if (!p || p->page_order[0] < 1 || p->page_order[0] > 11) return 0; + int bit = homePageBitIndex(item); + if (bit < 0) return 0; + for (int i = 0; i < 11; i++) { + uint8_t v = p->page_order[i]; + if (v < 1 || v > 11) break; + if ((int)(v - 1) == bit) return i + 1; + } + return 0; + } + + // Initialises page_order to the default display sequence if not already set. + void ensurePageOrderInit(NodePrefs* p) const { + if (!p) return; + if (p->page_order[0] >= 1 && p->page_order[0] <= 11) return; + // Default: CLOCK RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS TOOLS MESSAGES SHUTDOWN + int j = 0; + p->page_order[j++] = 0 + 1; // CLOCK + p->page_order[j++] = 1 + 1; // RECENT + p->page_order[j++] = 2 + 1; // RADIO + p->page_order[j++] = 3 + 1; // BLUETOOTH + p->page_order[j++] = 4 + 1; // ADVERT +#if ENV_INCLUDE_GPS == 1 + p->page_order[j++] = 5 + 1; // GPS +#endif +#if UI_SENSORS_PAGE == 1 + p->page_order[j++] = 6 + 1; // SENSORS +#endif + p->page_order[j++] = 9 + 1; // SETTINGS + p->page_order[j++] = 7 + 1; // TOOLS + p->page_order[j++] = 10 + 1; // MESSAGES (QUICK_MSG) + p->page_order[j++] = 8 + 1; // SHUTDOWN + while (j < 11) p->page_order[j++] = 0; + } + + // Swaps item's page_order slot with its neighbour in the given direction (-1=earlier, +1=later). + void movePageInOrder(int item, int delta, NodePrefs* p) { + ensurePageOrderInit(p); + int bit = homePageBitIndex(item); + if (bit < 0) return; + int cur = -1, total = 0; + for (int i = 0; i < 11; i++) { + uint8_t v = p->page_order[i]; + if (v < 1 || v > 11) break; + if ((int)(v - 1) == bit) cur = i; + total++; + } + if (cur < 0) return; + int next = cur + delta; + if (next < 0 || next >= total) return; + uint8_t tmp = p->page_order[cur]; + p->page_order[cur] = p->page_order[next]; + p->page_order[next] = tmp; + } + bool isMsgSlot(int item) const { return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; } @@ -269,9 +355,17 @@ class SettingsScreen : public UIScreen { uint8_t v = p ? p->notif_melody_ch : 0; display.print(L[v < 3 ? v : 0]); } } else if (isHomePage(item)) { + int pos = homePagePosition(item, p); + if (pos > 0) { + char pb[4]; snprintf(pb, sizeof(pb), "%d ", pos); + display.print(pb); + } display.print(homePageLabel(item)); display.setCursor(VAL_X, y); - display.print(homePageVisible(item, p) ? "ON" : "OFF"); + if (!homePageToggleable(item)) + display.print("always"); + else + display.print(homePageVisible(item, p) ? "ON" : "OFF"); } else if (item == TX_POWER) { display.print("TX Pwr"); char buf[8]; @@ -452,10 +546,18 @@ public: p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; _dirty = true; return true; } - if (isHomePage(_selected) && p && (left || right || enter)) { - p->home_pages_mask ^= homePageBit(_selected); - _dirty = true; - return true; + if (isHomePage(_selected) && p) { + if (left || right) { + movePageInOrder(_selected, left ? -1 : 1, p); + _dirty = true; + return true; + } + if (enter && homePageToggleable(_selected)) { + p->home_pages_mask ^= homePageBit(_selected); + _dirty = true; + return true; + } + return enter; } if (_selected == TX_POWER && p) { if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ecf78738..20a617a0 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -174,6 +174,35 @@ class HomeScreen : public UIScreen { return -1; // SETTINGS, QUICK_MSG always visible } + // Maps page_order bit-index (0-10) back to the HomePage enum value for this build. + // Returns -1 if the page is not compiled in. + int bitToPage(int bit) const { + switch (bit) { + case 0: return CLOCK; + case 1: return RECENT; + case 2: return RADIO; + case 3: return BLUETOOTH; + case 4: return ADVERT; + case 5: +#if ENV_INCLUDE_GPS == 1 + return GPS; +#else + return -1; +#endif + case 6: +#if UI_SENSORS_PAGE == 1 + return SENSORS; +#else + return -1; +#endif + case 7: return TOOLS; + case 8: return SHUTDOWN; + case 9: return SETTINGS; + case 10: return QUICK_MSG; + default: return -1; + } + } + bool isPageVisible(int page) const { int bit = pageBit(page); if (bit < 0) return true; @@ -181,12 +210,31 @@ class HomeScreen : public UIScreen { return (mask >> bit) & 1; } - int navPage(int from, int dir) const { - for (int i = 1; i < (int)Count; i++) { - int next = ((from + dir * i) % (int)Count + (int)Count) % (int)Count; - if (isPageVisible(next)) return next; + // Build ordered list of all visible pages, respecting page_order when set. + // Returns count; out[] receives HomePage enum values. + int buildVisibleOrder(int* out) const { + int n = 0; + bool custom = _node_prefs && _node_prefs->page_order[0] >= 1 && _node_prefs->page_order[0] <= 11; + if (custom) { + for (int i = 0; i < 11; i++) { + uint8_t v = _node_prefs->page_order[i]; + if (v < 1 || v > 11) break; + int pg = bitToPage(v - 1); + if (pg >= 0 && pg < (int)Count && isPageVisible(pg)) out[n++] = pg; + } + } else { + for (int pg = 0; pg < (int)Count; pg++) + if (isPageVisible(pg)) out[n++] = pg; } - return from; + return n; + } + + int navPage(int from, int dir) const { + int order[11]; int n = buildVisibleOrder(order); + if (n == 0) return from; + int cur = 0; + for (int i = 0; i < n; i++) if (order[i] == from) { cur = i; break; } + return order[((cur + dir) % n + n) % n]; } int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) { @@ -354,20 +402,15 @@ public: // curr page indicator — hidden on CLOCK page (full screen used for dashboard) if (_page != CLOCK) { - int vis_count = 0, curr_vis = 0; - for (int i = 0; i < (int)Count; i++) { - if (!isPageVisible(i)) continue; - if (i == _page) curr_vis = vis_count; - vis_count++; - } + int order[11]; int n = buildVisibleOrder(order); + int curr_vis = 0; + for (int i = 0; i < n; i++) if (order[i] == _page) { curr_vis = i; break; } int y = 14; - int x = display.width() / 2 - 5 * (vis_count - 1); - int vi = 0; - for (int i = 0; i < (int)Count; i++) { - if (!isPageVisible(i)) continue; - if (vi == curr_vis) display.fillRect(x-1, y-1, 3, 3); - else display.fillRect(x, y, 1, 1); - x += 10; vi++; + int x = display.width() / 2 - 5 * (n - 1); + for (int i = 0; i < n; i++) { + if (i == curr_vis) display.fillRect(x-1, y-1, 3, 3); + else display.fillRect(x, y, 1, 1); + x += 10; } } From 4dd36f685d7091bb2a57a597c2d5ac20b5b7ccd4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 23:50:21 +0200 Subject: [PATCH 179/183] fix: apply rotation before splash screen, clip logo to display width - Move applyRotation() before setCurrScreen(splash) so e-ink startup screen respects the saved display_rotation preference - Clamp meshcore logo width to display.width() so it doesn't overflow in portrait mode (122px screen vs 128px logo) Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 61afcefa..dcc6f470 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -71,7 +71,7 @@ public: // meshcore logo display.setColor(DisplayDriver::LIGHT); - int logoWidth = 128; + int logoWidth = min(128, display.width()); int logo_y = 3; display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13); @@ -893,11 +893,10 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no nearby_screen = new NearbyScreen(this); dashboard_config = new DashboardConfigScreen(this, node_prefs); auto_advert_screen = new AutoAdvertScreen(this, node_prefs); - setCurrScreen(splash); - applyBrightness(); applyFont(); applyRotation(); + setCurrScreen(splash); } void UITask::gotoSettingsScreen() { From 7510878f9e0b0999e7251303081689b4a67453a4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 22 May 2026 23:53:16 +0200 Subject: [PATCH 180/183] fix: center-crop splash logo in portrait mode via negative x offset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert logo width clamp — instead let x go negative so GxEPD's drawPixel clips symmetrically. In portrait (122px wide) x = (122-128)/2 = -3, cropping 3px from each side rather than 6px from the right only. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index dcc6f470..2efa459c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -71,7 +71,7 @@ public: // meshcore logo display.setColor(DisplayDriver::LIGHT); - int logoWidth = min(128, display.width()); + int logoWidth = 128; int logo_y = 3; display.drawXbm((display.width() - logoWidth) / 2, logo_y, meshcore_logo, logoWidth, 13); From 4f68e5ffba6dd189597b1ff6f9af62472bff3a91 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 23 May 2026 09:46:28 +0200 Subject: [PATCH 181/183] fix: fixed-width position prefix in Home Pages settings to prevent label shift Use %2d (right-aligned, always 3 chars) instead of %d so label x-position stays constant when going from single-digit (1-9) to double-digit (10-11). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/SettingsScreen.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 4600dd80..10306cdf 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -374,7 +374,7 @@ class SettingsScreen : public UIScreen { } else if (isHomePage(item)) { int pos = homePagePosition(item, p); if (pos > 0) { - char pb[4]; snprintf(pb, sizeof(pb), "%d ", pos); + char pb[5]; snprintf(pb, sizeof(pb), "%2d ", pos); display.print(pb); } display.print(homePageLabel(item)); From 93e3a29b0def54179018b45b5520d34d721f7409 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 23 May 2026 09:54:31 +0200 Subject: [PATCH 182/183] fix: align ON/OFF/always to fixed column flush with right edge in Home Pages Column at width()-6*charWidth so "always" touches the right edge; ON and OFF start at the same x position. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/SettingsScreen.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 10306cdf..0d977ef6 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -378,7 +378,7 @@ class SettingsScreen : public UIScreen { display.print(pb); } display.print(homePageLabel(item)); - display.setCursor(display.valCol(), y); + display.setCursor(display.width() - 6 * display.getCharWidth(), y); if (!homePageToggleable(item)) display.print("always"); else From 2b7fc37491f001e32179759b6b14315e13bb02db Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 23 May 2026 10:06:22 +0200 Subject: [PATCH 183/183] fix(eink): 30s clock/lock refresh, static auto-advert indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clock page: 60s → 30s refresh (time shown without seconds, 30s is sufficient) - Lock screen: 60s → 30s refresh - Auto-advert "A" indicator: remove blink on e-ink (always shown when active); blink preserved on OLED via EINK_DISPLAY_MODEL guard Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 2efa459c..0203f572 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -347,10 +347,15 @@ class HomeScreen : public UIScreen { } leftmostX = btX - ind_gap; - // "A" indicator — left of BT, blinks 50% duty at 4s period + // "A" indicator — left of BT; blinks on OLED, always shown on e-ink if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) { int aX = leftmostX - ind; - if ((millis() % 4000) < 2000) { +#ifdef EINK_DISPLAY_MODEL + bool show_a = true; +#else + bool show_a = (millis() % 4000) < 2000; +#endif + if (show_a) { display.setColor(DisplayDriver::LIGHT); display.fillRect(aX, 0, ind, lh); display.setColor(DisplayDriver::DARK); @@ -768,7 +773,7 @@ public: } bool auto_adv = _node_prefs && _node_prefs->advert_auto_interval_sec > 0; #ifdef EINK_DISPLAY_MODEL - if (_page == HomePage::CLOCK) return 60000; // no seconds on e-ink; content changes at most every minute + if (_page == HomePage::CLOCK) return 30000; // no seconds on e-ink; refresh every 30s return 30000; // e-ink: limit base polling; new messages still force immediate refresh via notify() #else if (_page == HomePage::CLOCK) { @@ -1484,7 +1489,7 @@ void UITask::loop() { _display->print(hint); _display->endFrame(); #ifdef EINK_DISPLAY_MODEL - _next_refresh = millis() + 60000; + _next_refresh = millis() + 30000; #else _next_refresh = millis() + 1000; #endif