refactor(ui): apply code review fixes — bugs, risk, optimisations, cleanup

Fixes 7 bugs (timing UB on unsigned long, abs() no-op, XBM CRC PROGMEM
scan, CayenneLPP heap thrash ×2, UTF-8 reply prefix, lock-seq reset on
wake), 3 risk items (notif melody stop guard, bot trigger pre-lowercase,
lock-seq cleared on display wake), plus optimisations (fmtMsgAge/
getTextWidth caching, RTTTL builder consolidated to NodePrefs, HP_*
constants and homePageLabel table moved to NodePrefs, DataStore flat
lambda schema, translateUTF8Static factored out) and minor cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-23 20:03:45 +02:00
parent 05335420e3
commit b279b95b2d
16 changed files with 198 additions and 245 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ compile_commands.json
.venv/
venv/
platformio.local.ini
CODE_REVIEW.md

View File

@@ -200,142 +200,103 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon)
}
void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& node_lat, double& node_lon) {
// Set hardware defaults before reading — if the file is older and lacks these fields,
// the compile-time values apply rather than the zero from memset in MyMesh::begin().
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
#endif
#ifdef JOYSTICK_ROTATION
_prefs.joystick_rotation = JOYSTICK_ROTATION;
#endif
File file = openRead(_fs, filename);
if (file) {
uint8_t pad[8];
if (!file) return;
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));
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));
if (file.available()) {
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));
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_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_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.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));
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));
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));
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));
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));
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));
if (file.available()) {
file.read((uint8_t *)_prefs.page_order, sizeof(_prefs.page_order));
if (file.available()) {
file.read((uint8_t *)&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation));
} else {
#ifdef JOYSTICK_ROTATION
_prefs.joystick_rotation = JOYSTICK_ROTATION;
#endif
}
}
} else {
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
#endif
#ifdef JOYSTICK_ROTATION
_prefs.joystick_rotation = JOYSTICK_ROTATION;
#endif
}
}
} else {
#ifdef DISPLAY_ROTATION
_prefs.display_rotation = DISPLAY_ROTATION;
#endif
#ifdef JOYSTICK_ROTATION
_prefs.joystick_rotation = JOYSTICK_ROTATION;
#endif
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
}
uint8_t pad[8];
file.close();
}
// Core fields — present in every saved file (written unconditionally since the beginning).
file.read((uint8_t *)&_prefs.airtime_factor, sizeof(_prefs.airtime_factor));
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));
// Extension fields — append-only, newest at the bottom.
// Each read is gated on file.available(); fields absent in older files stay at their
// zero-initialised or hardware-default values set above.
auto rd = [&](void* p, size_t n) {
if (file.available() >= (int)n) file.read((uint8_t*)p, n);
};
rd(_prefs.custom_msgs, sizeof(_prefs.custom_msgs));
rd(&_prefs.ch_notif_override, sizeof(_prefs.ch_notif_override));
rd(&_prefs.ch_notif_muted, sizeof(_prefs.ch_notif_muted));
rd(&_prefs.dm_show_all, sizeof(_prefs.dm_show_all));
rd(&_prefs.room_fav_only, sizeof(_prefs.room_fav_only));
rd(&_prefs.buzzer_volume, sizeof(_prefs.buzzer_volume));
rd(&_prefs.ringtone_bpm_idx, sizeof(_prefs.ringtone_bpm_idx));
rd(&_prefs.ringtone_len, sizeof(_prefs.ringtone_len));
if (_prefs.ringtone_len > 32) _prefs.ringtone_len = 0;
rd(_prefs.ringtone_notes, sizeof(_prefs.ringtone_notes));
rd(&_prefs.home_pages_mask, sizeof(_prefs.home_pages_mask));
rd(&_prefs.bot_enabled, sizeof(_prefs.bot_enabled));
rd(&_prefs.bot_channel_enabled, sizeof(_prefs.bot_channel_enabled));
rd(&_prefs.bot_channel_idx, sizeof(_prefs.bot_channel_idx));
rd(_prefs.bot_trigger, sizeof(_prefs.bot_trigger));
for (char* p = _prefs.bot_trigger; *p; p++) *p = (char)tolower((uint8_t)*p);
rd(_prefs.bot_reply_dm, sizeof(_prefs.bot_reply_dm));
rd(_prefs.bot_reply_ch, sizeof(_prefs.bot_reply_ch));
rd(&_prefs.clock_hide_seconds, sizeof(_prefs.clock_hide_seconds));
rd(&_prefs.buzzer_auto, sizeof(_prefs.buzzer_auto));
rd(_prefs.dm_notif, sizeof(_prefs.dm_notif));
rd(_prefs.dashboard_fields, sizeof(_prefs.dashboard_fields));
rd(&_prefs.advert_auto_interval_sec, sizeof(_prefs.advert_auto_interval_sec));
rd(&_prefs.ringtone2_bpm_idx, sizeof(_prefs.ringtone2_bpm_idx));
rd(&_prefs.ringtone2_len, sizeof(_prefs.ringtone2_len));
if (_prefs.ringtone2_len > 32) _prefs.ringtone2_len = 0;
rd(_prefs.ringtone2_notes, sizeof(_prefs.ringtone2_notes));
rd(&_prefs.notif_melody_dm, sizeof(_prefs.notif_melody_dm));
rd(&_prefs.notif_melody_ch, sizeof(_prefs.notif_melody_ch));
rd(&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set));
rd(&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2));
rd(_prefs.dm_melody, sizeof(_prefs.dm_melody));
rd(&_prefs.auto_lock, sizeof(_prefs.auto_lock));
rd(&_prefs.clock_12h, sizeof(_prefs.clock_12h));
rd(&_prefs.use_lemon_font, sizeof(_prefs.use_lemon_font));
rd(&_prefs.display_rotation, sizeof(_prefs.display_rotation));
rd(_prefs.page_order, sizeof(_prefs.page_order));
rd(&_prefs.joystick_rotation, sizeof(_prefs.joystick_rotation));
file.close();
}
void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) {

View File

@@ -8,12 +8,12 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) {
millis() - _bot_last_dm_reply_ms > 10000UL))
return;
const char* tr = _prefs.bot_trigger;
const char* tr = _prefs.bot_trigger; // already lowercase (normalised at save/load time)
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++;
while (i < tlen && tolower((uint8_t)t[i]) == (uint8_t)tr[i]) i++;
if (i == tlen) matched = true;
}
if (!matched) return;
@@ -45,12 +45,12 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) {
const char* sep = strstr(text, ": ");
if (sep) msg = sep + 2;
const char* tr = _prefs.bot_trigger;
const char* tr = _prefs.bot_trigger; // already lowercase (normalised at save/load time)
int tlen = strlen(tr);
bool matched = false;
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++;
while (i < tlen && tolower((uint8_t)t[i]) == (uint8_t)tr[i]) i++;
if (i == tlen) matched = true;
}
if (!matched) return;

