mirror of
https://github.com/pelgraine/Meck.git
synced 2026-05-12 20:35:52 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e589b3eb9 | |||
| b0ad1c4901 | |||
| ceb29ba662 | |||
| 18b9ab6c4d |
@@ -289,6 +289,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
if (file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)) != sizeof(_prefs.default_scope_key)) {
|
if (file.read((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)) != sizeof(_prefs.default_scope_key)) {
|
||||||
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key));
|
||||||
}
|
}
|
||||||
|
if (file.read((uint8_t *)_prefs.channel_notif, sizeof(_prefs.channel_notif)) != sizeof(_prefs.channel_notif)) {
|
||||||
|
memset(_prefs.channel_notif, 0, sizeof(_prefs.channel_notif)); // default: NOTIF_ALL
|
||||||
|
}
|
||||||
|
|
||||||
// Clamp to valid ranges
|
// Clamp to valid ranges
|
||||||
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
if (_prefs.dark_mode > 1) _prefs.dark_mode = 0;
|
||||||
@@ -298,6 +301,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no
|
|||||||
if (_prefs.ui_font_style > 2) _prefs.ui_font_style = 0;
|
if (_prefs.ui_font_style > 2) _prefs.ui_font_style = 0;
|
||||||
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
|
if (_prefs.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_threshold = 3;
|
||||||
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
|
if (_prefs.rx_fail_reboot_threshold > 10) _prefs.rx_fail_reboot_threshold = 3;
|
||||||
|
// Clamp channel notification preferences to valid range
|
||||||
|
for (int i = 0; i < (int)sizeof(_prefs.channel_notif); i++) {
|
||||||
|
if (_prefs.channel_notif[i] > 2) _prefs.channel_notif[i] = 0;
|
||||||
|
}
|
||||||
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
|
// auto_lock_minutes: only accept known options (0, 2, 5, 10, 15, 30)
|
||||||
{
|
{
|
||||||
uint8_t alm = _prefs.auto_lock_minutes;
|
uint8_t alm = _prefs.auto_lock_minutes;
|
||||||
@@ -357,6 +364,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_
|
|||||||
file.write((uint8_t *)&_prefs.ui_font_style, sizeof(_prefs.ui_font_style)); // 105
|
file.write((uint8_t *)&_prefs.ui_font_style, sizeof(_prefs.ui_font_style)); // 105
|
||||||
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 106
|
file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 106
|
||||||
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 137
|
file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 137
|
||||||
|
file.write((uint8_t *)_prefs.channel_notif, sizeof(_prefs.channel_notif)); // 153
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
#define FIRMWARE_VER_CODE 11
|
#define FIRMWARE_VER_CODE 11
|
||||||
|
|
||||||
#ifndef FIRMWARE_BUILD_DATE
|
#ifndef FIRMWARE_BUILD_DATE
|
||||||
#define FIRMWARE_BUILD_DATE "7 May 2026"
|
#define FIRMWARE_BUILD_DATE "12 May 2026"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifndef FIRMWARE_VERSION
|
#ifndef FIRMWARE_VERSION
|
||||||
#define FIRMWARE_VERSION "Meck v1.9"
|
#define FIRMWARE_VERSION "Meck v1.10"
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
#define ADVERT_LOC_NONE 0
|
#define ADVERT_LOC_NONE 0
|
||||||
#define ADVERT_LOC_SHARE 1
|
#define ADVERT_LOC_SHARE 1
|
||||||
|
|
||||||
|
// Per-channel notification preferences (stored in channel_notif[])
|
||||||
|
#define NOTIF_ALL 0 // Notify on all messages (default)
|
||||||
|
#define NOTIF_MENTIONS 1 // Notify only when @nodename appears in message
|
||||||
|
#define NOTIF_NONE 2 // No notifications (muted)
|
||||||
|
|
||||||
struct NodePrefs { // persisted to file
|
struct NodePrefs { // persisted to file
|
||||||
float airtime_factor;
|
float airtime_factor;
|
||||||
char node_name[32];
|
char node_name[32];
|
||||||
@@ -53,6 +58,12 @@ struct NodePrefs { // persisted to file
|
|||||||
char default_scope_name[31]; // e.g. "au-nsw", empty = unscoped
|
char default_scope_name[31]; // e.g. "au-nsw", empty = unscoped
|
||||||
uint8_t default_scope_key[16]; // TransportKey derived from "#" + name
|
uint8_t default_scope_key[16]; // TransportKey derived from "#" + name
|
||||||
|
|
||||||
|
// --- Per-channel notification preferences ---
|
||||||
|
// Index 0..MAX_GROUP_CHANNELS-1 for group channels, index MAX_GROUP_CHANNELS for DMs.
|
||||||
|
// Values: NOTIF_ALL (0), NOTIF_MENTIONS (1), NOTIF_NONE (2).
|
||||||
|
// Defaults to NOTIF_ALL for all channels.
|
||||||
|
uint8_t channel_notif[21]; // 20 group channels + 1 DM slot
|
||||||
|
|
||||||
// --- Font helpers (inline, no overhead) ---
|
// --- Font helpers (inline, no overhead) ---
|
||||||
// Returns the DisplayDriver text-size index for "small/body" text.
|
// Returns the DisplayDriver text-size index for "small/body" text.
|
||||||
// T-Deck Pro: 0 = built-in 6×8 (or 7pt with custom fonts), 1 = 9pt.
|
// T-Deck Pro: 0 = built-in 6×8 (or 7pt with custom fonts), 1 = 9pt.
|
||||||
|
|||||||
@@ -84,6 +84,9 @@
|
|||||||
#endif
|
#endif
|
||||||
#ifdef MECK_AUDIO_VARIANT
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
#include "VoiceMessageScreen.h"
|
#include "VoiceMessageScreen.h"
|
||||||
|
#include "BundledSounds.h"
|
||||||
|
#include "NotifSounds.h"
|
||||||
|
NotifSounds notifSounds; // Global singleton for per-channel notification tones
|
||||||
#endif
|
#endif
|
||||||
static bool audiobookMode = false;
|
static bool audiobookMode = false;
|
||||||
static bool voiceMode = false;
|
static bool voiceMode = false;
|
||||||
@@ -1751,6 +1754,11 @@ static void lastHeardToggleContact() {
|
|||||||
return KEY_ENTER; // Not editing: toggle/edit selected row
|
return KEY_ENTER; // Not editing: toggle/edit selected row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel picker: long press = delete message history for highlighted channel
|
||||||
|
if (ui_task.isOnChannelPickerScreen()) {
|
||||||
|
return 'x';
|
||||||
|
}
|
||||||
|
|
||||||
// Default: enter/select
|
// Default: enter/select
|
||||||
return KEY_ENTER;
|
return KEY_ENTER;
|
||||||
}
|
}
|
||||||
@@ -2029,6 +2037,15 @@ void setup() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Copy bundled notification sounds to SD card (audio variant only).
|
||||||
|
// Skips files that already exist so user customisations are preserved.
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
if (sdCardReady) {
|
||||||
|
copyBundledSoundsToSD();
|
||||||
|
notifSounds.begin();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
|
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
|
||||||
store.begin();
|
store.begin();
|
||||||
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
|
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
|
||||||
@@ -2658,6 +2675,137 @@ void loop() {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Notification tone: play custom MP3 for per-channel notification sounds.
|
||||||
|
// Polls the notifSounds pending request, lazy-inits Audio*, plays the file,
|
||||||
|
// and disables DAC when playback finishes.
|
||||||
|
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
||||||
|
{
|
||||||
|
static bool notifTonePlaying = false;
|
||||||
|
static bool notifFadingOut = false;
|
||||||
|
static bool notifMuted = false;
|
||||||
|
static uint32_t notifStartMs = 0;
|
||||||
|
static uint32_t notifDurationMs = 0;
|
||||||
|
|
||||||
|
// Service audio decode while notification tone is playing
|
||||||
|
if (notifTonePlaying && audio) {
|
||||||
|
audio->loop();
|
||||||
|
|
||||||
|
// Fade-out: ramp volume down near the end of the file.
|
||||||
|
// Window scales with duration -- 15% of estimated length, capped at 400ms.
|
||||||
|
// Prevents short tones from losing too much audio to the fade.
|
||||||
|
if (!notifFadingOut && notifDurationMs > 0) {
|
||||||
|
uint32_t elapsed = millis() - notifStartMs;
|
||||||
|
uint32_t fadeWindow = notifDurationMs * 15 / 100; // 15% of duration
|
||||||
|
if (fadeWindow > 400) fadeWindow = 400;
|
||||||
|
if (fadeWindow < 80) fadeWindow = 80;
|
||||||
|
uint32_t fadePoint = notifDurationMs - fadeWindow;
|
||||||
|
if (elapsed >= fadePoint) {
|
||||||
|
notifFadingOut = true;
|
||||||
|
audio->setVolume(3);
|
||||||
|
Serial.printf("NotifTone: Fade at %lums (window %lums)\n", elapsed, fadeWindow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mute completely at 40% through the fade window
|
||||||
|
if (notifFadingOut && !notifMuted && notifDurationMs > 0) {
|
||||||
|
uint32_t elapsed = millis() - notifStartMs;
|
||||||
|
uint32_t muteWindow = notifDurationMs * 6 / 100; // 6% of duration
|
||||||
|
if (muteWindow > 150) muteWindow = 150;
|
||||||
|
if (muteWindow < 30) muteWindow = 30;
|
||||||
|
uint32_t mutePoint = notifDurationMs - muteWindow;
|
||||||
|
if (elapsed >= mutePoint) {
|
||||||
|
notifMuted = true;
|
||||||
|
audio->setVolume(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audio->isRunning()) {
|
||||||
|
audio->setVolume(0);
|
||||||
|
audio->stopSong();
|
||||||
|
delay(50);
|
||||||
|
digitalWrite(41, LOW);
|
||||||
|
notifTonePlaying = false;
|
||||||
|
notifFadingOut = false;
|
||||||
|
notifMuted = false;
|
||||||
|
notifDurationMs = 0;
|
||||||
|
Serial.println("NotifTone: Playback finished");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for new play request
|
||||||
|
if (notifSounds.hasPendingPlay()) {
|
||||||
|
const char* file = notifSounds.getPendingFile();
|
||||||
|
|
||||||
|
// Don't interrupt alarm or active audiobook
|
||||||
|
AlarmScreen* alarmScr = (AlarmScreen*)ui_task.getAlarmScreen();
|
||||||
|
AudiobookPlayerScreen* abPlayer =
|
||||||
|
(AudiobookPlayerScreen*)ui_task.getAudiobookScreen();
|
||||||
|
bool audioBusy = (alarmScr && alarmScr->isRinging()) ||
|
||||||
|
(alarmScr && alarmScr->isAlarmAudioActive()) ||
|
||||||
|
(abPlayer && abPlayer->isAudioActive());
|
||||||
|
|
||||||
|
if (!audioBusy && file[0] != '\0') {
|
||||||
|
// Lazy-init Audio object
|
||||||
|
if (!audio) audio = new Audio();
|
||||||
|
|
||||||
|
// Read file size to estimate duration for fade-out timing.
|
||||||
|
// Estimate assumes 256kbps CBR: durationMs = fileBytes / 32.
|
||||||
|
// Slightly overestimates for lower bitrates (fade triggers early
|
||||||
|
// rather than late — better than hiss at end).
|
||||||
|
uint32_t estimatedMs = 0;
|
||||||
|
{
|
||||||
|
File f = SD.open(file, "r");
|
||||||
|
if (f) {
|
||||||
|
uint32_t sz = f.size();
|
||||||
|
estimatedMs = sz / 32; // 256kbps: bytes * 8 / 256000 * 1000
|
||||||
|
Serial.printf("NotifTone: File %lu bytes, est %lums\n", sz, estimatedMs);
|
||||||
|
f.close();
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop any stale playback before touching DAC
|
||||||
|
audio->stopSong();
|
||||||
|
|
||||||
|
// Configure I2S pins first (before DAC power)
|
||||||
|
bool ok = audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT, 0);
|
||||||
|
if (!ok) ok = audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT);
|
||||||
|
|
||||||
|
// Start playback at volume 0 (silence into DAC during power-on)
|
||||||
|
audio->setVolume(0);
|
||||||
|
audio->connecttoFS(SD, file);
|
||||||
|
|
||||||
|
// Enable DAC amplifier and let it stabilise with silence flowing
|
||||||
|
pinMode(41, OUTPUT);
|
||||||
|
digitalWrite(41, HIGH);
|
||||||
|
delay(80);
|
||||||
|
|
||||||
|
// Fill audio buffer with silence at volume 0 (prevents power-on pop)
|
||||||
|
for (int i = 0; i < 30; i++) {
|
||||||
|
audio->loop();
|
||||||
|
delay(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ramp volume up to max
|
||||||
|
audio->setVolume(21);
|
||||||
|
notifTonePlaying = true;
|
||||||
|
notifFadingOut = false;
|
||||||
|
notifMuted = false;
|
||||||
|
notifStartMs = millis();
|
||||||
|
// Subtract ~140ms of pre-decode (80ms DAC warmup + 60ms buffer fill)
|
||||||
|
// that elapsed between connecttoFS and now — the file is already
|
||||||
|
// that far into decoding when we start timing.
|
||||||
|
notifDurationMs = (estimatedMs > 140) ? (estimatedMs - 140) : estimatedMs;
|
||||||
|
|
||||||
|
Serial.printf("NotifTone: Playing '%s'\n", file);
|
||||||
|
} else if (audioBusy) {
|
||||||
|
Serial.println("NotifTone: Skipped -- audio busy");
|
||||||
|
}
|
||||||
|
notifSounds.clearPending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Voice message: service mic DMA capture + playback audio decode
|
// Voice message: service mic DMA capture + playback audio decode
|
||||||
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -148,8 +148,10 @@ public:
|
|||||||
|
|
||||||
// Add a new message to the history
|
// Add a new message to the history
|
||||||
// peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent)
|
// peer_name: for DMs, the contact this message belongs to (sender for received, recipient for sent)
|
||||||
|
// suppressUnread: if true, do not increment the unread counter for this message
|
||||||
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
void addMessage(uint8_t channel_idx, uint8_t path_len, const char* sender, const char* text,
|
||||||
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr) {
|
const uint8_t* path_bytes = nullptr, int8_t snr = 0, const char* peer_name = nullptr,
|
||||||
|
bool suppressUnread = false) {
|
||||||
// Move to next slot in circular buffer
|
// Move to next slot in circular buffer
|
||||||
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
|
||||||
|
|
||||||
@@ -191,8 +193,9 @@ public:
|
|||||||
_replySelectPos = -1;
|
_replySelectPos = -1;
|
||||||
|
|
||||||
// Track unread count for this channel (only for received messages, not sent)
|
// Track unread count for this channel (only for received messages, not sent)
|
||||||
// path_len == 0 means locally sent
|
// path_len == 0 means locally sent.
|
||||||
if (path_len != 0) {
|
// suppressUnread: per-channel notification preference says not to count this message.
|
||||||
|
if (path_len != 0 && !suppressUnread) {
|
||||||
int unreadSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
|
int unreadSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : channel_idx;
|
||||||
if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) {
|
if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) {
|
||||||
_unread[unreadSlot]++;
|
_unread[unreadSlot]++;
|
||||||
@@ -397,6 +400,42 @@ public:
|
|||||||
return pos;
|
return pos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Per-channel history deletion
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Clear all stored messages for a specific channel (or all DMs if 0xFF).
|
||||||
|
// Invalidates matching slots in the circular buffer and persists to SD.
|
||||||
|
// Does NOT alter _newestIdx -- gaps are naturally overwritten as new
|
||||||
|
// messages arrive.
|
||||||
|
int clearHistoryForChannel(uint8_t channel_idx) {
|
||||||
|
int cleared = 0;
|
||||||
|
for (int i = 0; i < CHANNEL_MSG_HISTORY_SIZE; i++) {
|
||||||
|
if (_messages[i].valid && _messages[i].channel_idx == channel_idx) {
|
||||||
|
_messages[i].valid = false;
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleared > 0) {
|
||||||
|
// Reset unread counter for the cleared channel
|
||||||
|
markChannelRead(channel_idx);
|
||||||
|
// Reset scroll if we're viewing the cleared channel
|
||||||
|
if (_viewChannelIdx == channel_idx) {
|
||||||
|
_scrollPos = 0;
|
||||||
|
}
|
||||||
|
// Reset DM inbox state if clearing DMs
|
||||||
|
if (channel_idx == 0xFF) {
|
||||||
|
_dmInboxScroll = 0;
|
||||||
|
_dmFilterName[0] = '\0';
|
||||||
|
_dmInboxMode = true;
|
||||||
|
}
|
||||||
|
saveToSD();
|
||||||
|
Serial.printf("ChannelScreen: Cleared %d messages for channel %d\n",
|
||||||
|
cleared, (int)channel_idx);
|
||||||
|
}
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// SD card persistence
|
// SD card persistence
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ extern MyMesh the_mesh;
|
|||||||
// T-Deck Pro / MAX : vertical list with "> " cursor, unread badge, right-
|
// T-Deck Pro / MAX : vertical list with "> " cursor, unread badge, right-
|
||||||
// aligned. Same highlight/tap convention as Contacts.
|
// aligned. Same highlight/tap convention as Contacts.
|
||||||
//
|
//
|
||||||
// Navigation signals use a wantsExit() flag (same pattern as PathEditor) —
|
// Delete history:
|
||||||
|
// Press X on a highlighted channel to enter delete confirmation mode.
|
||||||
|
// Confirmation overlay asks the user to press Enter to confirm or Q to
|
||||||
|
// cancel. On confirm, all messages for that channel are invalidated in
|
||||||
|
// the circular buffer and persisted to SD.
|
||||||
|
//
|
||||||
|
// Navigation signals use a wantsExit() flag (same pattern as PathEditor) --
|
||||||
// UITask is only forward-declared, so the picker cannot call UITask methods
|
// UITask is only forward-declared, so the picker cannot call UITask methods
|
||||||
// directly. main.cpp / UITask.cpp check the flag after injectKey().
|
// directly. main.cpp / UITask.cpp check the flag after injectKey().
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -50,12 +56,15 @@ class ChannelPickerScreen : public UIScreen {
|
|||||||
int _cursor;
|
int _cursor;
|
||||||
int _scrollTop; // Scroll offset (T-Deck Pro list only)
|
int _scrollTop; // Scroll offset (T-Deck Pro list only)
|
||||||
|
|
||||||
// Grid layout cache (T5S3) — set in render(), consumed by touch hit test
|
// Grid layout cache (T5S3) -- set in render(), consumed by touch hit test
|
||||||
int _cellW;
|
int _cellW;
|
||||||
int _cellH;
|
int _cellH;
|
||||||
int _gridTop;
|
int _gridTop;
|
||||||
int _gridCols;
|
int _gridCols;
|
||||||
|
|
||||||
|
// Delete confirmation sub-menu
|
||||||
|
bool _confirmDelete; // True when showing "Delete history?" overlay
|
||||||
|
|
||||||
// Rebuild the items list from MyMesh. O(20), safe every render.
|
// Rebuild the items list from MyMesh. O(20), safe every render.
|
||||||
void rebuildItems() {
|
void rebuildItems() {
|
||||||
int n = 0;
|
int n = 0;
|
||||||
@@ -100,13 +109,14 @@ public:
|
|||||||
: _task(task), _channelScreen(nullptr),
|
: _task(task), _channelScreen(nullptr),
|
||||||
_itemCount(0), _cursor(0), _scrollTop(0),
|
_itemCount(0), _cursor(0), _scrollTop(0),
|
||||||
_cellW(40), _cellH(12), _gridTop(14), _gridCols(3),
|
_cellW(40), _cellH(12), _gridTop(14), _gridCols(3),
|
||||||
|
_confirmDelete(false),
|
||||||
_wantExit(false) {
|
_wantExit(false) {
|
||||||
_items[0] = 0xFF;
|
_items[0] = 0xFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setChannelScreen(ChannelScreen* cs) { _channelScreen = cs; }
|
void setChannelScreen(ChannelScreen* cs) { _channelScreen = cs; }
|
||||||
|
|
||||||
// --- wantsExit flag — checked by main.cpp / UITask after injectKey() ---
|
// --- wantsExit flag -- checked by main.cpp / UITask after injectKey() ---
|
||||||
bool _wantExit;
|
bool _wantExit;
|
||||||
bool wantsExit() const { return _wantExit; }
|
bool wantsExit() const { return _wantExit; }
|
||||||
|
|
||||||
@@ -118,6 +128,7 @@ public:
|
|||||||
if (_items[i] == currentChannelIdx) { _cursor = i; break; }
|
if (_items[i] == currentChannelIdx) { _cursor = i; break; }
|
||||||
}
|
}
|
||||||
_scrollTop = 0;
|
_scrollTop = 0;
|
||||||
|
_confirmDelete = false;
|
||||||
_wantExit = false;
|
_wantExit = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +182,7 @@ public:
|
|||||||
_cellW = bubbleW;
|
_cellW = bubbleW;
|
||||||
_cellH = bubbleH + gap;
|
_cellH = bubbleH + gap;
|
||||||
_gridTop = headerH;
|
_gridTop = headerH;
|
||||||
_gridCols = 1; // Single column — list mode
|
_gridCols = 1; // Single column -- list mode
|
||||||
|
|
||||||
// Centre scroll window on cursor
|
// Centre scroll window on cursor
|
||||||
_scrollTop = max(0, min(_cursor - maxVisible / 2, _itemCount - maxVisible));
|
_scrollTop = max(0, min(_cursor - maxVisible / 2, _itemCount - maxVisible));
|
||||||
@@ -200,7 +211,7 @@ public:
|
|||||||
display.drawRect(x + 1, y + 1, w - 2, h - 2);
|
display.drawRect(x + 1, y + 1, w - 2, h - 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel name — left-aligned with inner padding
|
// Channel name -- left-aligned with inner padding
|
||||||
char name[32];
|
char name[32];
|
||||||
getItemName(i, name, sizeof(name));
|
getItemName(i, name, sizeof(name));
|
||||||
char filtered[32];
|
char filtered[32];
|
||||||
@@ -229,7 +240,7 @@ public:
|
|||||||
display.drawTextEllipsized(textX, textY, nameMaxW, filtered);
|
display.drawTextEllipsized(textX, textY, nameMaxW, filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unread badge — right-aligned inside bubble
|
// Unread badge -- right-aligned inside bubble
|
||||||
if (unread > 0) {
|
if (unread > 0) {
|
||||||
int bx = x + w - badgeW;
|
int bx = x + w - badgeW;
|
||||||
display.setCursor(bx, textY);
|
display.setCursor(bx, textY);
|
||||||
@@ -329,6 +340,47 @@ public:
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Delete confirmation overlay
|
||||||
|
// Drawn on top of the list when _confirmDelete is active.
|
||||||
|
// =================================================================
|
||||||
|
if (_confirmDelete) {
|
||||||
|
// Clear a centred box and draw a border
|
||||||
|
int boxW = display.width() - 16;
|
||||||
|
int boxH = 42;
|
||||||
|
int boxX = 8;
|
||||||
|
int boxY = (display.height() - boxH) / 2;
|
||||||
|
|
||||||
|
// Clear the box area
|
||||||
|
display.setColor(DisplayDriver::DARK);
|
||||||
|
display.fillRect(boxX, boxY, boxW, boxH);
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.drawRect(boxX, boxY, boxW, boxH);
|
||||||
|
display.drawRect(boxX + 1, boxY + 1, boxW - 2, boxH - 2);
|
||||||
|
|
||||||
|
// Channel name
|
||||||
|
display.setTextSize(1);
|
||||||
|
char name[32];
|
||||||
|
getItemName(_cursor, name, sizeof(name));
|
||||||
|
char filtered[32];
|
||||||
|
display.translateUTF8ToBlocks(filtered, name, sizeof(filtered));
|
||||||
|
|
||||||
|
display.setColor(DisplayDriver::GREEN);
|
||||||
|
display.drawTextEllipsized(boxX + 4, boxY + 5, boxW - 8, filtered);
|
||||||
|
|
||||||
|
// "Delete history?" prompt
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
const char* prompt = "Delete message history?";
|
||||||
|
display.setCursor(boxX + 4, boxY + 17);
|
||||||
|
display.print(prompt);
|
||||||
|
|
||||||
|
// Key hints
|
||||||
|
display.setColor(DisplayDriver::YELLOW);
|
||||||
|
const char* hints = "Enter:Yes Q:Cancel";
|
||||||
|
display.setCursor(boxX + 4, boxY + 29);
|
||||||
|
display.print(hints);
|
||||||
|
}
|
||||||
|
|
||||||
// === Footer ===
|
// === Footer ===
|
||||||
display.setTextSize(1);
|
display.setTextSize(1);
|
||||||
int footerY = display.height() - 12;
|
int footerY = display.height() - 12;
|
||||||
@@ -337,20 +389,31 @@ public:
|
|||||||
display.setCursor(0, footerY);
|
display.setCursor(0, footerY);
|
||||||
|
|
||||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||||
display.print("Tap:Open");
|
if (_confirmDelete) {
|
||||||
const char* rt = "Boot:Back";
|
display.print("Tap:Yes");
|
||||||
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
const char* rt = "Boot:Cancel";
|
||||||
display.print(rt);
|
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
||||||
|
display.print(rt);
|
||||||
|
} else {
|
||||||
|
display.print("Tap:Open");
|
||||||
|
const char* rt = "Hold:Del Boot:Back";
|
||||||
|
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
||||||
|
display.print(rt);
|
||||||
|
}
|
||||||
#elif defined(LILYGO_TECHO_LITE)
|
#elif defined(LILYGO_TECHO_LITE)
|
||||||
display.print("Q:Bk");
|
display.print("Q:Bk");
|
||||||
const char* rt = "Ent:Open";
|
const char* rt = "Ent:Open";
|
||||||
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
||||||
display.print(rt);
|
display.print(rt);
|
||||||
#else
|
#else
|
||||||
display.print("W/S:Nav Q:Back");
|
if (_confirmDelete) {
|
||||||
const char* rt = "Ent:Open";
|
display.print("Enter:Yes Q:Cancel");
|
||||||
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
} else {
|
||||||
display.print(rt);
|
display.print("W/S:Nav Q:Back");
|
||||||
|
const char* rt = "Ent:Open";
|
||||||
|
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
|
||||||
|
display.print(rt);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef USE_EINK
|
#ifdef USE_EINK
|
||||||
@@ -364,6 +427,30 @@ public:
|
|||||||
// Input
|
// Input
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
bool handleInput(char c) override {
|
bool handleInput(char c) override {
|
||||||
|
// --- Delete confirmation mode ---
|
||||||
|
if (_confirmDelete) {
|
||||||
|
// Enter -- confirm deletion
|
||||||
|
if (c == '\r' || c == 13 || c == KEY_ENTER || c == KEY_SELECT) {
|
||||||
|
if (_channelScreen && _cursor >= 0 && _cursor < _itemCount) {
|
||||||
|
int cleared = _channelScreen->clearHistoryForChannel(_items[_cursor]);
|
||||||
|
char name[32];
|
||||||
|
getItemName(_cursor, name, sizeof(name));
|
||||||
|
Serial.printf("ChannelPicker: Deleted %d messages for '%s'\n", cleared, name);
|
||||||
|
}
|
||||||
|
_confirmDelete = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Q / backspace -- cancel
|
||||||
|
if (c == 'q' || c == 'Q' || c == '\b' || c == KEY_CANCEL) {
|
||||||
|
_confirmDelete = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Consume all other keys while confirmation is showing
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Normal picker mode ---
|
||||||
|
|
||||||
// W / UP
|
// W / UP
|
||||||
if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) {
|
if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) {
|
||||||
if (_cursor > 0) { _cursor--; return true; }
|
if (_cursor > 0) { _cursor--; return true; }
|
||||||
@@ -376,7 +463,7 @@ public:
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A / D — consumed (no channel cycling from picker)
|
// A / D -- consumed (no channel cycling from picker)
|
||||||
if (c == 'a' || c == 'A' || c == KEY_LEFT) {
|
if (c == 'a' || c == 'A' || c == KEY_LEFT) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -384,16 +471,24 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter — select the highlighted channel and signal exit
|
// X -- delete message history for highlighted channel
|
||||||
|
if (c == 'x' || c == 'X') {
|
||||||
|
if (_cursor >= 0 && _cursor < _itemCount) {
|
||||||
|
_confirmDelete = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter -- select the highlighted channel and signal exit
|
||||||
if (c == '\r' || c == 13 || c == KEY_ENTER || c == KEY_SELECT) {
|
if (c == '\r' || c == 13 || c == KEY_ENTER || c == KEY_SELECT) {
|
||||||
if (_channelScreen && _cursor >= 0 && _cursor < _itemCount) {
|
if (_channelScreen && _cursor >= 0 && _cursor < _itemCount) {
|
||||||
_channelScreen->setViewChannelIdx(_items[_cursor]);
|
_channelScreen->setViewChannelIdx(_items[_cursor]);
|
||||||
}
|
}
|
||||||
_wantExit = true;
|
_wantExit = true;
|
||||||
return true; // Consumed — caller checks wantsExit() and navigates
|
return true; // Consumed -- caller checks wantsExit() and navigates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Q / backspace — cancel without changing channel, signal exit
|
// Q / backspace -- cancel without changing channel, signal exit
|
||||||
if (c == 'q' || c == 'Q' || c == '\b' || c == KEY_CANCEL) {
|
if (c == 'q' || c == 'Q' || c == '\b' || c == KEY_CANCEL) {
|
||||||
_wantExit = true;
|
_wantExit = true;
|
||||||
return true;
|
return true;
|
||||||
@@ -405,10 +500,22 @@ public:
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Touch hit test (virtual coordinates)
|
// Touch hit test (virtual coordinates)
|
||||||
// Returns: 0=miss, 1=cursor moved, 2=activate.
|
// Returns: 0=miss, 1=cursor moved, 2=activate.
|
||||||
// T5S3 bubbles: any tap on a bubble → 2 (direct open).
|
// T5S3 bubbles: any tap on a bubble -> 2 (direct open).
|
||||||
// T-Deck Pro list: 1st tap → 1 (highlight), 2nd tap same row → 2.
|
// T-Deck Pro list: 1st tap -> 1 (highlight), 2nd tap same row -> 2.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
int selectAtVxVy(int vx, int vy) {
|
int selectAtVxVy(int vx, int vy) {
|
||||||
|
// If delete confirmation is showing:
|
||||||
|
// T5S3: tap = confirm (return 2 → KEY_ENTER → handleInput confirms)
|
||||||
|
// T-Deck Pro: tap = cancel (dismiss overlay, stay on picker)
|
||||||
|
if (_confirmDelete) {
|
||||||
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||||
|
return 2; // Confirm — maps to KEY_ENTER in mapTouchTap
|
||||||
|
#else
|
||||||
|
_confirmDelete = false;
|
||||||
|
return 1; // Cancel — redraw without activating
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||||
// Vertical bubble list hit test
|
// Vertical bubble list hit test
|
||||||
if (vy < _gridTop || _cellH == 0) return 0;
|
if (vy < _gridTop || _cellH == 0) return 0;
|
||||||
@@ -420,7 +527,7 @@ public:
|
|||||||
_cursor = idx;
|
_cursor = idx;
|
||||||
return 2; // Direct open on tap
|
return 2; // Direct open on tap
|
||||||
#else
|
#else
|
||||||
// T-Deck Pro / MAX list hit test — uses NodePrefs for large_font compatibility
|
// T-Deck Pro / MAX list hit test -- uses NodePrefs for large_font compatibility
|
||||||
NodePrefs* prefs = the_mesh.getNodePrefs();
|
NodePrefs* prefs = the_mesh.getNodePrefs();
|
||||||
int lineH = prefs->smallLineH();
|
int lineH = prefs->smallLineH();
|
||||||
const int headerH = 14;
|
const int headerH = 14;
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NotifSounds.h -- Per-channel notification sound configuration
|
||||||
|
//
|
||||||
|
// Stores a custom MP3 filename per channel for notification tones.
|
||||||
|
// Config persisted to /meshcore/notif_sounds.cfg on SD card.
|
||||||
|
// Sound files live in /alarms/ (shared with alarm clock sounds).
|
||||||
|
//
|
||||||
|
// Playback is request-based: UITask calls requestPlay() when a message
|
||||||
|
// arrives on a channel with a custom tone. main.cpp loop() polls
|
||||||
|
// hasPendingPlay() and drives the Audio* object.
|
||||||
|
//
|
||||||
|
// Guard: MECK_AUDIO_VARIANT (requires speaker hardware)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
|
||||||
|
#ifndef NOTIF_SOUNDS_H
|
||||||
|
#define NOTIF_SOUNDS_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <SD.h>
|
||||||
|
#include <vector>
|
||||||
|
#include <algorithm>
|
||||||
|
#include "variant.h"
|
||||||
|
|
||||||
|
#ifndef MAX_GROUP_CHANNELS
|
||||||
|
#define MAX_GROUP_CHANNELS 20
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define NOTIF_SOUND_NAME_MAX 32
|
||||||
|
#define NOTIF_SOUND_SLOTS (MAX_GROUP_CHANNELS + 1) // +1 for DMs
|
||||||
|
#define NOTIF_SOUND_CONFIG_PATH "/meshcore/notif_sounds.cfg"
|
||||||
|
#define NOTIF_SOUND_MAGIC 0x4E534E44 // "NSND"
|
||||||
|
#define NOTIF_SOUND_VERSION 1
|
||||||
|
#ifndef ALARMS_FOLDER
|
||||||
|
#define ALARMS_FOLDER "/alarms"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct __attribute__((packed)) NotifSoundCfgHeader {
|
||||||
|
uint32_t magic;
|
||||||
|
uint8_t version;
|
||||||
|
uint8_t count; // Number of slots stored
|
||||||
|
uint8_t reserved[2];
|
||||||
|
// Followed by count * NOTIF_SOUND_NAME_MAX bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
class NotifSounds {
|
||||||
|
public:
|
||||||
|
NotifSounds() : _pendingPlay(false) {
|
||||||
|
memset(_sounds, 0, sizeof(_sounds));
|
||||||
|
_pendingFile[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void begin() {
|
||||||
|
loadConfig();
|
||||||
|
Serial.println("NotifSounds: Config loaded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Config accessors ---
|
||||||
|
|
||||||
|
const char* getSoundForChannel(uint8_t channel_idx) const {
|
||||||
|
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : (int)channel_idx;
|
||||||
|
if (slot < 0 || slot >= NOTIF_SOUND_SLOTS) return "";
|
||||||
|
return _sounds[slot];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasSoundForChannel(uint8_t channel_idx) const {
|
||||||
|
const char* s = getSoundForChannel(channel_idx);
|
||||||
|
return s && s[0] != '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSoundForChannel(uint8_t channel_idx, const char* filename) {
|
||||||
|
int slot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : (int)channel_idx;
|
||||||
|
if (slot < 0 || slot >= NOTIF_SOUND_SLOTS) return;
|
||||||
|
if (filename) {
|
||||||
|
strncpy(_sounds[slot], filename, NOTIF_SOUND_NAME_MAX - 1);
|
||||||
|
_sounds[slot][NOTIF_SOUND_NAME_MAX - 1] = '\0';
|
||||||
|
} else {
|
||||||
|
_sounds[slot][0] = '\0';
|
||||||
|
}
|
||||||
|
saveConfig();
|
||||||
|
Serial.printf("NotifSounds: Channel %d -> '%s'\n", (int)channel_idx,
|
||||||
|
_sounds[slot][0] ? _sounds[slot] : "(default)");
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearSoundForChannel(uint8_t channel_idx) {
|
||||||
|
setSoundForChannel(channel_idx, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sound file scanning (reuses /alarms/ folder) ---
|
||||||
|
|
||||||
|
void scanSoundFiles() {
|
||||||
|
_soundFiles.clear();
|
||||||
|
if (!SD.exists(ALARMS_FOLDER)) {
|
||||||
|
SD.mkdir(ALARMS_FOLDER);
|
||||||
|
}
|
||||||
|
File root = SD.open(ALARMS_FOLDER);
|
||||||
|
if (!root || !root.isDirectory()) {
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File entry = root.openNextFile();
|
||||||
|
while (entry) {
|
||||||
|
if (!entry.isDirectory()) {
|
||||||
|
String name = entry.name();
|
||||||
|
if (name.length() > 0 && name.charAt(0) != '.') {
|
||||||
|
String lower = name;
|
||||||
|
lower.toLowerCase();
|
||||||
|
if (lower.endsWith(".mp3")) {
|
||||||
|
_soundFiles.push_back(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry = root.openNextFile();
|
||||||
|
}
|
||||||
|
root.close();
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
std::sort(_soundFiles.begin(), _soundFiles.end());
|
||||||
|
Serial.printf("NotifSounds: Found %d sound files\n", (int)_soundFiles.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
int getSoundFileCount() const { return (int)_soundFiles.size(); }
|
||||||
|
const String& getSoundFile(int idx) const { return _soundFiles[idx]; }
|
||||||
|
const std::vector<String>& getSoundFiles() const { return _soundFiles; }
|
||||||
|
|
||||||
|
// --- Pending playback request ---
|
||||||
|
|
||||||
|
void requestPlay(const char* fullPath) {
|
||||||
|
strncpy(_pendingFile, fullPath, sizeof(_pendingFile) - 1);
|
||||||
|
_pendingFile[sizeof(_pendingFile) - 1] = '\0';
|
||||||
|
_pendingPlay = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasPendingPlay() const { return _pendingPlay; }
|
||||||
|
|
||||||
|
const char* getPendingFile() const { return _pendingFile; }
|
||||||
|
|
||||||
|
void clearPending() {
|
||||||
|
_pendingPlay = false;
|
||||||
|
_pendingFile[0] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
char _sounds[NOTIF_SOUND_SLOTS][NOTIF_SOUND_NAME_MAX];
|
||||||
|
std::vector<String> _soundFiles;
|
||||||
|
|
||||||
|
bool _pendingPlay;
|
||||||
|
char _pendingFile[48];
|
||||||
|
|
||||||
|
void loadConfig() {
|
||||||
|
memset(_sounds, 0, sizeof(_sounds));
|
||||||
|
|
||||||
|
if (!SD.exists(NOTIF_SOUND_CONFIG_PATH)) {
|
||||||
|
Serial.println("NotifSounds: No config file, using defaults");
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
File f = SD.open(NOTIF_SOUND_CONFIG_PATH, "r");
|
||||||
|
if (!f) {
|
||||||
|
Serial.println("NotifSounds: Failed to open config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifSoundCfgHeader hdr;
|
||||||
|
if (f.read((uint8_t*)&hdr, sizeof(hdr)) != sizeof(hdr) ||
|
||||||
|
hdr.magic != NOTIF_SOUND_MAGIC || hdr.version != NOTIF_SOUND_VERSION) {
|
||||||
|
Serial.println("NotifSounds: Config invalid or wrong version");
|
||||||
|
f.close();
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int slotsToRead = hdr.count;
|
||||||
|
if (slotsToRead > NOTIF_SOUND_SLOTS) slotsToRead = NOTIF_SOUND_SLOTS;
|
||||||
|
|
||||||
|
for (int i = 0; i < slotsToRead; i++) {
|
||||||
|
if (f.read((uint8_t*)_sounds[i], NOTIF_SOUND_NAME_MAX) != NOTIF_SOUND_NAME_MAX) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_sounds[i][NOTIF_SOUND_NAME_MAX - 1] = '\0'; // Safety null-terminate
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
Serial.printf("NotifSounds: Loaded %d slots from config\n", slotsToRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveConfig() {
|
||||||
|
if (!SD.exists("/meshcore")) {
|
||||||
|
SD.mkdir("/meshcore");
|
||||||
|
}
|
||||||
|
|
||||||
|
File f = SD.open(NOTIF_SOUND_CONFIG_PATH, FILE_WRITE);
|
||||||
|
if (!f) {
|
||||||
|
Serial.println("NotifSounds: Failed to save config");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotifSoundCfgHeader hdr;
|
||||||
|
hdr.magic = NOTIF_SOUND_MAGIC;
|
||||||
|
hdr.version = NOTIF_SOUND_VERSION;
|
||||||
|
hdr.count = NOTIF_SOUND_SLOTS;
|
||||||
|
hdr.reserved[0] = 0;
|
||||||
|
hdr.reserved[1] = 0;
|
||||||
|
f.write((uint8_t*)&hdr, sizeof(hdr));
|
||||||
|
|
||||||
|
for (int i = 0; i < NOTIF_SOUND_SLOTS; i++) {
|
||||||
|
f.write((uint8_t*)_sounds[i], NOTIF_SOUND_NAME_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
digitalWrite(SDCARD_CS, HIGH);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global singleton
|
||||||
|
extern NotifSounds notifSounds;
|
||||||
|
|
||||||
|
#endif // NOTIF_SOUNDS_H
|
||||||
|
#endif // MECK_AUDIO_VARIANT
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
#include <MeshCore.h>
|
#include <MeshCore.h>
|
||||||
#include "../NodePrefs.h"
|
#include "../NodePrefs.h"
|
||||||
#include "MeckFonts.h"
|
#include "MeckFonts.h"
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
#include "NotifSounds.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
// Inline edit hint shown next to values being adjusted
|
// Inline edit hint shown next to values being adjusted
|
||||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||||
@@ -170,6 +173,7 @@ enum EditMode : uint8_t {
|
|||||||
EDIT_PICKER, // A/D cycles options (radio preset, contact mode)
|
EDIT_PICKER, // A/D cycles options (radio preset, contact mode)
|
||||||
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
|
EDIT_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
|
||||||
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
|
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
|
||||||
|
EDIT_NOTIF_SOUND, // Sound picker for per-channel notification tone
|
||||||
#ifdef MECK_WIFI_COMPANION
|
#ifdef MECK_WIFI_COMPANION
|
||||||
EDIT_WIFI, // WiFi scan/select/password flow
|
EDIT_WIFI, // WiFi scan/select/password flow
|
||||||
#endif
|
#endif
|
||||||
@@ -252,6 +256,13 @@ private:
|
|||||||
uint8_t _fontPickerOriginal; // font style before edit (for cancel revert)
|
uint8_t _fontPickerOriginal; // font style before edit (for cancel revert)
|
||||||
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
|
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
|
||||||
|
|
||||||
|
// Notification sound picker state (audio variant only)
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
int _notifSoundSelected; // Cursor in sound picker (0=default/silent, 1+=files)
|
||||||
|
int _notifSoundScroll; // Scroll offset in picker list
|
||||||
|
uint8_t _notifSoundChannel; // Channel index being edited
|
||||||
|
#endif
|
||||||
|
|
||||||
// Onboarding mode
|
// Onboarding mode
|
||||||
bool _onboarding;
|
bool _onboarding;
|
||||||
|
|
||||||
@@ -595,6 +606,11 @@ public:
|
|||||||
_fmError = nullptr;
|
_fmError = nullptr;
|
||||||
_dnsServer = nullptr;
|
_dnsServer = nullptr;
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
_notifSoundSelected = 0;
|
||||||
|
_notifSoundScroll = 0;
|
||||||
|
_notifSoundChannel = 0;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
void enter() {
|
void enter() {
|
||||||
@@ -1930,15 +1946,33 @@ public:
|
|||||||
snprintf(tmp, sizeof(tmp), " %s [*]", ch.name);
|
snprintf(tmp, sizeof(tmp), " %s [*]", ch.name);
|
||||||
}
|
}
|
||||||
if (selected) {
|
if (selected) {
|
||||||
// Show edit/delete hints on right
|
// Build hint with notification state + actions
|
||||||
|
uint8_t nPref = _prefs->channel_notif[chIdx];
|
||||||
|
const char* nTag = (nPref == NOTIF_NONE) ? "Off" :
|
||||||
|
(nPref == NOTIF_MENTIONS) ? "@" : "All";
|
||||||
|
char hintBuf[40];
|
||||||
#if defined(LilyGo_T5S3_EPaper_Pro)
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||||
const char* hint = chIdx > 0 ? "Ent:Region Hold:Del" : "Ent:Region";
|
if (chIdx > 0) {
|
||||||
|
snprintf(hintBuf, sizeof(hintBuf), "Notif:%s Ent:Region Hold:Del", nTag);
|
||||||
|
} else {
|
||||||
|
snprintf(hintBuf, sizeof(hintBuf), "Notif:%s Ent:Region", nTag);
|
||||||
|
}
|
||||||
|
#elif defined(MECK_AUDIO_VARIANT)
|
||||||
|
if (chIdx > 0) {
|
||||||
|
snprintf(hintBuf, sizeof(hintBuf), "N:%s T:Tone X:Del", nTag);
|
||||||
|
} else {
|
||||||
|
snprintf(hintBuf, sizeof(hintBuf), "N:%s T:Tone Ent:Region", nTag);
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
const char* hint = chIdx > 0 ? "Ent:Region X:Del" : "Ent:Region";
|
if (chIdx > 0) {
|
||||||
|
snprintf(hintBuf, sizeof(hintBuf), "N:%s Ent:Region X:Del", nTag);
|
||||||
|
} else {
|
||||||
|
snprintf(hintBuf, sizeof(hintBuf), "N:%s Ent:Region", nTag);
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
int hintW = display.getTextWidth(hint);
|
int hintW = display.getTextWidth(hintBuf);
|
||||||
display.setCursor(display.width() - hintW - 2, y);
|
display.setCursor(display.width() - hintW - 2, y);
|
||||||
display.print(hint);
|
display.print(hintBuf);
|
||||||
display.setCursor(0, y);
|
display.setCursor(0, y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2075,6 +2109,92 @@ public:
|
|||||||
display.setTextSize(1);
|
display.setTextSize(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Notification sound picker overlay (audio variant) ===
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
if (_editMode == EDIT_NOTIF_SOUND) {
|
||||||
|
int bx = 2, by = 14, bw = display.width() - 4;
|
||||||
|
int bh = display.height() - 28;
|
||||||
|
display.setColor(DisplayDriver::DARK);
|
||||||
|
display.fillRect(bx, by, bw, bh);
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.drawRect(bx, by, bw, bh);
|
||||||
|
|
||||||
|
display.setTextSize(_prefs->smallTextSize());
|
||||||
|
int lineH = _prefs->smallLineH();
|
||||||
|
|
||||||
|
// Header
|
||||||
|
display.setColor(DisplayDriver::GREEN);
|
||||||
|
display.setCursor(bx + 4, by + 3);
|
||||||
|
display.print("Notification Tone");
|
||||||
|
|
||||||
|
int listTop = by + 14;
|
||||||
|
int listBot = by + bh - 14;
|
||||||
|
int maxVisible = (listBot - listTop) / lineH;
|
||||||
|
if (maxVisible < 3) maxVisible = 3;
|
||||||
|
|
||||||
|
// Total items: 1 ("Default") + sound file count
|
||||||
|
const auto& files = notifSounds.getSoundFiles();
|
||||||
|
int totalItems = 1 + (int)files.size();
|
||||||
|
|
||||||
|
// Centre scroll on selection
|
||||||
|
_notifSoundScroll = max(0, min(_notifSoundSelected - maxVisible / 2,
|
||||||
|
totalItems - maxVisible));
|
||||||
|
if (_notifSoundScroll < 0) _notifSoundScroll = 0;
|
||||||
|
int endIdx = min(totalItems, _notifSoundScroll + maxVisible);
|
||||||
|
|
||||||
|
int sy = listTop;
|
||||||
|
for (int i = _notifSoundScroll; i < endIdx && sy + lineH <= listBot; i++) {
|
||||||
|
bool isSel = (i == _notifSoundSelected);
|
||||||
|
|
||||||
|
if (isSel) {
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.fillRect(bx + 2, sy + _prefs->smallHighlightOff(), bw - 4, lineH);
|
||||||
|
display.setColor(DisplayDriver::DARK);
|
||||||
|
} else {
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
|
display.setCursor(bx + 6, sy);
|
||||||
|
if (i == 0) {
|
||||||
|
display.print("Default (silent)");
|
||||||
|
} else {
|
||||||
|
// Show MP3 filename without extension
|
||||||
|
String displayName = files[i - 1];
|
||||||
|
int dot = displayName.lastIndexOf('.');
|
||||||
|
if (dot > 0) displayName = displayName.substring(0, dot);
|
||||||
|
if (displayName.length() > 28) displayName = displayName.substring(0, 28);
|
||||||
|
display.print(displayName.c_str());
|
||||||
|
}
|
||||||
|
sy += lineH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
display.setTextSize(1);
|
||||||
|
display.setColor(DisplayDriver::YELLOW);
|
||||||
|
int fy = by + bh - 11;
|
||||||
|
#if defined(LilyGo_T5S3_EPaper_Pro)
|
||||||
|
display.setCursor(bx + 4, fy);
|
||||||
|
display.print("Tap:Pick Boot:Back");
|
||||||
|
#else
|
||||||
|
display.setCursor(bx + 4, fy);
|
||||||
|
display.print("Enter:Pick Q:Back");
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Scroll indicator
|
||||||
|
if (totalItems > maxVisible) {
|
||||||
|
int sbX = bx + bw - 4;
|
||||||
|
int sbH = listBot - listTop;
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.drawRect(sbX, listTop, 3, sbH);
|
||||||
|
int thumbH = max(4, (maxVisible * sbH) / totalItems);
|
||||||
|
int maxScroll = totalItems - maxVisible;
|
||||||
|
if (maxScroll < 1) maxScroll = 1;
|
||||||
|
int thumbY = listTop + (_notifSoundScroll * (sbH - thumbH)) / maxScroll;
|
||||||
|
display.fillRect(sbX + 1, thumbY + 1, 1, thumbH - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef MECK_WIFI_COMPANION
|
#ifdef MECK_WIFI_COMPANION
|
||||||
// === WiFi setup overlay ===
|
// === WiFi setup overlay ===
|
||||||
if (_editMode == EDIT_WIFI) {
|
if (_editMode == EDIT_WIFI) {
|
||||||
@@ -2536,6 +2656,41 @@ public:
|
|||||||
return true; // consume all keys in confirm mode
|
return true; // consume all keys in confirm mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Notification sound picker (audio variant) ---
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
if (_editMode == EDIT_NOTIF_SOUND) {
|
||||||
|
const auto& files = notifSounds.getSoundFiles();
|
||||||
|
int totalItems = 1 + (int)files.size(); // 0=Default, rest=files
|
||||||
|
|
||||||
|
if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) {
|
||||||
|
if (_notifSoundSelected > 0) _notifSoundSelected--;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c == 's' || c == 'S' || c == 0xF1 || c == KEY_DOWN) {
|
||||||
|
if (_notifSoundSelected < totalItems - 1) _notifSoundSelected++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c == '\r' || c == 13) {
|
||||||
|
// Select: 0 = clear (default silent), 1+ = file
|
||||||
|
if (_notifSoundSelected == 0) {
|
||||||
|
notifSounds.clearSoundForChannel(_notifSoundChannel);
|
||||||
|
} else {
|
||||||
|
int fileIdx = _notifSoundSelected - 1;
|
||||||
|
if (fileIdx >= 0 && fileIdx < (int)files.size()) {
|
||||||
|
notifSounds.setSoundForChannel(_notifSoundChannel, files[fileIdx].c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_editMode = EDIT_NONE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (c == 'q' || c == 'Q' || c == '\b') {
|
||||||
|
_editMode = EDIT_NONE;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true; // consume all keys in picker mode
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
#ifdef MECK_OTA_UPDATE
|
#ifdef MECK_OTA_UPDATE
|
||||||
// --- OTA update flow ---
|
// --- OTA update flow ---
|
||||||
if (_editMode == EDIT_OTA) {
|
if (_editMode == EDIT_OTA) {
|
||||||
@@ -3286,6 +3441,45 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// N: cycle notification preference (All -> Mentions -> None -> All)
|
||||||
|
if (c == 'n' || c == 'N') {
|
||||||
|
if (_rows[_cursor].type == ROW_CHANNEL) {
|
||||||
|
uint8_t chIdx = _rows[_cursor].param;
|
||||||
|
uint8_t cur = _prefs->channel_notif[chIdx];
|
||||||
|
_prefs->channel_notif[chIdx] = (cur + 1) % 3;
|
||||||
|
the_mesh.savePrefs();
|
||||||
|
const char* labels[] = {"All", "Mentions", "Off"};
|
||||||
|
Serial.printf("Settings: Channel %d notif -> %s\n",
|
||||||
|
chIdx, labels[_prefs->channel_notif[chIdx]]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// T: open notification tone picker (audio variant only)
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
if (c == 't' || c == 'T') {
|
||||||
|
if (_rows[_cursor].type == ROW_CHANNEL) {
|
||||||
|
_notifSoundChannel = _rows[_cursor].param;
|
||||||
|
notifSounds.scanSoundFiles();
|
||||||
|
_notifSoundSelected = 0; // 0 = "Default (silent)"
|
||||||
|
_notifSoundScroll = 0;
|
||||||
|
// Pre-select current assignment
|
||||||
|
const char* current = notifSounds.getSoundForChannel(_notifSoundChannel);
|
||||||
|
if (current && current[0] != '\0') {
|
||||||
|
const auto& files = notifSounds.getSoundFiles();
|
||||||
|
for (int i = 0; i < (int)files.size(); i++) {
|
||||||
|
if (files[i] == String(current)) {
|
||||||
|
_notifSoundSelected = i + 1; // +1 because 0 is "Default"
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_editMode = EDIT_NOTIF_SOUND;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Q: back -- if in sub-screen, return to top level; else exit settings
|
// Q: back -- if in sub-screen, return to top level; else exit settings
|
||||||
if (c == 'q' || c == 'Q') {
|
if (c == 'q' || c == 'Q') {
|
||||||
if (_subScreen != SUB_NONE) {
|
if (_subScreen != SUB_NONE) {
|
||||||
|
|||||||
@@ -65,6 +65,15 @@
|
|||||||
#include "SMSScreen.h"
|
#include "SMSScreen.h"
|
||||||
#include "ModemManager.h"
|
#include "ModemManager.h"
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
#include "NotifSounds.h"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Per-channel notification suppression flag.
|
||||||
|
// Set by newMsg() based on channel_notif preference, checked by notify()
|
||||||
|
// to suppress buzzer/vibration. Safe because both are called sequentially
|
||||||
|
// from the same mesh callback on the same thread.
|
||||||
|
static bool s_lastMsgSuppressed = false;
|
||||||
|
|
||||||
class SplashScreen : public UIScreen {
|
class SplashScreen : public UIScreen {
|
||||||
UITask* _task;
|
UITask* _task;
|
||||||
@@ -1454,6 +1463,15 @@ void UITask::dismissBootHint() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UITask::notify(UIEventType t) {
|
void UITask::notify(UIEventType t) {
|
||||||
|
// Per-channel notification gating: if the last message was from a
|
||||||
|
// muted channel (or mentions-only without an @mention), suppress
|
||||||
|
// buzzer and vibration. Ack events are never suppressed.
|
||||||
|
if (s_lastMsgSuppressed && t != UIEventType::ack) {
|
||||||
|
s_lastMsgSuppressed = false; // Consume the flag
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_lastMsgSuppressed = false;
|
||||||
|
|
||||||
#if defined(PIN_BUZZER)
|
#if defined(PIN_BUZZER)
|
||||||
switch(t){
|
switch(t){
|
||||||
case UIEventType::contactMessage:
|
case UIEventType::contactMessage:
|
||||||
@@ -1520,6 +1538,62 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Per-channel notification preference check ---
|
||||||
|
// Determines whether to suppress toast, buzzer, keyboard flash, vibration,
|
||||||
|
// display wake, and unread counter for this message. Messages are ALWAYS
|
||||||
|
// stored in history regardless -- only alerts and unread badges are gated.
|
||||||
|
bool suppressNotif = false;
|
||||||
|
{
|
||||||
|
int notifSlot = (channel_idx == 0xFF) ? MAX_GROUP_CHANNELS : (int)channel_idx;
|
||||||
|
if (notifSlot >= 0 && notifSlot < (int)sizeof(_node_prefs->channel_notif)) {
|
||||||
|
uint8_t pref = _node_prefs->channel_notif[notifSlot];
|
||||||
|
if (pref == NOTIF_NONE) {
|
||||||
|
suppressNotif = true;
|
||||||
|
} else if (pref == NOTIF_MENTIONS) {
|
||||||
|
// Check for @nodename or @[nodename] in message text (case-insensitive).
|
||||||
|
// MeshCore companion app sends mentions as @[node name] with brackets.
|
||||||
|
suppressNotif = true; // Suppress unless mention found
|
||||||
|
if (_node_prefs->node_name[0] != '\0') {
|
||||||
|
char tagPlain[36];
|
||||||
|
char tagBracket[38];
|
||||||
|
snprintf(tagPlain, sizeof(tagPlain), "@%s", _node_prefs->node_name);
|
||||||
|
snprintf(tagBracket, sizeof(tagBracket), "@[%s]", _node_prefs->node_name);
|
||||||
|
int lenPlain = strlen(tagPlain);
|
||||||
|
int lenBracket = strlen(tagBracket);
|
||||||
|
const char* p = text;
|
||||||
|
while (*p) {
|
||||||
|
if (strncasecmp(p, tagBracket, lenBracket) == 0 ||
|
||||||
|
strncasecmp(p, tagPlain, lenPlain) == 0) {
|
||||||
|
suppressNotif = false; // Mentioned -- notify
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set the flag for notify() which is called immediately after newMsg().
|
||||||
|
// If a custom notification tone is assigned and notifications are active,
|
||||||
|
// request MP3 playback and suppress the RTTTL buzzer so they don't overlap.
|
||||||
|
#ifdef MECK_AUDIO_VARIANT
|
||||||
|
if (!suppressNotif) {
|
||||||
|
const char* customSound = notifSounds.getSoundForChannel(channel_idx);
|
||||||
|
if (customSound && customSound[0] != '\0') {
|
||||||
|
char soundPath[48];
|
||||||
|
snprintf(soundPath, sizeof(soundPath), "/alarms/%s", customSound);
|
||||||
|
notifSounds.requestPlay(soundPath);
|
||||||
|
s_lastMsgSuppressed = true; // Suppress buzzer -- MP3 replaces it
|
||||||
|
} else {
|
||||||
|
s_lastMsgSuppressed = suppressNotif;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s_lastMsgSuppressed = suppressNotif;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
s_lastMsgSuppressed = suppressNotif;
|
||||||
|
#endif
|
||||||
|
|
||||||
// Add to channel history screen with channel index, path data, and SNR
|
// Add to channel history screen with channel index, path data, and SNR
|
||||||
// For DMs (channel_idx == 0xFF):
|
// For DMs (channel_idx == 0xFF):
|
||||||
@@ -1541,15 +1615,15 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
|||||||
if (isRoomMsg) {
|
if (isRoomMsg) {
|
||||||
// Room server: text already has "Poster: message" format — store as-is
|
// Room server: text already has "Poster: message" format — store as-is
|
||||||
// Tag with room server name for conversation filtering
|
// Tag with room server name for conversation filtering
|
||||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name);
|
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, from_name, suppressNotif);
|
||||||
} else {
|
} else {
|
||||||
// Regular DM: prefix with sender name
|
// Regular DM: prefix with sender name
|
||||||
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
|
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
|
||||||
snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text);
|
snprintf(dmFormatted, sizeof(dmFormatted), "%s: %s", from_name, text);
|
||||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr);
|
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, dmFormatted, path, snr, nullptr, suppressNotif);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr);
|
((ChannelScreen *) channel_screen)->addMessage(channel_idx, path_len, from_name, text, path, snr, nullptr, suppressNotif);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user is currently viewing this channel on the device, or companion
|
// If user is currently viewing this channel on the device, or companion
|
||||||
@@ -1572,11 +1646,11 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't interrupt user with popup - just show brief notification
|
// Don't interrupt user with popup - just show brief notification
|
||||||
// Messages are stored in channel history, accessible via tile/key
|
// Messages are stored in channel history, accessible via tile/key
|
||||||
// Suppress toasts for room server messages (bulk sync would spam toasts)
|
// Suppress toasts for room server messages (bulk sync would spam toasts)
|
||||||
if (!isOnRepeaterAdmin() && !isRoomMsg) {
|
if (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif) {
|
||||||
char alertBuf[40];
|
char alertBuf[40];
|
||||||
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
|
||||||
showAlert(alertBuf, 2000);
|
showAlert(alertBuf, 2000);
|
||||||
@@ -1586,7 +1660,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
|||||||
forceRefresh();
|
forceRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_display != NULL) {
|
if (_display != NULL && !suppressNotif) {
|
||||||
if (!_display->isOn() && !hasConnection()) {
|
if (!_display->isOn() && !hasConnection()) {
|
||||||
_display->turnOn();
|
_display->turnOn();
|
||||||
}
|
}
|
||||||
@@ -1602,9 +1676,9 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard flash notification (suppress for room sync)
|
// Keyboard flash notification (suppress for room sync and muted channels)
|
||||||
#ifdef KB_BL_PIN
|
#ifdef KB_BL_PIN
|
||||||
if (_node_prefs->kb_flash_notify && !isRoomMsg) {
|
if (_node_prefs->kb_flash_notify && !isRoomMsg && !suppressNotif) {
|
||||||
digitalWrite(KB_BL_PIN, HIGH);
|
digitalWrite(KB_BL_PIN, HIGH);
|
||||||
_kb_flash_off_at = millis() + 200; // 200ms flash
|
_kb_flash_off_at = millis() + 200; // 200ms flash
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ build_flags =
|
|||||||
-D MECK_AUDIO_VARIANT
|
-D MECK_AUDIO_VARIANT
|
||||||
-D MECK_WEB_READER=1
|
-D MECK_WEB_READER=1
|
||||||
-D MECK_OTA_UPDATE=1
|
-D MECK_OTA_UPDATE=1
|
||||||
-D FIRMWARE_VERSION='"Meck v1.9.WiFi"'
|
-D FIRMWARE_VERSION='"Meck v1.10.WiFi"'
|
||||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||||
+<helpers/esp32/*.cpp>
|
+<helpers/esp32/*.cpp>
|
||||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||||
@@ -226,7 +226,7 @@ build_flags =
|
|||||||
-D HAS_4G_MODEM=1
|
-D HAS_4G_MODEM=1
|
||||||
-D MECK_WEB_READER=1
|
-D MECK_WEB_READER=1
|
||||||
-D MECK_OTA_UPDATE=1
|
-D MECK_OTA_UPDATE=1
|
||||||
-D FIRMWARE_VERSION='"Meck v1.9.4G"'
|
-D FIRMWARE_VERSION='"Meck v1.10.4G"'
|
||||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||||
+<helpers/esp32/*.cpp>
|
+<helpers/esp32/*.cpp>
|
||||||
+<helpers/ui/MomentaryButton.cpp>
|
+<helpers/ui/MomentaryButton.cpp>
|
||||||
@@ -262,7 +262,7 @@ build_flags =
|
|||||||
-D HAS_4G_MODEM=1
|
-D HAS_4G_MODEM=1
|
||||||
-D MECK_WEB_READER=1
|
-D MECK_WEB_READER=1
|
||||||
-D MECK_OTA_UPDATE=1
|
-D MECK_OTA_UPDATE=1
|
||||||
-D FIRMWARE_VERSION='"Meck v1.9.4G.WiFi"'
|
-D FIRMWARE_VERSION='"Meck v1.10.4G.WiFi"'
|
||||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||||
+<helpers/esp32/*.cpp>
|
+<helpers/esp32/*.cpp>
|
||||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||||
@@ -296,7 +296,7 @@ build_flags =
|
|||||||
-D HAS_4G_MODEM=1
|
-D HAS_4G_MODEM=1
|
||||||
-D MECK_WEB_READER=1
|
-D MECK_WEB_READER=1
|
||||||
-D MECK_OTA_UPDATE=1
|
-D MECK_OTA_UPDATE=1
|
||||||
-D FIRMWARE_VERSION='"Meck v1.9.4G.SA"'
|
-D FIRMWARE_VERSION='"Meck v1.10.4G.SA"'
|
||||||
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
build_src_filter = ${LilyGo_TDeck_Pro.build_src_filter}
|
||||||
+<helpers/esp32/*.cpp>
|
+<helpers/esp32/*.cpp>
|
||||||
-<helpers/esp32/SerialBLEInterface.cpp>
|
-<helpers/esp32/SerialBLEInterface.cpp>
|
||||||
|
|||||||
Reference in New Issue
Block a user