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] 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;