View File

@@ -1,5 +1,6 @@
#pragma once
#include <cstdint> // For uint8_t, uint32_t
#include <cstdint>
#include <stdio.h>
#define TELEM_MODE_DENY 0
#define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags
@@ -84,4 +85,44 @@ struct NodePrefs { // persisted to file
// 0 = end of list (also uninitialized legacy). hasCustomOrder iff page_order[0] in 1..11.
uint8_t page_order[11];
uint8_t joystick_rotation; // 0-3 steps CW; independent of display_rotation
// Bitmasks for home_pages_mask (bit=1 → page visible; 0 field = all visible).
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;
// Label for home page by bit-index (010); returns "" for out-of-range.
static const char* homePageLabel(uint8_t bit) {
static const char* labels[] = {
"Clock", "Recent", "Radio", "Bluetooth", "Advert",
"GPS", "Sensors", "Tools", "Shutdown", "Settings", "Messages"
};
return (bit < 11) ? labels[bit] : "";
}
static void buildRTTTLString(const uint8_t* notes, uint8_t len, uint8_t bpm_idx,
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' };
if (len == 0) { buf[0] = '\0'; return; }
uint16_t bpm = BPM_OPTS[bpm_idx < 5 ? bpm_idx : 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';
}
};

View File

@@ -117,6 +117,7 @@ public:
if (_kb_field == 2) {
strncpy(_prefs->bot_trigger, _kb.buf, sizeof(_prefs->bot_trigger) - 1);
_prefs->bot_trigger[sizeof(_prefs->bot_trigger) - 1] = '\0';
for (char* p = _prefs->bot_trigger; *p; p++) *p = (char)tolower((uint8_t)*p);
} 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';
@@ -184,7 +185,7 @@ public:
}
_kb.begin(initial, max);
if (_sel == 2) {
_kb._ph_count = 0; // trigger is literal text — placeholders never match incoming msgs
_kb.clearPlaceholders(); // trigger is literal text — placeholders never match incoming msgs
} else {
kbAddSensorPlaceholders(_kb, &sensors);
}

View File

@@ -46,6 +46,8 @@ struct KeyboardWidget {
addPlaceholder("{time}");
}
void clearPlaceholders() { _ph_count = 0; }
void addPlaceholder(const char* ph) {
if (_ph_count < KB_PH_MAX) {
strncpy(_ph_buf[_ph_count], ph, KB_PH_LEN - 1);

View File

@@ -125,8 +125,7 @@ class QuickMsgScreen : public UIScreen {
}
static void fmtMsgAge(char* buf, int n, uint32_t timestamp) {
uint32_t now = rtc_clock.getCurrentTime();
static void fmtMsgAge(char* buf, int n, uint32_t timestamp, uint32_t now) {
if (timestamp == 0 || now < timestamp) { buf[0] = '\0'; return; }
uint32_t age = now - timestamp;
if (age < 60) snprintf(buf, n, "%us", age);
@@ -645,6 +644,7 @@ public:
display.fillRect(0, lh + 1, display.width(), display.sepH());
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
uint32_t now_ts = rtc_clock.getCurrentTime();
for (int i = 0; i < _hist_visible && (_dm_hist_scroll + i) < dm_count; i++) {
int item = _dm_hist_scroll + i;
@@ -657,7 +657,7 @@ 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);
char age[6]; fmtMsgAge(age, sizeof(age), e.timestamp, now_ts);
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
if (sel) {
@@ -748,6 +748,7 @@ public:
display.fillRect(0, lh + 1, display.width(), display.sepH());
int ch_hist_count = histCountForChannel(_sel_channel_idx);
uint32_t now_ts = rtc_clock.getCurrentTime();
for (int i = 0; i < _hist_visible && (_hist_scroll + i) < ch_hist_count; i++) {
int item = _hist_scroll + i;
@@ -771,7 +772,7 @@ public:
msg_part[sizeof(msg_part) - 1] = '\0';
}
char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp);
char age[6]; fmtMsgAge(age, sizeof(age), _hist[ring_pos].timestamp, now_ts);
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
if (sel) {
@@ -1048,7 +1049,8 @@ 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), "@[%.31s] ", _sel_contact.name);
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}
@@ -1096,7 +1098,8 @@ 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), "@[%.31s] ", _sel_contact.name);
char _tname[32]; DisplayDriver::translateUTF8Static(_tname, _sel_contact.name, sizeof(_tname));
snprintf(_reply_prefix, sizeof(_reply_prefix), "@[%.31s] ", _tname);
_ctx_menu.begin("Options", 1);
_ctx_menu.addItem("Reply");
}

View File

@@ -47,19 +47,7 @@ class RingtoneEditorScreen : public UIScreen {
}
void buildRTTTL() {
uint16_t bpm = BPM_OPTS[_bpm_idx < 5 ? _bpm_idx : 2];
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(_play_buf + pos, sizeof(_play_buf) - pos, "%dp", dur_val);
else
pos += snprintf(_play_buf + pos, sizeof(_play_buf) - pos, "%d%c%d", dur_val, PITCH_NAMES[pitch], octave);
}
if (pos < (int)sizeof(_play_buf)) _play_buf[pos] = '\0';
NodePrefs::buildRTTTLString(_notes, _len, _bpm_idx, _play_buf, sizeof(_play_buf));
}
void previewNote(uint8_t note_byte) {

View File

@@ -168,47 +168,20 @@ class SettingsScreen : public UIScreen {
}
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;
// HOME_SETTINGS and HOME_QUICK_MSG have no mask bit — always visible
#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;
int bit = homePageBitIndex(item);
return (bit >= 0 && bit < 9) ? (uint16_t)(1 << bit) : 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_SETTINGS) return "Settings";
if (item == HOME_QUICK_MSG) return "Messages";
#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 "";
int bit = homePageBitIndex(item);
return NodePrefs::homePageLabel((uint8_t)(bit >= 0 ? bit : 11));
}
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;
uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : NodePrefs::HP_ALL;
return (mask & bit) != 0;
}
@@ -600,7 +573,7 @@ public:
return true;
}
if (enter && homePageToggleable(_selected)) {
if (!p->home_pages_mask) p->home_pages_mask = HP_ALL;
if (!p->home_pages_mask) p->home_pages_mask = NodePrefs::HP_ALL;
p->home_pages_mask ^= homePageBit(_selected);
_dirty = true;
return true;

View File

@@ -111,18 +111,6 @@ 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;
#include "KeyboardWidget.h"
#include "FullscreenMsgView.h"
@@ -216,7 +204,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) ? _node_prefs->home_pages_mask : HP_ALL;
uint16_t mask = (_node_prefs && _node_prefs->home_pages_mask) ? _node_prefs->home_pages_mask : NodePrefs::HP_ALL;
return (mask >> bit) & 1;
}
@@ -698,7 +686,6 @@ public:
// 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) {
@@ -721,12 +708,10 @@ public:
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" },
@@ -1015,24 +1000,10 @@ void UITask::showAlert(const char* text, int 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';
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;
NodePrefs::buildRTTTLString(notes, len, bpm_i, buf, size);
}
void UITask::notify(UIEventType t) {
@@ -1066,6 +1037,7 @@ switch(t){
}
bool custom_played = false;
if (slot > 0 && _node_prefs) {
if (buzzer.isPlaying()) buzzer.stop(); // stop before overwriting _notif_mel_buf
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);
@@ -1101,6 +1073,7 @@ switch(t){
}
bool custom_played = false;
if (slot > 0 && _node_prefs) {
if (buzzer.isPlaying()) buzzer.stop(); // stop before overwriting _notif_mel_buf
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);
@@ -1181,7 +1154,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
void UITask::userLedHandler() {
#ifdef PIN_STATUS_LED
int cur_time = millis();
unsigned long cur_time = millis();
if (cur_time > next_led_change) {
if (led_state == 0) {
led_state = 1;
@@ -1276,8 +1249,7 @@ 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 local_lpp(200);
if (!lpp) { local_lpp.reset(); sensors.querySensors(0xFF, local_lpp); lpp = &local_lpp; }
if (!lpp) { static CayenneLPP s_lpp(200); s_lpp.reset(); sensors.querySensors(0xFF, s_lpp); lpp = &s_lpp; }
LPPReader r(lpp->getBuffer(), lpp->getSize());
uint8_t ch, type;
while (r.readHeader(ch, type)) {
@@ -1383,7 +1355,7 @@ void UITask::loop() {
}
#endif
#if defined(PIN_USER_BTN_ANA)
if (abs(millis() - _analogue_pin_read_millis) > 10) {
if (millis() - _analogue_pin_read_millis > 10) {
ev = analog_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
c = checkDisplayOn(KEY_NEXT);
@@ -1474,14 +1446,13 @@ void UITask::loop() {
// Two sensor values side by side (dashboard_fields[0] and [1])
if (_node_prefs) {
char v0[20] = "", v1[20] = "";
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;
_dash_lpp.reset(); sensors.querySensors(0xFF, _dash_lpp); lpp_ptr = &_dash_lpp;
}
formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr);
formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr);
@@ -1597,6 +1568,8 @@ char UITask::checkDisplayOn(char c) {
_next_refresh = 0;
return 0; // eat the waking key press
}
_lock_seq_count = 0;
_lock_seq_used = false;
c = 0;
}
if (!_locked) {
@@ -1627,8 +1600,7 @@ char UITask::handleTripleClick(char c) {
MESH_DEBUG_PRINTLN("UITask: triple click triggered");
checkDisplayOn(c);
toggleBuzzer();
c = 0;
return c;
return 0;
}
bool UITask::getGPSState() {

View File

@@ -51,11 +51,11 @@ class UITask : public AbstractUITask {
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;
unsigned long next_backlight_btn_check = 0;
#ifdef PIN_STATUS_LED
int led_state = 0;
int next_led_change = 0;
int last_led_increment = 0;
unsigned long next_led_change = 0;
unsigned long last_led_increment = 0;
#endif
#ifdef PIN_USER_BTN_ANA
@@ -73,6 +73,7 @@ class UITask : public AbstractUITask {
UIScreen* dashboard_config;
UIScreen* auto_advert_screen;
UIScreen* curr;
CayenneLPP _dash_lpp;
void userLedHandler();
@@ -86,7 +87,7 @@ class UITask : public AbstractUITask {
public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL) {
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : AbstractUITask(board, serial), _display(NULL), _sensors(NULL), _node_prefs(NULL), _dash_lpp(200) {
next_batt_chck = _next_refresh = 0;
ui_started_at = 0;
_batt_mv = 0;

View File

@@ -6,6 +6,8 @@
class DisplayDriver {
int _w, _h;
protected:
bool _vw_dirty = true;
bool _vw_result = false;
DisplayDriver(int w, int h) { _w = w; _h = h; }
void setDimensions(int w, int h) { _w = w; _h = h; }
public:
@@ -151,8 +153,8 @@ public:
}
}
// convert UTF-8 to ASCII, transliterating accented/diacritic characters
virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) {
// convert UTF-8 to ASCII, transliterating accented/diacritic characters (callable without a display instance)
static void translateUTF8Static(char* dest, const char* src, size_t dest_size) {
size_t j = 0;
const uint8_t* p = (const uint8_t*)src;
while (*p && j < dest_size - 1) {
@@ -178,6 +180,10 @@ public:
}
dest[j] = 0;
}
virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) {
translateUTF8Static(dest, src, dest_size);
}
// draw text with ellipsis if it exceeds max_width
virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) {
@@ -194,9 +200,11 @@ public:
// for fixed-width fonts (OLED), keep tight spacing to save precious characters
const char* ellipsis;
// use a simple heuristic: if 'i' and 'l' have different widths, it's variable-width
int i_width = getTextWidth("i");
int l_width = getTextWidth("l");
if (i_width != l_width) {
if (_vw_dirty) {
_vw_result = (getTextWidth("i") != getTextWidth("l"));
_vw_dirty = false;
}
if (_vw_result) {
ellipsis = "... "; // variable-width fonts: add space
} else {
ellipsis = "..."; // fixed-width fonts: no space

View File

@@ -80,6 +80,7 @@ void GxEPDDisplay::startFrame(Color bkg) {
void GxEPDDisplay::setTextSize(int sz) {
_text_sz = sz;
_vw_dirty = true;
display_crc.update<int>(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.
@@ -163,7 +164,7 @@ void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
display_crc.update<int>(y);
display_crc.update<int>(w);
display_crc.update<int>(h);
display_crc.update<uint8_t>(bits, w * h / 8);
display_crc.update<uintptr_t>((uintptr_t)bits);
uint16_t widthInBytes = (w + 7) / 8;
for (uint16_t by = 0; by < h; by++) {
for (uint16_t bx = 0; bx < w; bx++) {

View File

@@ -82,7 +82,7 @@ public:
if (_text_sz == 2) return 16 * sc;
return (_use_lemon ? 10 : 8) * sc;
}
void setLemonFont(bool enabled) override { _use_lemon = enabled; }
void setLemonFont(bool enabled) override { _use_lemon = enabled; _vw_dirty = true; }
bool isLemonFont() const override { return _use_lemon; }
bool begin();

View File

@@ -50,6 +50,7 @@ void SH1106Display::startFrame(Color bkg)
void SH1106Display::setTextSize(int sz)
{
_text_sz = sz;
_vw_dirty = true;
display.setTextSize(sz);
}

View File

@@ -50,7 +50,7 @@ public:
uint16_t getTextWidth(const char *str) override;
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 setLemonFont(bool enabled) override { _use_lemon = enabled; _vw_dirty = true; }
bool isLemonFont() const override { return _use_lemon; }
void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) override;
void setBrightness(uint8_t level) override;