Compare commits

..

9 Commits

20 changed files with 49232 additions and 113 deletions
+82 -5
View File
@@ -30,6 +30,10 @@ A fork created specifically to focus on enabling BLE & WiFi companion firmware f
- [Roomservers](#roomservers)
- [Repeater Admin Screen](#repeater-admin-screen)
- [Trace Route Screen (v1.9+)](#trace-route-screen-v19)
- [Delete Message History (v1.10+)](#delete-message-history-v110)
- [Per-Channel Notification Preferences (v1.10+)](#per-channel-notification-preferences-v110)
- [Custom Notification Tones (v1.10+)](#custom-notification-tones-v110)
- [Games (v1.10+)](#games-v110)
- [Settings Screen](#settings-screen)
- [Font Styles](#font-styles)
- [Compose Mode](#compose-mode)
@@ -244,7 +248,8 @@ The T-Deck Pro firmware includes full keyboard support for standalone messaging
| K | Open alarm clock (audio variant only) |
| F | Open node discovery (search for nearby repeaters/nodes) |
| H | Open last heard list (passive advert history) |
| R | Open trace route screen (v1.9+) see [Trace Route Screen](#trace-route-screen-v19) |
| R | Open trace route screen (v1.9+) -- see [Trace Route Screen](#trace-route-screen-v19) |
| J | Open games menu (v1.10+) -- see [Games](#games-v110) |
| G | Open map screen (shows contacts with GPS positions) |
| Mic | Open voice messages (audio variant only) |
| Q | Back to home screen |
@@ -312,9 +317,12 @@ Pressing **A** or **D** on the channel messages screen opens the channel picker.
|-----|--------|
| W / S | Navigate up / down |
| Enter | Switch to selected channel |
| X | Delete message history for highlighted channel (v1.10+) |
| Q | Back to home screen |
On the T5S3, swiping left or right on the channel messages screen also opens the channel picker, which displays a **vertical bubble list** matching the Meck P4 aesthetic.
Pressing **X** on any highlighted channel brings up a confirmation overlay. Press **Enter** to confirm deletion or **Q** to cancel. This clears all stored messages for that channel from the circular buffer and saves to SD. The channel itself is not removed -- only its message history.
On the T5S3, swiping left or right on the channel messages screen also opens the channel picker, which displays a **vertical bubble list** matching the Meck P4 aesthetic. Long-press a channel to bring up the delete history confirmation.
### Contacts Screen
@@ -484,6 +492,63 @@ You also need to be able to **hear the last repeater in the chain directly** —
The screen supports up to **16 hops** per trace.
### Delete Message History (v1.10+)
You can clear all stored messages for any individual channel or the DM inbox without removing the channel itself.
From the home screen, press **M** to open the channel messages screen, then **A** or **D** to open the channel picker. Navigate to the channel you want to clear and press **X**. A confirmation overlay appears asking "Delete message history?" -- press **Enter** to confirm or **Q** to cancel.
On the T5S3, long-press the channel in the channel picker to bring up the same confirmation. Tap to confirm or press the Boot button to cancel.
Messages are invalidated in the circular buffer and the change is saved to SD immediately. The unread counter is also reset. New messages will continue to appear as they arrive.
### Per-Channel Notification Preferences (v1.10+)
Each channel (and the DM inbox) can be individually set to one of three notification levels:
- **All** -- notify on every message (default)
- **@ (Mentions)** -- only notify when someone tags you with @YourNodeName or @[YourNodeName]
- **Off** -- completely muted (no buzzer, no keyboard flash, no screen wake, no toast)
Messages are always stored in history regardless of the notification setting -- only the alerts and unread badges are suppressed.
To change a channel's notification preference: from the home screen, press **S** to open settings, scroll down to the **Channels >>** section and open it. Navigate to the channel you want to configure, and press **N** to cycle through the three modes. The current setting is shown in the channel row hint as `N:All`, `N:@`, or `N:Off`.
### Custom Notification Tones (v1.10+)
Each channel can have its own notification tone instead of the default buzzer sound. When a message arrives on a channel with a custom tone assigned, that tone plays through the speaker instead of the RTTTL buzzer.
To assign a tone: from the home screen, press **S** to open settings, scroll down to the **Channels >>** section and open it. Navigate to the channel you want, and press **T**. The tone picker appears with a list of available sounds. Use **W/S** to browse, **Enter** to select, or **Q** to cancel. Select "Default (silent)" to remove a custom tone and revert to the standard buzzer.
**Audio variant (PCM5102A DAC):** A selection of bundled tones are copied to the `/alarms/` folder on the SD card on first boot. You can also add your own MP3 files to that folder -- they'll appear in the tone picker alongside the bundled options. This gives you complete flexibility to use any short MP3 as a notification sound.
**4G variant (A7682E modem):** Seven bundled notification tones are embedded in the firmware as 8kHz mono WAV files and transferred to the modem's internal filesystem on boot. Playback goes through the modem's own speaker amplifier via AT+CCMXPLAY. Custom user-supplied tones are not supported on the 4G variant -- only the bundled set is available.
**Available bundled tones:** Bell, Ding, High Trill, Low Soft Ding (x2), Mid Trill, and Soft Notif. All are short, 1-2 second alert sounds.
### Games (v1.10+)
Press **J** from the home screen to open the games menu. Two classic games are included:
**Snake** -- the Nokia classic. Guide the snake around the screen to eat food and grow longer without crashing into the walls or your own tail. The game runs on the e-ink display at a pace suited to the refresh rate.
| Key | Action |
|-----|--------|
| W | Turn up |
| A | Turn left |
| S | Turn down |
| D | Turn right |
| Q | Quit to games menu |
**Minesweeper** -- clear the board without hitting a mine. Numbers reveal how many adjacent cells contain mines. Flag cells you suspect are mines to keep track.
| Key | Action |
|-----|--------|
| W / A / S / D | Move cursor |
| Enter | Reveal cell |
| F | Toggle flag on cell |
| Q | Quit to games menu |
### Settings Screen
Press **S** from the home screen to open settings. On first boot (when the device name is still the default hex ID), the settings screen launches automatically as an onboarding wizard to set your device name and radio preset.
@@ -532,7 +597,15 @@ Press **S** from the home screen to open settings. On first boot (when the devic
Press Q to return to the top-level settings list.
**Channels sub-screen** press Enter on the `Channels >>` row to open. Lists all current channels with their region scope tags (e.g. `[au-nsw]` or `[*]` for device default). Press Enter on a channel to edit its region scope. Press X to delete non-primary channels. Press Q to return to the top-level settings list.
**Channels sub-screen** -- press Enter on the `Channels >>` row to open. Lists all current channels with their region scope tags (e.g. `[au-nsw]` or `[*]` for device default). The hint line for each channel shows its current notification preference (`N:All`, `N:@`, or `N:Off`) and available actions.
| Key | Action |
|-----|--------|
| Enter | Edit channel region scope |
| N | Cycle notification preference (All / Mentions / Off) -- see [Per-Channel Notification Preferences](#per-channel-notification-preferences-v110) |
| T | Open notification tone picker (audio and 4G variants) -- see [Custom Notification Tones](#custom-notification-tones-v110) |
| X | Delete channel (non-primary channels only) |
| Q | Back to top-level settings |
The top-level settings screen also displays your node ID and firmware version. On the 4G variant, IMEI, carrier name, and APN details are shown here as well.
@@ -1141,11 +1214,15 @@ There are a number of fairly major features in the pipeline, with no particular
- [X] BLE 2M PHY, DLE, and faster write interval
- [X] Trace route screen with contact picker and typed-path entry (v1.9)
- [X] DM message persistence across reboots (v1.9)
- [X] Per-channel message history deletion (v1.10)
- [X] Per-channel notification preferences with @mention support (v1.10)
- [X] Custom notification tones per channel -- audio variant (MP3) and 4G variant (WAV via modem) (v1.10)
- [X] Games menu with Snake and Minesweeper (v1.10)
- [X] MAX_GROUP_CHANNELS expanded to 40 for all builds (v1.10)
- [ ] Fix M4B rendering to enable chaptered audiobook playback
- [ ] Better JPEG and PNG decoding
- [ ] Improve EPUB rendering and EPUB format handling
- [ ] Figure out a way to silence the ringtone
- [ ] Figure out a way to customise the ringtone
- [ ] Incoming call ringer silence (hardware limitation -- A7682E drives speaker autonomously on RING, no software mute path available)
**T5S3 E-Paper Pro:**
- [X] Core port: display, touch input, LoRa, battery, RTC
+8
View File
@@ -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)) {
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
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.tx_fail_reset_threshold > 10) _prefs.tx_fail_reset_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)
{
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.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.channel_notif, sizeof(_prefs.channel_notif)); // 153
file.close();
}
+2 -2
View File
@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 11
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "7 May 2026"
#define FIRMWARE_BUILD_DATE "15 May 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v1.9"
#define FIRMWARE_VERSION "Meck v1.10"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+11
View File
@@ -8,6 +8,11 @@
#define ADVERT_LOC_NONE 0
#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
float airtime_factor;
char node_name[32];
@@ -53,6 +58,12 @@ struct NodePrefs { // persisted to file
char default_scope_name[31]; // e.g. "au-nsw", empty = unscoped
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) ---
// 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.
+393 -4
View File
@@ -29,6 +29,9 @@
#include "LastHeardScreen.h"
#include "PathEditorScreen.h"
#include "Tracescreen.h"
#include "GamesMenuScreen.h"
#include "SnakeScreen.h"
#include "MinesweeperScreen.h"
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
@@ -84,6 +87,11 @@
#endif
#ifdef MECK_AUDIO_VARIANT
#include "VoiceMessageScreen.h"
#include "BundledSounds.h"
#endif
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
#include "NotifSounds.h"
NotifSounds notifSounds; // Global singleton for per-channel notification tones
#endif
static bool audiobookMode = false;
static bool voiceMode = false;
@@ -697,6 +705,9 @@
#include "LastHeardScreen.h"
#include "PathEditorScreen.h"
#include "Tracescreen.h"
#include "GamesMenuScreen.h"
#include "SnakeScreen.h"
#include "MinesweeperScreen.h"
static TouchDrvGT911 gt911Touch;
static bool gt911Ready = false;
@@ -1207,6 +1218,26 @@ static void lastHeardToggleContact() {
return 0;
}
// Games menu: tap to select game entry
if (ui_task.isOnGamesMenu()) {
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
if (gm) {
int result = gm->selectRowAtVY(vy);
if (result == 2) return '\r'; // Tapped current selection -- launch
}
return 0; // Cursor moved or miss -- just refresh
}
// Snake screen: tap = Enter (start / restart)
if (ui_task.isOnSnakeScreen()) {
return '\r';
}
// Minesweeper screen: tap = Enter (reveal cell / start / restart)
if (ui_task.isOnMinesweeperScreen()) {
return '\r';
}
// Home screen FIRST page: tile taps (virtual coordinate hit test)
if (ui_task.isOnHomeScreen() && ui_task.isHomeShowingTiles()) {
const int tileW = 40, tileH = 22, gapX = 1, gapY = 1;
@@ -1232,8 +1263,9 @@ static void lastHeardToggleContact() {
#else
if (row == 1 && col == 2) { ui_task.gotoDiscoveryScreen(); return 0; }
#endif
// Third row: only centre tile (col 1) is real; col 0 and col 2 fall through to page-flip
if (row == 2 && col == 1) { ui_task.gotoTraceScreen(); return 0; }
// Third row: Trace (col 0) + Games (col 1)
if (row == 2 && col == 0) { ui_task.gotoTraceScreen(); return 0; }
if (row == 2 && col == 1) { ui_task.gotoGamesMenu(); return 0; }
}
// Tap outside tiles — left half backward, right half forward
return (vx < 64) ? (char)KEY_PREV : (char)KEY_NEXT;
@@ -1470,6 +1502,32 @@ static void lastHeardToggleContact() {
if (ui_task.isOnSMSScreen()) return 0;
#endif
// Snake screen: swipes control direction
if (ui_task.isOnSnakeScreen()) {
if (horizontal) {
return (dx < 0) ? 'a' : 'd';
} else {
return (dy < 0) ? 'w' : 's';
}
}
// Minesweeper screen: swipes move cursor
if (ui_task.isOnMinesweeperScreen()) {
if (horizontal) {
return (dx < 0) ? 'a' : 'd';
} else {
return (dy < 0) ? 'w' : 's';
}
}
// Games menu: vertical swipe scrolls list
if (ui_task.isOnGamesMenu()) {
if (!horizontal) {
return (dy > 0) ? 's' : 'w';
}
return 0;
}
// Reader (reading mode): swipe left/right for page turn
if (ui_task.isOnTextReader()) {
TextReaderScreen* reader = (TextReaderScreen*)ui_task.getTextReaderScreen();
@@ -1542,6 +1600,21 @@ static void lastHeardToggleContact() {
if (ui_task.isOnSMSScreen()) return 0;
#endif
// Snake screen: long press exits to games menu
if (ui_task.isOnSnakeScreen()) {
return 'q';
}
// Minesweeper screen: long press toggles flag on cursor cell
if (ui_task.isOnMinesweeperScreen()) {
return 'f';
}
// Games menu: long press exits to home
if (ui_task.isOnGamesMenu()) {
return 'q';
}
// Home screen: long press = activate current page action
// (BLE toggle, send advert, hibernate, GPS toggle, etc.)
if (ui_task.isOnHomeScreen()) {
@@ -1751,6 +1824,11 @@ static void lastHeardToggleContact() {
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
return KEY_ENTER;
}
@@ -2029,6 +2107,21 @@ void setup() {
}
#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();
}
#endif
// Initialise per-channel notification sound config (audio + 4G variants).
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
if (sdCardReady) {
notifSounds.begin();
}
#endif
MESH_DEBUG_PRINTLN("setup() - about to call store.begin()");
store.begin();
MESH_DEBUG_PRINTLN("setup() - store.begin() done");
@@ -2658,6 +2751,137 @@ void loop() {
}
#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
#if defined(LilyGo_TDeck_Pro) && defined(MECK_AUDIO_VARIANT)
{
@@ -3230,6 +3454,33 @@ void loop() {
ui_task.gotoHomeScreen();
}
}
// Snake screen: check if Exit was triggered
if (ui_task.isOnSnakeScreen()) {
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
if (ss && ss->wantsExit()) {
ui_task.gotoGamesMenu();
}
}
// Minesweeper screen: check if Exit was triggered
if (ui_task.isOnMinesweeperScreen()) {
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
if (ms && ms->wantsExit()) {
ui_task.gotoGamesMenu();
}
}
// Games menu: check if game launch was triggered
if (ui_task.isOnGamesMenu()) {
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
if (gm && gm->wantsLaunch()) {
GameID sel = gm->selectedGame();
gm->clearFlags();
switch (sel) {
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
default: break;
}
}
}
// Channel picker: check if long-press Enter was handled (wantsExit)
if (ui_task.isOnChannelPickerScreen()) {
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
@@ -3263,6 +3514,33 @@ void loop() {
ui_task.gotoHomeScreen();
}
}
// Snake screen: check if Exit was triggered
if (ui_task.isOnSnakeScreen()) {
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
if (ss && ss->wantsExit()) {
ui_task.gotoGamesMenu();
}
}
// Minesweeper screen: check if Exit was triggered
if (ui_task.isOnMinesweeperScreen()) {
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
if (ms && ms->wantsExit()) {
ui_task.gotoGamesMenu();
}
}
// Games menu: check if game launch was triggered
if (ui_task.isOnGamesMenu()) {
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
if (gm && gm->wantsLaunch()) {
GameID sel = gm->selectedGame();
gm->clearFlags();
switch (sel) {
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
default: break;
}
}
}
// Channel picker: check if Enter/Q was handled (wantsExit)
if (ui_task.isOnChannelPickerScreen()) {
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
@@ -3406,6 +3684,7 @@ void loop() {
case 'f': ui_task.gotoDiscoveryScreen(); break;
case 'h': ui_task.gotoLastHeardScreen(); break;
case 'r': ui_task.gotoTraceScreen(); break;
case 'j': ui_task.gotoGamesMenu(); break;
case (char)0xF3: ui_task.injectKey(KEY_LEFT); break; // Left arrow → prev page
case (char)0xF4: ui_task.injectKey(KEY_RIGHT); break; // Right arrow → next page
#ifdef MECK_WEB_READER
@@ -3456,7 +3735,21 @@ void loop() {
if (!handled) {
// ESC or Q → back navigation
if (ckb == 0x1B || ckb == 'q') {
if (ui_task.isOnChannelPickerScreen()) {
if (ui_task.isOnSnakeScreen()) {
ui_task.injectKey('q');
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
if (ss && ss->wantsExit()) {
ui_task.gotoGamesMenu();
}
} else if (ui_task.isOnMinesweeperScreen()) {
ui_task.injectKey('q');
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
if (ms && ms->wantsExit()) {
ui_task.gotoGamesMenu();
}
} else if (ui_task.isOnGamesMenu()) {
ui_task.gotoHomeScreen();
} else if (ui_task.isOnChannelPickerScreen()) {
ui_task.gotoHomeScreen();
} else if (ui_task.isOnChannelScreen()) {
ChannelScreen* chScr = (ChannelScreen*)ui_task.getChannelScreen();
@@ -3611,6 +3904,33 @@ void loop() {
if (ts && ts->wantsExit()) {
ui_task.gotoHomeScreen();
}
} else if (ui_task.isOnGamesMenu()) {
// Games menu: Enter launches selected game
ui_task.injectKey('\r');
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
if (gm && gm->wantsLaunch()) {
GameID sel = gm->selectedGame();
gm->clearFlags();
switch (sel) {
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
default: break;
}
}
} else if (ui_task.isOnSnakeScreen()) {
// Snake: Enter starts/restarts game
ui_task.injectKey('\r');
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
if (ss && ss->wantsExit()) {
ui_task.gotoGamesMenu();
}
} else if (ui_task.isOnMinesweeperScreen()) {
// Minesweeper: Enter reveals cell or starts/restarts
ui_task.injectKey('\r');
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
if (ms && ms->wantsExit()) {
ui_task.gotoGamesMenu();
}
} else if (ui_task.isOnChannelPickerScreen()) {
// Channel picker: Enter selects channel
ui_task.injectKey('\r');
@@ -4717,6 +5037,7 @@ void handleKeyboardInput() {
|| ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen()
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|| ui_task.isOnTraceScreen()
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
@@ -4735,6 +5056,7 @@ void handleKeyboardInput() {
|| ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen()
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|| ui_task.isOnTraceScreen()
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
@@ -4757,6 +5079,7 @@ void handleKeyboardInput() {
|| ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen()
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|| ui_task.isOnTraceScreen()
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
@@ -4775,6 +5098,7 @@ void handleKeyboardInput() {
|| ui_task.isOnDiscoveryScreen() || ui_task.isOnLastHeardScreen()
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|| ui_task.isOnTraceScreen()
|| ui_task.isOnGamesMenu() || ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
#ifdef MECK_WEB_READER
|| ui_task.isOnWebReader()
#endif
@@ -4797,6 +5121,7 @@ void handleKeyboardInput() {
} else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|| ui_task.isOnTraceScreen()
|| ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
#ifdef MECK_AUDIO_VARIANT
|| ui_task.isOnAlarmScreen()
#endif
@@ -4815,6 +5140,7 @@ void handleKeyboardInput() {
} else if (ui_task.isOnContactsScreen() || ui_task.isOnMapScreen()
|| ui_task.isOnPathEditor() || ui_task.isOnChannelPickerScreen()
|| ui_task.isOnTraceScreen()
|| ui_task.isOnSnakeScreen() || ui_task.isOnMinesweeperScreen()
#ifdef MECK_AUDIO_VARIANT
|| ui_task.isOnAlarmScreen()
#endif
@@ -4843,6 +5169,31 @@ void handleKeyboardInput() {
Serial.println("TraceScreen: Exit -- returning to home");
ui_task.gotoHomeScreen();
}
} else if (ui_task.isOnGamesMenu()) {
ui_task.injectKey('\r');
GamesMenuScreen* gm = (GamesMenuScreen*)ui_task.getGamesMenuScreen();
if (gm && gm->wantsLaunch()) {
GameID sel = gm->selectedGame();
gm->clearFlags();
switch (sel) {
case GAME_SNAKE: ui_task.gotoSnakeScreen(); break;
case GAME_MINESWEEPER: ui_task.gotoMinesweeperScreen(); break;
// case GAME_2048: ui_task.goto2048Screen(); break;
default: break;
}
}
} else if (ui_task.isOnSnakeScreen()) {
ui_task.injectKey('\r');
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
if (ss && ss->wantsExit()) {
ui_task.gotoGamesMenu();
}
} else if (ui_task.isOnMinesweeperScreen()) {
ui_task.injectKey('\r');
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
if (ms && ms->wantsExit()) {
ui_task.gotoGamesMenu();
}
} else if (ui_task.isOnChannelPickerScreen()) {
ui_task.injectKey('\r'); // Picker handles Enter: selects channel + sets wantsExit
ChannelPickerScreen* pick = (ChannelPickerScreen*)ui_task.getChannelPickerScreen();
@@ -5022,8 +5373,12 @@ void handleKeyboardInput() {
break;
case 'f':
// Minesweeper: F toggles flag on cursor cell
if (ui_task.isOnMinesweeperScreen()) {
ui_task.injectKey('f');
}
// Start discovery scan from home/contacts screen, or rescan on discovery screen
if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) {
else if (ui_task.isOnContactsScreen() || ui_task.isOnHomeScreen()) {
Serial.println("Starting discovery scan...");
the_mesh.startDiscovery();
ui_task.gotoDiscoveryScreen();
@@ -5040,6 +5395,14 @@ void handleKeyboardInput() {
}
break;
case 'j':
// Open games menu from home screen
if (ui_task.isOnHomeScreen()) {
Serial.println("Opening games menu");
ui_task.gotoGamesMenu();
}
break;
case 'l':
// L = Login/Admin — from DM conversation, open repeater admin with auto-login
if (ui_task.isOnChannelScreen()) {
@@ -5119,6 +5482,32 @@ void handleKeyboardInput() {
}
break;
}
// Snake screen: Q goes back to games menu
if (ui_task.isOnSnakeScreen()) {
ui_task.injectKey('q');
SnakeScreen* ss = (SnakeScreen*)ui_task.getSnakeScreen();
if (ss && ss->wantsExit()) {
Serial.println("Nav: Snake -> Games Menu");
ui_task.gotoGamesMenu();
}
break;
}
// Minesweeper screen: Q goes back to games menu
if (ui_task.isOnMinesweeperScreen()) {
ui_task.injectKey('q');
MinesweeperScreen* ms = (MinesweeperScreen*)ui_task.getMinesweeperScreen();
if (ms && ms->wantsExit()) {
Serial.println("Nav: Minesweeper -> Games Menu");
ui_task.gotoGamesMenu();
}
break;
}
// Games menu: Q goes back to home
if (ui_task.isOnGamesMenu()) {
Serial.println("Nav: Games Menu -> Home");
ui_task.gotoHomeScreen();
break;
}
// Alarm screen: Q/backspace routing depends on sub-mode
#ifdef MECK_AUDIO_VARIANT
if (ui_task.isOnAlarmScreen()) {
File diff suppressed because it is too large Load Diff
@@ -148,8 +148,10 @@ public:
// Add a new message to the history
// 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,
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
_newestIdx = (_newestIdx + 1) % CHANNEL_MSG_HISTORY_SIZE;
@@ -191,8 +193,9 @@ public:
_replySelectPos = -1;
// Track unread count for this channel (only for received messages, not sent)
// path_len == 0 means locally sent
if (path_len != 0) {
// path_len == 0 means locally sent.
// 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;
if (unreadSlot >= 0 && unreadSlot <= MAX_GROUP_CHANNELS) {
_unread[unreadSlot]++;
@@ -397,6 +400,42 @@ public:
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
// -----------------------------------------------------------------------
@@ -32,7 +32,13 @@ extern MyMesh the_mesh;
// T-Deck Pro / MAX : vertical list with "> " cursor, unread badge, right-
// 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
// directly. main.cpp / UITask.cpp check the flag after injectKey().
// ---------------------------------------------------------------------------
@@ -50,12 +56,15 @@ class ChannelPickerScreen : public UIScreen {
int _cursor;
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 _cellH;
int _gridTop;
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.
void rebuildItems() {
int n = 0;
@@ -100,13 +109,14 @@ public:
: _task(task), _channelScreen(nullptr),
_itemCount(0), _cursor(0), _scrollTop(0),
_cellW(40), _cellH(12), _gridTop(14), _gridCols(3),
_confirmDelete(false),
_wantExit(false) {
_items[0] = 0xFF;
}
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 wantsExit() const { return _wantExit; }
@@ -118,6 +128,7 @@ public:
if (_items[i] == currentChannelIdx) { _cursor = i; break; }
}
_scrollTop = 0;
_confirmDelete = false;
_wantExit = false;
}
@@ -171,7 +182,7 @@ public:
_cellW = bubbleW;
_cellH = bubbleH + gap;
_gridTop = headerH;
_gridCols = 1; // Single column list mode
_gridCols = 1; // Single column -- list mode
// Centre scroll window on cursor
_scrollTop = max(0, min(_cursor - maxVisible / 2, _itemCount - maxVisible));
@@ -200,7 +211,7 @@ public:
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];
getItemName(i, name, sizeof(name));
char filtered[32];
@@ -229,7 +240,7 @@ public:
display.drawTextEllipsized(textX, textY, nameMaxW, filtered);
}
// Unread badge right-aligned inside bubble
// Unread badge -- right-aligned inside bubble
if (unread > 0) {
int bx = x + w - badgeW;
display.setCursor(bx, textY);
@@ -329,6 +340,47 @@ public:
}
#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 ===
display.setTextSize(1);
int footerY = display.height() - 12;
@@ -337,20 +389,31 @@ public:
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap:Open");
const char* rt = "Boot:Back";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
if (_confirmDelete) {
display.print("Tap:Yes");
const char* rt = "Boot:Cancel";
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)
display.print("Q:Bk");
const char* rt = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
#else
display.print("W/S:Nav Q:Back");
const char* rt = "Ent:Open";
display.setCursor(display.width() - display.getTextWidth(rt) - 2, footerY);
display.print(rt);
if (_confirmDelete) {
display.print("Enter:Yes Q:Cancel");
} else {
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
#ifdef USE_EINK
@@ -364,6 +427,30 @@ public:
// Input
// -----------------------------------------------------------------------
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
if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) {
if (_cursor > 0) { _cursor--; return true; }
@@ -376,7 +463,7 @@ public:
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) {
return true;
}
@@ -384,16 +471,24 @@ public:
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 (_channelScreen && _cursor >= 0 && _cursor < _itemCount) {
_channelScreen->setViewChannelIdx(_items[_cursor]);
}
_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) {
_wantExit = true;
return true;
@@ -405,10 +500,22 @@ public:
// -----------------------------------------------------------------------
// Touch hit test (virtual coordinates)
// Returns: 0=miss, 1=cursor moved, 2=activate.
// T5S3 bubbles: any tap on a bubble 2 (direct open).
// T-Deck Pro list: 1st tap 1 (highlight), 2nd tap same row 2.
// T5S3 bubbles: any tap on a bubble -> 2 (direct open).
// T-Deck Pro list: 1st tap -> 1 (highlight), 2nd tap same row -> 2.
// -----------------------------------------------------------------------
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)
// Vertical bubble list hit test
if (vy < _gridTop || _cellH == 0) return 0;
@@ -420,7 +527,7 @@ public:
_cursor = idx;
return 2; // Direct open on tap
#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();
int lineH = prefs->smallLineH();
const int headerH = 14;
@@ -0,0 +1,171 @@
#pragma once
// =============================================================================
// GamesMenuScreen -- Game launcher menu for Meck
//
// Lists available games. W/S to navigate, Enter to launch, Q to exit.
// Uses wantsExit() and wantsLaunch() flags for navigation -- same pattern
// as ChannelPickerScreen.
// =============================================================================
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
// Forward declarations
class UITask;
// Game identifiers -- add new entries here as games are added
enum GameID {
GAME_NONE = 0,
GAME_SNAKE,
GAME_MINESWEEPER,
// GAME_2048,
GAME_COUNT // Must be last -- used for array sizing
};
class GamesMenuScreen : public UIScreen {
private:
UITask* _task;
bool _wantsExit;
bool _wantsLaunch;
int _cursor;
GameID _selectedGame;
// Game registry -- add new games here
struct GameEntry {
GameID id;
const char* name;
const char* description;
};
static constexpr int NUM_GAMES = 2; // Increment as games are added
static const GameEntry* getGames() {
static const GameEntry games[NUM_GAMES] = {
{ GAME_SNAKE, "Snake", "Classic Nokia-style" },
{ GAME_MINESWEEPER, "Minesweeper", "Find the mines" },
// { GAME_2048, "2048", "Slide and merge" },
};
return games;
}
public:
GamesMenuScreen(UITask* task)
: _task(task), _wantsExit(false), _wantsLaunch(false),
_cursor(0), _selectedGame(GAME_NONE) {}
bool wantsExit() const { return _wantsExit; }
bool wantsLaunch() const { return _wantsLaunch; }
GameID selectedGame() const { return _selectedGame; }
void enter() {
_wantsExit = false;
_wantsLaunch = false;
_selectedGame = GAME_NONE;
// Preserve cursor position so returning from a game stays on the same entry
}
void clearFlags() {
_wantsExit = false;
_wantsLaunch = false;
_selectedGame = GAME_NONE;
}
// ------- Input -------
bool handleInput(char c) override {
switch (c) {
case 'w': case 'W':
if (_cursor > 0) _cursor--;
return true;
case 's': case 'S':
if (_cursor < NUM_GAMES - 1) _cursor++;
return true;
case '\r':
_selectedGame = getGames()[_cursor].id;
_wantsLaunch = true;
return true;
case 'q': case 'Q':
_wantsExit = true;
return true;
default:
return false;
}
}
// ------- Render -------
int render(DisplayDriver& display) override {
display.startFrame();
display.setTextSize(1);
// --- Header ---
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, 2, "Games");
#else
display.setCursor(2, 2);
display.print("Games");
#endif
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 12, display.width(), 1);
// --- Game list ---
int y = 18;
int lineH = 16;
for (int i = 0; i < NUM_GAMES; i++) {
bool selected = (i == _cursor);
if (selected) {
// Highlight bar
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y - 1, display.width(), lineH);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, y + 2, getGames()[i].name);
#else
display.setCursor(6, y + 2);
display.print(getGames()[i].name);
#endif
y += lineH;
}
// --- Footer ---
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, display.height() - 8, "Tap to select");
display.setTextSize(1);
#else
display.setTextSize(1);
int fy = display.height() - 12;
display.drawRect(0, fy - 2, display.width(), 1);
display.setCursor(2, fy);
display.print("Enter:Play Q:Back");
#endif
return 5000; // Static menu -- slow refresh
}
// --- T5S3 touch: tap to select game entry ---
int selectRowAtVY(int vy) {
int y = 18;
int lineH = 16;
if (vy < y) return 0; // Above list
int row = (vy - y) / lineH;
if (row >= NUM_GAMES) return 0; // Below list
if (row == _cursor) {
// Tapped current selection -- launch
_selectedGame = getGames()[_cursor].id;
_wantsLaunch = true;
return 2;
}
_cursor = row;
return 1; // Moved cursor
}
};
@@ -0,0 +1,466 @@
#pragma once
// =============================================================================
// MinesweeperScreen -- Classic Minesweeper for Meck e-ink devices
//
// 9x9 grid, 10 mines (classic Beginner difficulty).
// First reveal is always safe -- mines are placed after the first click.
// Fully turn-based: no tick timer, renders only on input. Perfect for e-ink.
//
// T-Deck Pro: 14x14 pixel cells (126x126 grid area on 240x320 display)
// T5S3: 8x8 pixel cells (72x72 grid area on 128x128 virtual display)
// =============================================================================
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
// Forward declarations
class UITask;
// -- Grid parameters --
#define MINE_GRID_W 9
#define MINE_GRID_H 9
#define MINE_COUNT 10
#define MINE_TOTAL (MINE_GRID_W * MINE_GRID_H)
// -- Cell sizes per platform --
#if defined(LilyGo_T5S3_EPaper_Pro)
#define MINE_CELL 8
#define MINE_HDR 14
#define MINE_FTR 10
#else
#define MINE_CELL 14
#define MINE_HDR 14
#define MINE_FTR 14
#endif
#define MINE_VALUE 9 // Content value indicating a mine
class MinesweeperScreen : public UIScreen {
public:
enum GameState { READY, PLAYING, WON, LOST };
enum CellState : uint8_t { CELL_HIDDEN, CELL_REVEALED, CELL_FLAGGED };
private:
UITask* _task;
bool _wantsExit;
// Grid
uint8_t _content[MINE_TOTAL]; // 0-8 = adjacent mine count, MINE_VALUE = mine
CellState _cellState[MINE_TOTAL]; // Hidden, revealed, or flagged
// Game state
GameState _state;
bool _minesPlaced; // False until first reveal (first-click safety)
int _cursorX, _cursorY;
int _flagCount;
int _revealedCount;
// Display layout (recomputed each render based on game state)
int _offsetX, _offsetY;
bool _cursorBlink; // Toggles each render for slow blink cursor
// Simple xorshift PRNG
uint16_t _rngState;
uint16_t rng() {
_rngState ^= _rngState << 7;
_rngState ^= _rngState >> 9;
_rngState ^= _rngState << 8;
return _rngState;
}
// -- Grid helpers --
int idx(int x, int y) const { return y * MINE_GRID_W + x; }
bool inBounds(int x, int y) const { return x >= 0 && x < MINE_GRID_W && y >= 0 && y < MINE_GRID_H; }
void resetGrid() {
memset(_content, 0, sizeof(_content));
for (int i = 0; i < MINE_TOTAL; i++) _cellState[i] = CELL_HIDDEN;
_minesPlaced = false;
_flagCount = 0;
_revealedCount = 0;
_cursorX = MINE_GRID_W / 2;
_cursorY = MINE_GRID_H / 2;
}
// Place mines randomly, excluding the first-clicked cell and its neighbours
void placeMines(int safeX, int safeY) {
_rngState = (uint16_t)(millis() ^ 0xC0DE);
int placed = 0;
while (placed < MINE_COUNT) {
int x = rng() % MINE_GRID_W;
int y = rng() % MINE_GRID_H;
// Skip safe zone (first click + 8 neighbours)
if (abs(x - safeX) <= 1 && abs(y - safeY) <= 1) continue;
// Skip if already a mine
if (_content[idx(x, y)] == MINE_VALUE) continue;
_content[idx(x, y)] = MINE_VALUE;
placed++;
}
// Compute adjacency counts
for (int cy = 0; cy < MINE_GRID_H; cy++) {
for (int cx = 0; cx < MINE_GRID_W; cx++) {
if (_content[idx(cx, cy)] == MINE_VALUE) continue;
int count = 0;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = cx + dx, ny = cy + dy;
if (inBounds(nx, ny) && _content[idx(nx, ny)] == MINE_VALUE) count++;
}
}
_content[idx(cx, cy)] = count;
}
}
_minesPlaced = true;
}
// Flood-fill reveal from (x,y). Reveals empty cells and their numbered borders.
void floodReveal(int x, int y) {
if (!inBounds(x, y)) return;
int i = idx(x, y);
if (_cellState[i] != CELL_HIDDEN) return;
if (_content[i] == MINE_VALUE) return;
_cellState[i] = CELL_REVEALED;
_revealedCount++;
// If this cell is 0 (no adjacent mines), reveal all neighbours
if (_content[i] == 0) {
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
floodReveal(x + dx, y + dy);
}
}
}
}
// Reveal a cell. Returns false if mine was hit.
bool revealCell(int x, int y) {
int i = idx(x, y);
if (_cellState[i] != CELL_HIDDEN) return true; // Already revealed or flagged
// First click: place mines safely
if (!_minesPlaced) {
placeMines(x, y);
}
if (_content[i] == MINE_VALUE) {
// Hit a mine -- game over
_cellState[i] = CELL_REVEALED;
_state = LOST;
revealAllMines();
return false;
}
floodReveal(x, y);
checkWin();
return true;
}
void toggleFlag(int x, int y) {
int i = idx(x, y);
if (_cellState[i] == CELL_HIDDEN) {
_cellState[i] = CELL_FLAGGED;
_flagCount++;
} else if (_cellState[i] == CELL_FLAGGED) {
_cellState[i] = CELL_HIDDEN;
_flagCount--;
}
// Can't flag revealed cells
}
void checkWin() {
// Win when all non-mine cells are revealed
if (_revealedCount == MINE_TOTAL - MINE_COUNT) {
_state = WON;
// Auto-flag remaining mines
for (int i = 0; i < MINE_TOTAL; i++) {
if (_content[i] == MINE_VALUE && _cellState[i] == CELL_HIDDEN) {
_cellState[i] = CELL_FLAGGED;
_flagCount++;
}
}
}
}
void revealAllMines() {
for (int i = 0; i < MINE_TOTAL; i++) {
if (_content[i] == MINE_VALUE) {
_cellState[i] = CELL_REVEALED;
}
}
}
// -- Drawing helpers --
void drawCell(DisplayDriver& display, int gx, int gy) const {
int px = _offsetX + gx * MINE_CELL;
int py = _offsetY + gy * MINE_CELL;
int i = idx(gx, gy);
bool isCursor = (gx == _cursorX && gy == _cursorY && _state == PLAYING);
if (_cellState[i] == CELL_HIDDEN) {
if (isCursor && !_cursorBlink) {
// Cursor blink OFF phase: outline only (visible gap in the solid grid)
display.setColor(DisplayDriver::LIGHT);
display.drawRect(px, py, MINE_CELL, MINE_CELL);
display.drawRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2);
} else {
// Solid filled with 1px inset (preserves grid lines between cells)
display.setColor(DisplayDriver::LIGHT);
display.fillRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2);
}
} else if (_cellState[i] == CELL_FLAGGED) {
if (isCursor && !_cursorBlink) {
// Cursor blink OFF phase: outline with F
display.setColor(DisplayDriver::LIGHT);
display.drawRect(px, py, MINE_CELL, MINE_CELL);
display.drawRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2);
} else {
// Solid fill with 1px inset
display.setColor(DisplayDriver::LIGHT);
display.fillRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2);
}
// F overlay
display.setColor((isCursor && !_cursorBlink) ? DisplayDriver::LIGHT : DisplayDriver::DARK);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(px + MINE_CELL / 2, py + 1, "F");
#else
display.setTextSize(1);
display.drawTextCentered(px + MINE_CELL / 2, py + 3, "F");
#endif
} else {
// Revealed cell: thin border
display.setColor(DisplayDriver::LIGHT);
display.drawRect(px, py, MINE_CELL, MINE_CELL);
if (_content[i] == MINE_VALUE) {
// Mine: solid dot in centre
int dotR = MINE_CELL / 4;
if (dotR < 2) dotR = 2;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(px + MINE_CELL/2 - dotR, py + MINE_CELL/2 - dotR, dotR*2, dotR*2);
} else if (_content[i] > 0) {
// Number 1-8
char num[2] = { (char)('0' + _content[i]), '\0' };
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setTextSize(0);
display.drawTextCentered(px + MINE_CELL / 2, py + 1, num);
#else
display.setTextSize(1);
display.drawTextCentered(px + MINE_CELL / 2, py + 3, num);
#endif
}
// Revealed 0: just the border (already drawn)
// Cursor on revealed cell: green double border
if (isCursor) {
display.setColor(DisplayDriver::GREEN);
display.drawRect(px, py, MINE_CELL, MINE_CELL);
display.drawRect(px + 1, py + 1, MINE_CELL - 2, MINE_CELL - 2);
}
}
}
public:
MinesweeperScreen(UITask* task)
: _task(task), _wantsExit(false), _state(READY), _minesPlaced(false),
_cursorX(MINE_GRID_W / 2), _cursorY(MINE_GRID_H / 2),
_flagCount(0), _revealedCount(0),
_offsetX(0), _offsetY(0), _cursorBlink(false), _rngState(0xBEEF) {
memset(_content, 0, sizeof(_content));
for (int i = 0; i < MINE_TOTAL; i++) _cellState[i] = CELL_HIDDEN;
}
bool wantsExit() const { return _wantsExit; }
void clearExit() { _wantsExit = false; }
GameState getState() const { return _state; }
void enter() {
_wantsExit = false;
// If game was PLAYING, resume where we left off
// If READY, WON, or LOST, show that state as-is
}
// ------- Input -------
bool handleInput(char c) override {
switch (_state) {
case READY:
if (c == '\r') {
resetGrid();
_state = PLAYING;
return true;
}
if (c == 'q' || c == 'Q') { _wantsExit = true; return true; }
return false;
case PLAYING:
switch (c) {
case 'w': case 'W': if (_cursorY > 0) _cursorY--; return true;
case 's': case 'S': if (_cursorY < MINE_GRID_H - 1) _cursorY++; return true;
case 'a': case 'A': if (_cursorX > 0) _cursorX--; return true;
case 'd': case 'D': if (_cursorX < MINE_GRID_W - 1) _cursorX++; return true;
case '\r':
revealCell(_cursorX, _cursorY);
return true;
case 'f': case 'F':
toggleFlag(_cursorX, _cursorY);
return true;
case 'q': case 'Q':
_wantsExit = true;
return true;
default: return false;
}
case WON:
case LOST:
if (c == '\r') {
resetGrid();
_state = PLAYING;
return true;
}
if (c == 'q' || c == 'Q') { _wantsExit = true; return true; }
return false;
}
return false;
}
// ------- Render -------
int render(DisplayDriver& display) override {
// Compute grid offset based on state
// PLAYING: full screen (no header/footer) for clean grid
// Other states: header + footer visible
int gridPixW = MINE_GRID_W * MINE_CELL;
int gridPixH = MINE_GRID_H * MINE_CELL;
if (_state == PLAYING) {
_offsetX = (display.width() - gridPixW) / 2;
_offsetY = (display.height() - gridPixH) / 2;
} else {
int usableH = display.height() - MINE_HDR - MINE_FTR;
_offsetX = (display.width() - gridPixW) / 2;
_offsetY = MINE_HDR + (usableH - gridPixH) / 2;
}
// Toggle cursor blink each render cycle
if (_state == PLAYING) _cursorBlink = !_cursorBlink;
display.startFrame();
display.setTextSize(1);
if (_state == READY) {
// --- READY: header + instructions + footer ---
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, 2, "Minesweeper");
#else
display.setCursor(2, 2);
display.print("Minesweeper");
#endif
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, MINE_HDR - 2, display.width(), 1);
int cx = display.width() / 2;
int y = MINE_HDR + 10;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(cx, y, "Minesweeper");
y += 16;
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Swipe to move cursor");
y += 11;
display.drawTextCentered(cx, y, "Tap to reveal");
y += 11;
display.drawTextCentered(cx, y, "Long press to flag");
#else
display.drawTextCentered(cx, y, "W/S/A/D to move cursor");
y += 11;
display.drawTextCentered(cx, y, "Enter to reveal a cell");
y += 11;
display.drawTextCentered(cx, y, "F to flag a mine");
#endif
y += 16;
display.setColor(DisplayDriver::LIGHT);
char info[32];
snprintf(info, sizeof(info), "%dx%d grid, %d mines", MINE_GRID_W, MINE_GRID_H, MINE_COUNT);
display.drawTextCentered(cx, y, info);
y += 16;
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Tap to start");
#else
display.drawTextCentered(cx, y, "Press Enter to start");
#endif
// Footer
display.setColor(DisplayDriver::LIGHT);
#if !defined(LilyGo_T5S3_EPaper_Pro)
int fy = display.height() - 12;
display.drawRect(0, fy - 2, display.width(), 1);
display.setCursor(2, fy);
display.print("Enter:Start Q:Back");
#endif
return 5000;
} else if (_state == PLAYING) {
// --- PLAYING: full screen grid, no header/footer ---
// Draw grid border
display.setColor(DisplayDriver::LIGHT);
display.drawRect(_offsetX - 1, _offsetY - 1, gridPixW + 2, gridPixH + 2);
// Draw all cells
for (int gy = 0; gy < MINE_GRID_H; gy++) {
for (int gx = 0; gx < MINE_GRID_W; gx++) {
drawCell(display, gx, gy);
}
}
return 100; // Blink cycle -- clamped to 800ms by render floor
} else {
// --- WON / LOST: grid + overlay ---
// Draw grid border
display.setColor(DisplayDriver::LIGHT);
display.drawRect(_offsetX - 1, _offsetY - 1, gridPixW + 2, gridPixH + 2);
// Draw all cells (no cursor blink in end state)
for (int gy = 0; gy < MINE_GRID_H; gy++) {
for (int gx = 0; gx < MINE_GRID_W; gx++) {
drawCell(display, gx, gy);
}
}
// Overlay
int cx = display.width() / 2;
int cy = display.height() / 2;
int boxW = display.width() * 3 / 4;
int boxH = 50;
int boxX = cx - boxW / 2;
int boxY = cy - boxH / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(boxX, boxY, boxW, boxH);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(boxX, boxY, boxW, boxH);
int ty = boxY + 10;
if (_state == WON) {
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(cx, ty, "Cleared!");
} else {
display.drawTextCentered(cx, ty, "Boom!");
}
ty += 16;
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(cx, ty, "Enter:Retry Q:Back");
return 5000;
}
}
};
File diff suppressed because it is too large Load Diff
+280 -33
View File
@@ -37,6 +37,10 @@ void ModemManager::begin() {
_ringing = false;
_nextRingTone = 0;
_toneActive = false;
_pendingToneIdx = -1;
_tonesTransferred = false;
_notifTonePlaying = false;
_notifToneStartTime = 0;
_urcPos = 0;
_imei[0] = '\0';
_imsi[0] = '\0';
@@ -67,6 +71,12 @@ void ModemManager::shutdown() {
MESH_DEBUG_PRINTLN("[Modem] shutdown()");
// Stop any playing notification tone
if (_notifTonePlaying) {
// Best-effort stop -- task is about to be deleted
_pendingToneIdx = -1;
}
// Hang up any active call first
if (isCallActive()) {
CallCommand cmd;
@@ -171,6 +181,38 @@ bool ModemManager::pollCallEvent(CallEvent& out) {
return xQueueReceive(_callEvtQueue, &out, 0) == pdTRUE;
}
// ---------------------------------------------------------------------------
// Public API - Notification Tones
// ---------------------------------------------------------------------------
void ModemManager::requestNotifTone(int8_t toneIdx) {
if (toneIdx < 0 || toneIdx >= MODEM_BUNDLED_TONE_COUNT) return;
if (!_tonesTransferred) return; // Not ready yet
if (isCallActive()) return; // Don't interrupt voice calls
_pendingToneIdx = toneIdx;
}
int8_t ModemManager::findToneByName(const char* name) {
if (!name || !name[0]) return -1;
for (int i = 0; i < MODEM_BUNDLED_TONE_COUNT; i++) {
// Match against filename (with or without .wav extension)
const char* fn = modemBundledTones[i].filename;
if (strcasecmp(name, fn) == 0) return i;
// Try matching without extension
const char* dot = strrchr(fn, '.');
if (dot) {
int baseLen = dot - fn;
if ((int)strlen(name) == baseLen && strncasecmp(name, fn, baseLen) == 0) return i;
}
// Also match against label
if (strcasecmp(name, modemBundledTones[i].label) == 0) return i;
}
return -1;
}
// ---------------------------------------------------------------------------
// State helpers
// ---------------------------------------------------------------------------
@@ -262,11 +304,11 @@ void ModemManager::saveAPNConfig(const char* apn) {
}
// ---------------------------------------------------------------------------
// APN Resolution called during init after network registration
// APN Resolution -- called during init after network registration
//
// Priority:
// 1. User-configured APN (from /sms/apn.cfg)
// 2. Network-provisioned APN (AT+CGDCONT? modem already has one)
// 2. Network-provisioned APN (AT+CGDCONT? -- modem already has one)
// 3. Auto-detected from IMSI via embedded ApnDatabase
// 4. Blank (some carriers work with empty APN)
// ---------------------------------------------------------------------------
@@ -327,7 +369,7 @@ void ModemManager::resolveAPN() {
}
}
// 4. No APN found leave blank
// 4. No APN found -- leave blank
_apn[0] = '\0';
strcpy(_apnSource, "none");
MESH_DEBUG_PRINTLN("[Modem] APN: none detected (IMSI=%s)", _imsi[0] ? _imsi : "unknown");
@@ -337,12 +379,13 @@ void ModemManager::resolveAPN() {
// URC (Unsolicited Result Code) Handling
// ---------------------------------------------------------------------------
// The modem can send unsolicited messages at any time:
// RING incoming call ringing
// +CLIP: "+1234...",145,... caller ID (after AT+CLIP=1)
// NO CARRIER call ended by remote
// BUSY outgoing call busy
// NO ANSWER outgoing call no answer
// +CMTI: "SM",<idx> new SMS arrived
// RING -- incoming call ringing
// +CLIP: "+1234...",145,... -- caller ID (after AT+CLIP=1)
// NO CARRIER -- call ended by remote
// BUSY -- outgoing call busy
// NO ANSWER -- outgoing call no answer
// +CMTI: "SM",<idx> -- new SMS arrived
// +AUDIOSTATE: audio play stop -- notification tone finished
//
// drainURCs() accumulates bytes into a line buffer and calls
// processURCLine() for each complete line.
@@ -354,7 +397,7 @@ void ModemManager::drainURCs() {
// Accumulate into line buffer
if (c == '\n') {
// End of line process if non-empty
// End of line -- process if non-empty
if (_urcPos > 0) {
// Trim trailing \r
while (_urcPos > 0 && _urcBuf[_urcPos - 1] == '\r') _urcPos--;
@@ -418,7 +461,7 @@ void ModemManager::processURCLine(const char* line) {
if (strcmp(line, "NO CARRIER") == 0) {
MESH_DEBUG_PRINTLN("[Modem] URC: NO CARRIER");
if (_state == ModemState::RINGING_IN) {
// Incoming call ended before we answered missed call
// Incoming call ended before we answered -- missed call
queueCallEvent(CallEventType::MISSED, _callPhone);
} else if (_state == ModemState::DIALING || _state == ModemState::IN_CALL) {
uint32_t duration = 0;
@@ -465,7 +508,7 @@ void ModemManager::processURCLine(const char* line) {
return;
}
// --- VOICE CALL: BEGIN A76xx-specific: audio path established ---
// --- VOICE CALL: BEGIN -- A76xx-specific: audio path established ---
if (strncmp(line, "VOICE CALL: BEGIN", 17) == 0) {
MESH_DEBUG_PRINTLN("[Modem] URC: VOICE CALL: BEGIN");
if (_state == ModemState::DIALING) {
@@ -477,7 +520,7 @@ void ModemManager::processURCLine(const char* line) {
return;
}
// --- VOICE CALL: END A76xx-specific: audio path closed ---
// --- VOICE CALL: END -- A76xx-specific: audio path closed ---
// Format: "VOICE CALL: END: <duration>"
if (strncmp(line, "VOICE CALL: END", 15) == 0) {
MESH_DEBUG_PRINTLN("[Modem] URC: %s", line);
@@ -502,6 +545,20 @@ void ModemManager::processURCLine(const char* line) {
_callStartTime = 0;
return;
}
// --- +AUDIOSTATE: notification tone playback status ---
// +AUDIOSTATE: audio play -- playback started
// +AUDIOSTATE: audio play stop -- playback finished
if (strncmp(line, "+AUDIOSTATE:", 12) == 0) {
if (strstr(line, "play stop") || strstr(line, "PLAY STOP")) {
MESH_DEBUG_PRINTLN("[Modem] URC: AUDIOSTATE play stop");
_notifTonePlaying = false;
} else if (strstr(line, "play") || strstr(line, "PLAY")) {
MESH_DEBUG_PRINTLN("[Modem] URC: AUDIOSTATE play");
// Playback confirmed started -- _notifTonePlaying already set
}
return;
}
}
void ModemManager::queueCallEvent(CallEventType type, const char* phone, uint32_t duration) {
@@ -526,7 +583,7 @@ bool ModemManager::doDialCall(const char* phone) {
_callPhone[SMS_PHONE_LEN - 1] = '\0';
_state = ModemState::DIALING;
// ATD<number>; the semicolon makes it a voice call (not data)
// ATD<number>; -- the semicolon makes it a voice call (not data)
char cmd[32];
snprintf(cmd, sizeof(cmd), "ATD%s;", phone);
@@ -538,11 +595,11 @@ bool ModemManager::doDialCall(const char* phone) {
return false;
}
// ATD returned OK call is being set up.
// ATD returned OK -- call is being set up.
// Connection/failure will come as URCs (NO CARRIER, BUSY, etc.)
// or we detect active call via AT+CLCC polling.
// For now, assume we're dialing and wait for URCs.
MESH_DEBUG_PRINTLN("[Modem] ATD OK dialing...");
MESH_DEBUG_PRINTLN("[Modem] ATD OK -- dialing...");
return true;
}
@@ -610,8 +667,8 @@ bool ModemManager::doSetVolume(uint8_t level) {
}
// ---------------------------------------------------------------------------
// Incoming call ringtone tone bursts via AT+SIMTONE on modem speaker
// Pattern: 400ms tone 1200ms silence repeat
// Incoming call ringtone -- tone bursts via AT+SIMTONE on modem speaker
// Pattern: 400ms tone -> 1200ms silence -> repeat
// ---------------------------------------------------------------------------
void ModemManager::handleRingtone() {
@@ -643,12 +700,192 @@ void ModemManager::handleRingtone() {
_toneActive = true;
_nextRingTone = now + 400; // Tone plays for 400ms
} else {
// Tone just finished gap before next burst
// Tone just finished -- gap before next burst
_toneActive = false;
_nextRingTone = now + 1200; // 1.2s silence (classic ring cadence)
}
}
// ---------------------------------------------------------------------------
// Notification Tone Transfer and Playback
// ---------------------------------------------------------------------------
// Transfers embedded WAV files from PROGMEM to the modem's internal
// filesystem using AT+CFTRANRX (confirmed via probe), then plays them
// on demand via AT+CCMXPLAY through the modem's speaker amplifier.
//
// Confirmed working commands on A7682E:
// AT+FSMEM -- filesystem space query
// AT+FSDEL="C:/file" -- delete file
// AT+CFTRANRX="C:/file",size -- write file (modem responds CONNECT)
// AT+CCMXPLAY="C:/file",0,0 -- play audio
// AT+CCMXSTOP -- stop audio
// AT+CRSL=n -- ringer volume (0-20)
// ---------------------------------------------------------------------------
bool ModemManager::transferTonesToModem() {
MESH_DEBUG_PRINTLN("[Modem] Transferring %d notification tones to modem...", MODEM_BUNDLED_TONE_COUNT);
// Verify filesystem is accessible
if (sendAT("AT+FSMEM", "OK", 3000)) {
MESH_DEBUG_PRINTLN("[Modem] Filesystem: %s", _atBuf);
} else {
MESH_DEBUG_PRINTLN("[Modem] FSMEM failed -- modem filesystem not accessible");
_tonesTransferred = true; // Don't retry every boot
return false;
}
int successCount = 0;
for (int i = 0; i < MODEM_BUNDLED_TONE_COUNT; i++) {
const ModemToneEntry& tone = modemBundledTones[i];
// Build modem filesystem path
char modemPath[48];
snprintf(modemPath, sizeof(modemPath), "C:/%s", tone.filename);
// Delete any existing file first (AT+FSDEL, ignore errors if not found)
char delCmd[64];
snprintf(delCmd, sizeof(delCmd), "AT+FSDEL=\"%s\"", modemPath);
sendAT(delCmd, "OK", 2000);
// Small gap to let modem settle between delete and write
vTaskDelay(pdMS_TO_TICKS(100));
// Drain any stale UART data before transfer
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
// Transfer file via AT+CFTRANRX="path",<size>
// Modem responds with CONNECT, then expects <size> bytes of binary data,
// then responds with OK.
char txCmd[80];
snprintf(txCmd, sizeof(txCmd), "AT+CFTRANRX=\"%s\",%d", modemPath, (int)tone.size);
MESH_DEBUG_PRINTLN("[Modem] Tone %d/%d: %s (%d bytes) -- sending...",
i + 1, MODEM_BUNDLED_TONE_COUNT, tone.filename, (int)tone.size);
Serial.printf("[Modem] TX: %s\n", txCmd);
MODEM_SERIAL.println(txCmd);
// Wait for CONNECT prompt (case-insensitive, also check for ">")
unsigned long start = millis();
bool gotPrompt = false;
bool gotError = false;
char promptBuf[128];
int ppos = 0;
while (millis() - start < 8000) {
while (MODEM_SERIAL.available()) {
char c = MODEM_SERIAL.read();
if (ppos < 127) { promptBuf[ppos++] = c; promptBuf[ppos] = '\0'; }
// Check for any known data-ready prompts
if (strstr(promptBuf, "CONNECT") || strstr(promptBuf, "connect") ||
c == '>') {
gotPrompt = true;
break;
}
if (strstr(promptBuf, "ERROR")) { gotError = true; break; }
}
if (gotPrompt || gotError) break;
vTaskDelay(pdMS_TO_TICKS(10));
}
if (!gotPrompt) {
// Log whatever we DID receive for debugging
MESH_DEBUG_PRINTLN("[Modem] Tone %d: no CONNECT/> prompt (got: [%s])",
i + 1, ppos > 0 ? promptBuf : "TIMEOUT");
// Drain UART and recover
vTaskDelay(pdMS_TO_TICKS(1000));
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
continue;
}
MESH_DEBUG_PRINTLN("[Modem] Tone %d: got prompt, sending %d bytes...", i + 1, (int)tone.size);
// Send the binary WAV data from PROGMEM in chunks
const uint8_t* src = tone.data;
size_t remaining = tone.size;
const size_t CHUNK_SIZE = 256;
while (remaining > 0) {
size_t chunk = (remaining > CHUNK_SIZE) ? CHUNK_SIZE : remaining;
uint8_t buf[CHUNK_SIZE];
memcpy_P(buf, src, chunk);
MODEM_SERIAL.write(buf, chunk);
src += chunk;
remaining -= chunk;
// Brief yield to avoid starving other tasks during large transfers
if (remaining > 0) vTaskDelay(pdMS_TO_TICKS(5));
}
// Wait for OK response after transfer completes
if (waitResponse("OK", 15000, _atBuf, AT_BUF_SIZE)) {
MESH_DEBUG_PRINTLN("[Modem] Tone %d: %s transferred OK", i + 1, tone.filename);
successCount++;
} else {
MESH_DEBUG_PRINTLN("[Modem] Tone %d: %s transfer FAILED: %s", i + 1, tone.filename, _atBuf);
// Drain UART to recover modem state
vTaskDelay(pdMS_TO_TICKS(1000));
while (MODEM_SERIAL.available()) MODEM_SERIAL.read();
}
// Delay between transfers to let modem flush to storage
vTaskDelay(pdMS_TO_TICKS(200));
}
_tonesTransferred = (successCount > 0);
MESH_DEBUG_PRINTLN("[Modem] Notification tones: %d/%d transferred", successCount, MODEM_BUNDLED_TONE_COUNT);
return (successCount == MODEM_BUNDLED_TONE_COUNT);
}
bool ModemManager::playModemTone(const char* filename) {
// AT+CCMXPLAY="C:/filename.wav",0,0
// param 1: 0 = local speaker output
// param 2: 0 = no repeat
char cmd[64];
snprintf(cmd, sizeof(cmd), "AT+CCMXPLAY=\"C:/%s\",0,0", filename);
bool ok = sendAT(cmd, "OK", 2000);
if (ok) {
_notifTonePlaying = true;
_notifToneStartTime = millis();
MESH_DEBUG_PRINTLN("[Modem] Playing tone: %s", filename);
} else {
MESH_DEBUG_PRINTLN("[Modem] CCMXPLAY failed: %s", filename);
}
return ok;
}
bool ModemManager::stopModemTone() {
if (!_notifTonePlaying) return true;
bool ok = sendAT("AT+CCMXSTOP", "OK", 1000);
_notifTonePlaying = false;
MESH_DEBUG_PRINTLN("[Modem] Tone stop %s", ok ? "OK" : "FAIL");
return ok;
}
void ModemManager::handleNotifTone() {
// Auto-stop if playback has been running too long (safety net)
if (_notifTonePlaying &&
(millis() - _notifToneStartTime) > NOTIF_TONE_TIMEOUT_MS) {
MESH_DEBUG_PRINTLN("[Modem] Tone playback timeout -- stopping");
stopModemTone();
}
// Check for pending tone request from main loop
int8_t idx = _pendingToneIdx;
if (idx < 0) return;
_pendingToneIdx = -1; // Consume the request
if (idx >= MODEM_BUNDLED_TONE_COUNT) return;
// Stop any currently playing tone before starting new one
if (_notifTonePlaying) {
stopModemTone();
}
// Don't play during active calls
if (isCallActive()) return;
playModemTone(modemBundledTones[idx].filename);
}
// ---------------------------------------------------------------------------
// FreeRTOS Task
// ---------------------------------------------------------------------------
@@ -683,7 +920,7 @@ restart:
vTaskDelay(pdMS_TO_TICKS(500));
}
if (!atOk) {
MESH_DEBUG_PRINTLN("[Modem] AT check failed retry from power-on in 30s");
MESH_DEBUG_PRINTLN("[Modem] AT check failed -- retry from power-on in 30s");
_state = ModemState::ERROR;
vTaskDelay(pdMS_TO_TICKS(30000));
goto restart;
@@ -705,7 +942,7 @@ restart:
MESH_DEBUG_PRINTLN("[Modem] IMEI: %s", _imei);
}
// IMSI (International Mobile Subscriber Identity) for APN auto-detection
// IMSI (International Mobile Subscriber Identity) -- for APN auto-detection
if (sendAT("AT+CIMI", "OK", 3000)) {
char* p = _atBuf;
while (*p && !isdigit(*p)) p++;
@@ -732,7 +969,7 @@ restart:
sendAT("AT+CLIP=1", "OK");
// Set audio output to loudspeaker mode (device speaker)
// 1=earpiece, 3=loudspeaker use loudspeaker for T-Deck Pro
// 1=earpiece, 3=loudspeaker -- use loudspeaker for T-Deck Pro
sendAT("AT+CSDVC=3", "OK", 1000);
// Set initial call volume (mid-level)
@@ -805,7 +1042,7 @@ restart:
// Initial signal query
pollCSQ();
// Resolve APN (user config network provisioned IMSI auto-detect)
// Resolve APN (user config -> network provisioned -> IMSI auto-detect)
resolveAPN();
// Sync ESP32 system clock from modem network time
@@ -857,6 +1094,11 @@ restart:
MESH_DEBUG_PRINTLN("[Modem] READY (CSQ=%d, operator=%s, APN=%s [%s], IMEI=%s)",
_csq, _operator, _apn[0] ? _apn : "(none)", _apnSource, _imei);
// ---- Phase 3b: Transfer notification tones to modem filesystem ----
// Done after READY so modem is fully initialised. Non-blocking for the
// mesh -- runs on Core 0 modem task. Uses AT+FSDEL + AT+CFTRANRX.
transferTonesToModem();
// ---- Phase 4: Main loop ----
unsigned long lastCSQPoll = 0;
unsigned long lastSMSPoll = 0;
@@ -867,17 +1109,22 @@ restart:
while (true) {
// ================================================================
// Step 1: Drain URCs catch RING, NO CARRIER, +CLIP, etc.
// Step 1: Drain URCs -- catch RING, NO CARRIER, +CLIP, +AUDIOSTATE
// This must run every iteration to avoid missing time-sensitive
// events like incoming calls or call-ended notifications.
// ================================================================
drainURCs();
// ================================================================
// Step 1b: Ringtone play tone bursts while incoming call rings
// Step 1b: Ringtone -- play tone bursts while incoming call rings
// ================================================================
handleRingtone();
// ================================================================
// Step 1c: Notification tone -- play/stop requested tones
// ================================================================
handleNotifTone();
// ================================================================
// Step 2: Process call commands from main loop
// ================================================================
@@ -888,7 +1135,7 @@ restart:
if (_state == ModemState::READY) {
doDialCall(callCmd.phone);
} else {
MESH_DEBUG_PRINTLN("[Modem] Can't dial state=%d", (int)_state);
MESH_DEBUG_PRINTLN("[Modem] Can't dial -- state=%d", (int)_state);
queueCallEvent(CallEventType::DIAL_FAILED, callCmd.phone);
}
break;
@@ -928,7 +1175,7 @@ restart:
_state == ModemState::DIALING &&
millis() - lastCLCCPoll > CLCC_POLL_INTERVAL) {
if (sendAT("AT+CLCC", "OK", 2000)) {
// +CLCC: 1,0,0,0,0,"number",129 stat field:
// +CLCC: 1,0,0,0,0,"number",129 -- stat field:
// 0=active, 1=held, 2=dialing, 3=alerting, 4=incoming, 5=waiting
char* p = strstr(_atBuf, "+CLCC:");
if (p) {
@@ -936,16 +1183,16 @@ restart:
if (sscanf(p, "+CLCC: %d,%d,%d,%d,%d", &idx, &dir, &stat, &mode, &mpty) >= 3) {
MESH_DEBUG_PRINTLN("[Modem] CLCC: stat=%d", stat);
if (stat == 0) {
// Call is active remote answered
// Call is active -- remote answered
_state = ModemState::IN_CALL;
_callStartTime = millis();
queueCallEvent(CallEventType::CONNECTED, _callPhone);
MESH_DEBUG_PRINTLN("[Modem] Call connected (detected via CLCC)");
}
// stat 2=dialing, 3=alerting still setting up, keep polling
// stat 2=dialing, 3=alerting -- still setting up, keep polling
}
} else {
// No +CLCC line in response no active calls
// No +CLCC line in response -- no active calls
// This shouldn't happen during DIALING unless the call ended
// and we missed the URC. Check state and clean up.
// (NO CARRIER URC should have been caught by drainURCs)
@@ -988,9 +1235,9 @@ restart:
// Shorter delay during active call states for responsive URC handling
if (isCallActive()) {
vTaskDelay(pdMS_TO_TICKS(100)); // 100ms responsive to URCs
vTaskDelay(pdMS_TO_TICKS(100)); // 100ms -- responsive to URCs
} else {
vTaskDelay(pdMS_TO_TICKS(500)); // 500ms normal idle
vTaskDelay(pdMS_TO_TICKS(500)); // 500ms -- normal idle
}
}
}
@@ -1108,7 +1355,7 @@ bool ModemManager::waitResponse(const char* expect, uint32_t timeout_ms,
vTaskDelay(pdMS_TO_TICKS(10));
}
// Timeout check one more time
// Timeout -- check one more time
if (buf && expect && strstr(buf, expect)) return true;
return false;
}
+39 -7
View File
@@ -6,7 +6,8 @@
// Runs AT commands on a dedicated FreeRTOS task (Core 0, priority 1) to never
// block the mesh radio loop. Communicates with main loop via lock-free queues.
//
// Supports: SMS send/receive, voice call dial/answer/hangup/DTMF
// Supports: SMS send/receive, voice call dial/answer/hangup/DTMF,
// notification tone playback via AT+CCMXPLAY
//
// Guard: HAS_4G_MODEM (defined only for the 4G build environment)
// =============================================================================
@@ -23,6 +24,7 @@
#include <freertos/semphr.h>
#include "variant.h"
#include "ApnDatabase.h"
#include "ModemBundledSounds.h"
// ---------------------------------------------------------------------------
// Modem pins (from variant.h, always defined for reference)
@@ -50,6 +52,10 @@
#define MODEM_CALL_CMD_QUEUE_SIZE 4
#define MODEM_CALL_EVT_QUEUE_SIZE 4
// Notification tone auto-stop timeout (ms) -- stop playback after this
// even if no +AUDIOSTATE URC received, to avoid stuck audio
#define NOTIF_TONE_TIMEOUT_MS 4000
// ---------------------------------------------------------------------------
// Modem state machine
// ---------------------------------------------------------------------------
@@ -88,7 +94,7 @@ struct SMSIncoming {
// Voice call structures
// ---------------------------------------------------------------------------
// Commands from main loop modem task
// Commands from main loop -> modem task
enum class CallCmd : uint8_t {
DIAL, // Initiate outgoing call
ANSWER, // Answer incoming call
@@ -104,7 +110,7 @@ struct CallCommand {
uint8_t volume; // Used by SET_VOLUME (0-5)
};
// Events from modem task main loop
// Events from modem task -> main loop
enum class CallEventType : uint8_t {
INCOMING, // Incoming call ringing (+CLIP parsed)
CONNECTED, // Call answered / outgoing connected
@@ -142,10 +148,24 @@ public:
bool setCallVolume(uint8_t level); // Set volume 0-5
bool pollCallEvent(CallEvent& out); // Poll from main loop
// Ringtone control called from main loop
// Ringtone control -- called from main loop
void setRingtoneEnabled(bool en) { _ringtoneEnabled = en; }
bool isRingtoneEnabled() const { return _ringtoneEnabled; }
// --- Notification tone API ---
// Request playback of a bundled notification tone by index (0-based).
// Called from main loop (Core 1); playback happens on modem task (Core 0).
// Pass -1 or out-of-range to be ignored.
void requestNotifTone(int8_t toneIdx);
// Check if bundled tones have been transferred to modem filesystem
bool areTonesReady() const { return _tonesTransferred; }
// Look up a modem tone index by base filename (e.g. "Bell-01").
// Returns index into modemBundledTones[] or -1 if not found.
// Matches with or without .wav extension.
static int8_t findToneByName(const char* name);
// --- State queries (lock-free reads) ---
ModemState getState() const { return _state; }
int getSignalBars() const; // 0-5
@@ -177,7 +197,7 @@ public:
// Save user-configured APN to SD card.
static void saveAPNConfig(const char* apn);
// Pause/resume polling used by web reader to avoid Core 0 contention
// Pause/resume polling -- used by web reader to avoid Core 0 contention
// during WiFi TLS handshakes. While paused, the task skips AT commands
// (SMS poll, CSQ poll) but still drains URCs and handles call commands
// so incoming calls aren't missed.
@@ -213,6 +233,12 @@ private:
unsigned long _nextRingTone = 0; // Next tone burst timestamp (modem task)
bool _toneActive = false; // Is a tone currently sounding
// Notification tone state
volatile int8_t _pendingToneIdx = -1; // Set by main loop, consumed by modem task
volatile bool _tonesTransferred = false; // True after all tones written to modem C:/
bool _notifTonePlaying = false; // Modem is currently playing a notif tone
unsigned long _notifToneStartTime = 0; // millis() when playback started (for timeout)
TaskHandle_t _taskHandle = nullptr;
// SMS queues
@@ -220,8 +246,8 @@ private:
QueueHandle_t _recvQueue = nullptr;
// Call queues
QueueHandle_t _callCmdQueue = nullptr; // main loop modem task
QueueHandle_t _callEvtQueue = nullptr; // modem task main loop
QueueHandle_t _callCmdQueue = nullptr; // main loop -> modem task
QueueHandle_t _callEvtQueue = nullptr; // modem task -> main loop
SemaphoreHandle_t _uartMutex = nullptr;
@@ -254,6 +280,12 @@ private:
void queueCallEvent(CallEventType type, const char* phone = nullptr, uint32_t duration = 0);
void handleRingtone(); // Play tone bursts while incoming call rings
// Notification tone transfer and playback (called from modem task)
bool transferTonesToModem(); // Transfer embedded WAVs to modem C:/ filesystem
bool playModemTone(const char* filename); // AT+CCMXPLAY
bool stopModemTone(); // AT+CCMXSTOP
void handleNotifTone(); // Poll _pendingToneIdx, play/stop as needed
// FreeRTOS task
static void taskEntry(void* param);
void taskLoop();
@@ -0,0 +1,260 @@
#pragma once
// =============================================================================
// NotifSounds.h -- Per-channel notification sound configuration
//
// Stores a custom sound filename per channel for notification tones.
// Config persisted to /meshcore/notif_sounds.cfg on SD card.
//
// Audio variant: Sound files are MP3s in /alarms/ (shared with alarm clock).
// 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.
//
// 4G variant: Sound files are 8kHz mono WAVs embedded in firmware
// (ModemBundledSounds.h) and transferred to modem C:/ filesystem on boot.
// UITask calls modemManager.requestNotifTone() directly.
//
// Guard: MECK_AUDIO_VARIANT || HAS_4G_MODEM
// =============================================================================
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
#ifndef NOTIF_SOUNDS_H
#define NOTIF_SOUNDS_H
#include <Arduino.h>
#include <SD.h>
#include <vector>
#include <algorithm>
#include "variant.h"
#ifdef HAS_4G_MODEM
#include "ModemBundledSounds.h"
#endif
#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() {
memset(_sounds, 0, sizeof(_sounds));
#ifdef MECK_AUDIO_VARIANT
_pendingPlay = false;
_pendingFile[0] = '\0';
#endif
}
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 ---
void scanSoundFiles() {
_soundFiles.clear();
#ifdef HAS_4G_MODEM
// 4G variant: available tones are embedded in firmware
for (int i = 0; i < MODEM_BUNDLED_TONE_COUNT; i++) {
_soundFiles.push_back(String(modemBundledTones[i].filename));
}
Serial.printf("NotifSounds: %d modem tones available\n", (int)_soundFiles.size());
#else // MECK_AUDIO_VARIANT
// Audio variant: scan SD card /alarms/ folder for MP3 files
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());
#endif
}
int getSoundFileCount() const { return (int)_soundFiles.size(); }
const String& getSoundFile(int idx) const { return _soundFiles[idx]; }
const std::vector<String>& getSoundFiles() const { return _soundFiles; }
#ifdef HAS_4G_MODEM
// Get the display label for a tone file (4G variant only).
// Returns the human-readable label from ModemBundledSounds.
const char* getToneLabel(int idx) const {
if (idx < 0 || idx >= MODEM_BUNDLED_TONE_COUNT) return "";
return modemBundledTones[idx].label;
}
#endif
// --- Pending playback request (audio variant only) ---
// The 4G variant calls modemManager.requestNotifTone() directly
// from UITask; no pending mechanism needed.
#ifdef MECK_AUDIO_VARIANT
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';
}
#endif
private:
char _sounds[NOTIF_SOUND_SLOTS][NOTIF_SOUND_NAME_MAX];
std::vector<String> _soundFiles;
#ifdef MECK_AUDIO_VARIANT
bool _pendingPlay;
char _pendingFile[48];
#endif
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 || HAS_4G_MODEM
@@ -7,6 +7,9 @@
#include <MeshCore.h>
#include "../NodePrefs.h"
#include "MeckFonts.h"
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
#include "NotifSounds.h"
#endif
// Inline edit hint shown next to values being adjusted
#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_NUMBER, // W/S adjusts value (freq, BW, SF, CR, TX, UTC)
EDIT_CONFIRM, // Confirmation dialog (delete channel, apply radio)
EDIT_NOTIF_SOUND, // Sound picker for per-channel notification tone
#ifdef MECK_WIFI_COMPANION
EDIT_WIFI, // WiFi scan/select/password flow
#endif
@@ -252,6 +256,13 @@ private:
uint8_t _fontPickerOriginal; // font style before edit (for cancel revert)
int _confirmAction; // 0=none, 1=delete channel, 2=apply radio
// Notification sound picker state
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
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
bool _onboarding;
@@ -595,6 +606,11 @@ public:
_fmError = nullptr;
_dnsServer = nullptr;
#endif
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
_notifSoundSelected = 0;
_notifSoundScroll = 0;
_notifSoundChannel = 0;
#endif
}
void enter() {
@@ -1930,15 +1946,33 @@ public:
snprintf(tmp, sizeof(tmp), " %s [*]", ch.name);
}
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)
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) || defined(HAS_4G_MODEM)
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
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
int hintW = display.getTextWidth(hint);
int hintW = display.getTextWidth(hintBuf);
display.setCursor(display.width() - hintW - 2, y);
display.print(hint);
display.print(hintBuf);
display.setCursor(0, y);
}
}
@@ -2075,6 +2109,92 @@ public:
display.setTextSize(1);
}
// === Notification sound picker overlay ===
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
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 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
// === WiFi setup overlay ===
if (_editMode == EDIT_WIFI) {
@@ -2536,6 +2656,41 @@ public:
return true; // consume all keys in confirm mode
}
// --- Notification sound picker ---
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
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
// --- OTA update flow ---
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
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
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
if (c == 'q' || c == 'Q') {
if (_subScreen != SUB_NONE) {
@@ -0,0 +1,489 @@
#pragma once
// =============================================================================
// SnakeScreen -- Classic Nokia-style Snake for Meck e-ink devices
//
// T-Deck Pro: 8x8 pixel cells on 240x320 display
// T5S3: 4x4 pixel cells on 128x128 virtual display
//
// The 800ms partial refresh floor naturally produces Nokia-era tick speed.
// Snake body stored as circular buffer -- ~1KB, no PSRAM needed.
// Game state persists when switching screens (auto-pause on exit).
//
// High scores: top 10 scores with dates stored to /games/snake_hi.dat on SD.
// =============================================================================
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
// Forward declarations
class UITask;
// -- Grid sizing per platform --
#if defined(LilyGo_T5S3_EPaper_Pro)
#define SNAKE_CELL 4
#define SNAKE_HDR 14
#define SNAKE_FTR 10
#else
#define SNAKE_CELL 8
#define SNAKE_HDR 14
#define SNAKE_FTR 14
#endif
#define SNAKE_MAX_LEN 512
#define SNAKE_HI_COUNT 10
#define SNAKE_HI_PATH "/games/snake_hi.dat"
#define SNAKE_HI_VERSION 1
class SnakeScreen : public UIScreen {
public:
enum GameState { READY, PLAYING, GAME_OVER };
enum Direction { UP, DOWN, LEFT, RIGHT };
struct HiScoreEntry {
uint16_t score;
uint32_t timestamp; // Unix epoch from RTC
};
private:
UITask* _task;
mesh::RTCClock* _rtc;
bool _wantsExit;
// Grid dimensions (computed once from display size on first render)
int _gridW, _gridH;
int _offsetX, _offsetY;
// Snake body circular buffer
struct Cell { uint8_t x, y; };
Cell _body[SNAKE_MAX_LEN];
int _headIdx;
int _length;
// Game state
GameState _state;
Direction _dir;
Direction _pendingDir;
Cell _food;
int _score;
unsigned long _lastTick;
unsigned long _tickInterval;
// High scores
HiScoreEntry _hiScores[SNAKE_HI_COUNT];
int _hiCount;
bool _newHiScore;
int _newHiRank; // 0-based rank of newly inserted score (-1 if none)
// Simple xorshift PRNG
uint16_t _rngState;
uint16_t rng() {
_rngState ^= _rngState << 7;
_rngState ^= _rngState >> 9;
_rngState ^= _rngState << 8;
return _rngState;
}
void spawnFood() {
for (int attempt = 0; attempt < 50; attempt++) {
int fx = rng() % _gridW;
int fy = rng() % _gridH;
if (!isSnakeAt(fx, fy)) {
_food.x = fx;
_food.y = fy;
return;
}
}
for (int gy = 0; gy < _gridH; gy++) {
for (int gx = 0; gx < _gridW; gx++) {
if (!isSnakeAt(gx, gy)) {
_food.x = gx;
_food.y = gy;
return;
}
}
}
}
bool isSnakeAt(int x, int y) const {
for (int i = 0; i < _length; i++) {
int idx = (_headIdx - i + SNAKE_MAX_LEN) % SNAKE_MAX_LEN;
if (_body[idx].x == x && _body[idx].y == y) return true;
}
return false;
}
Cell getHead() const { return _body[_headIdx]; }
void resetGame() {
int cx = _gridW / 2;
int cy = _gridH / 2;
_length = 3;
_headIdx = 2;
_body[0] = { (uint8_t)(cx - 2), (uint8_t)cy };
_body[1] = { (uint8_t)(cx - 1), (uint8_t)cy };
_body[2] = { (uint8_t)cx, (uint8_t)cy };
_dir = RIGHT;
_pendingDir = RIGHT;
_score = 0;
_tickInterval = 500;
_newHiScore = false;
_newHiRank = -1;
_rngState = (uint16_t)(millis() ^ 0xA5A5);
spawnFood();
}
bool tick() {
_dir = _pendingDir;
Cell head = getHead();
int nx = head.x;
int ny = head.y;
switch (_dir) {
case UP: ny--; break;
case DOWN: ny++; break;
case LEFT: nx--; break;
case RIGHT: nx++; break;
}
if (nx < 0 || nx >= _gridW || ny < 0 || ny >= _gridH) {
onDeath();
return false;
}
bool eating = (nx == _food.x && ny == _food.y);
int checkLen = eating ? _length : (_length - 1);
for (int i = 0; i < checkLen; i++) {
int idx = (_headIdx - i + SNAKE_MAX_LEN) % SNAKE_MAX_LEN;
if (_body[idx].x == nx && _body[idx].y == ny) {
onDeath();
return false;
}
}
_headIdx = (_headIdx + 1) % SNAKE_MAX_LEN;
_body[_headIdx] = { (uint8_t)nx, (uint8_t)ny };
if (eating) {
_length++;
if (_length >= SNAKE_MAX_LEN) _length = SNAKE_MAX_LEN;
_score += 10;
spawnFood();
}
return true;
}
void onDeath() {
_state = GAME_OVER;
if (_score > 0) {
_newHiRank = insertHiScore(_score);
_newHiScore = (_newHiRank >= 0);
if (_newHiScore) saveHiScores();
}
}
void drawCellFilled(DisplayDriver& display, int gx, int gy) const {
int px = _offsetX + gx * SNAKE_CELL;
int py = _offsetY + gy * SNAKE_CELL;
display.fillRect(px, py, SNAKE_CELL, SNAKE_CELL);
}
void drawCellOutline(DisplayDriver& display, int gx, int gy) const {
int px = _offsetX + gx * SNAKE_CELL;
int py = _offsetY + gy * SNAKE_CELL;
display.drawRect(px, py, SNAKE_CELL, SNAKE_CELL);
}
// --- High score persistence ---
void loadHiScores() {
_hiCount = 0;
memset(_hiScores, 0, sizeof(_hiScores));
if (!SD.exists(SNAKE_HI_PATH)) return;
File f = SD.open(SNAKE_HI_PATH, FILE_READ);
if (!f) return;
uint8_t ver = 0;
if (f.read(&ver, 1) != 1 || ver != SNAKE_HI_VERSION) { f.close(); return; }
uint8_t count = 0;
if (f.read(&count, 1) != 1) { f.close(); return; }
if (count > SNAKE_HI_COUNT) count = SNAKE_HI_COUNT;
for (int i = 0; i < count; i++) {
uint8_t buf[6];
if (f.read(buf, 6) != 6) break;
_hiScores[i].score = (uint16_t)buf[0] | ((uint16_t)buf[1] << 8);
_hiScores[i].timestamp = (uint32_t)buf[2] | ((uint32_t)buf[3] << 8)
| ((uint32_t)buf[4] << 16) | ((uint32_t)buf[5] << 24);
_hiCount++;
}
f.close();
}
void saveHiScores() {
if (!SD.exists("/games")) SD.mkdir("/games");
if (SD.exists(SNAKE_HI_PATH)) SD.remove(SNAKE_HI_PATH);
File f = SD.open(SNAKE_HI_PATH, FILE_WRITE);
if (!f) return;
uint8_t ver = SNAKE_HI_VERSION;
f.write(&ver, 1);
uint8_t count = (uint8_t)_hiCount;
f.write(&count, 1);
for (int i = 0; i < _hiCount; i++) {
uint8_t buf[6];
buf[0] = _hiScores[i].score & 0xFF;
buf[1] = (_hiScores[i].score >> 8) & 0xFF;
buf[2] = _hiScores[i].timestamp & 0xFF;
buf[3] = (_hiScores[i].timestamp >> 8) & 0xFF;
buf[4] = (_hiScores[i].timestamp >> 16) & 0xFF;
buf[5] = (_hiScores[i].timestamp >> 24) & 0xFF;
f.write(buf, 6);
}
f.close();
}
int insertHiScore(int score) {
uint32_t now = (_rtc != nullptr) ? _rtc->getCurrentTime() : 0;
int pos = _hiCount;
for (int i = 0; i < _hiCount; i++) {
if ((uint16_t)score > _hiScores[i].score) { pos = i; break; }
}
if (pos >= SNAKE_HI_COUNT) return -1;
int newCount = _hiCount + 1;
if (newCount > SNAKE_HI_COUNT) newCount = SNAKE_HI_COUNT;
for (int i = newCount - 1; i > pos; i--) _hiScores[i] = _hiScores[i - 1];
_hiScores[pos].score = (uint16_t)score;
_hiScores[pos].timestamp = now;
_hiCount = newCount;
return pos;
}
static void formatDate(uint32_t ts, char* buf, int bufLen) {
if (ts == 0) { snprintf(buf, bufLen, "--"); return; }
uint32_t days = ts / 86400;
int year = 1970;
while (true) {
int diy = ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) ? 366 : 365;
if (days < (uint32_t)diy) break;
days -= diy;
year++;
}
static const int dim[] = {31,28,31,30,31,30,31,31,30,31,30,31};
static const char* mn[] = {"Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"};
bool leap = ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0);
int month = 0;
for (month = 0; month < 12; month++) {
int d = dim[month];
if (month == 1 && leap) d = 29;
if (days < (uint32_t)d) break;
days -= d;
}
if (month > 11) month = 11;
snprintf(buf, bufLen, "%d %s %d", (int)(days + 1), mn[month], year);
}
public:
SnakeScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _wantsExit(false), _gridW(0), _gridH(0),
_offsetX(0), _offsetY(0), _headIdx(0), _length(0),
_state(READY), _dir(RIGHT), _pendingDir(RIGHT),
_score(0), _lastTick(0), _tickInterval(500), _rngState(0xBEEF),
_hiCount(0), _newHiScore(false), _newHiRank(-1) {
_food = {0, 0};
memset(_body, 0, sizeof(_body));
memset(_hiScores, 0, sizeof(_hiScores));
}
bool wantsExit() const { return _wantsExit; }
void clearExit() { _wantsExit = false; }
GameState getState() const { return _state; }
void enter() {
_wantsExit = false;
loadHiScores();
_lastTick = millis();
}
bool handleInput(char c) override {
switch (_state) {
case READY:
if (c == '\r') { _state = PLAYING; _lastTick = millis(); return true; }
if (c == 'q' || c == 'Q') { _wantsExit = true; return true; }
return false;
case PLAYING:
switch (c) {
case 'w': case 'W': if (_dir != DOWN) _pendingDir = UP; return true;
case 's': case 'S': if (_dir != UP) _pendingDir = DOWN; return true;
case 'a': case 'A': if (_dir != RIGHT) _pendingDir = LEFT; return true;
case 'd': case 'D': if (_dir != LEFT) _pendingDir = RIGHT; return true;
case 'q': case 'Q': _wantsExit = true; return true;
default: return false;
}
case GAME_OVER:
if (c == '\r') { resetGame(); _state = PLAYING; _lastTick = millis(); return true; }
if (c == 'q' || c == 'Q') { _wantsExit = true; return true; }
return false;
}
return false;
}
int render(DisplayDriver& display) override {
if (_gridW == 0) {
int usableW = display.width();
int usableH = display.height() - SNAKE_HDR - SNAKE_FTR;
_gridW = usableW / SNAKE_CELL;
_gridH = usableH / SNAKE_CELL;
_offsetX = (usableW - _gridW * SNAKE_CELL) / 2;
_offsetY = SNAKE_HDR + (usableH - _gridH * SNAKE_CELL) / 2;
resetGame();
}
display.startFrame();
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(display.width() / 2, 2, "Snake");
#else
display.setCursor(2, 2);
display.print("Snake");
char scoreBuf[16];
snprintf(scoreBuf, sizeof(scoreBuf), "Score: %d", _score);
int sw = display.getTextWidth(scoreBuf);
display.setCursor(display.width() - sw - 2, 2);
display.print(scoreBuf);
#endif
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, SNAKE_HDR - 2, display.width(), 1);
if (_state == READY) {
int cx = display.width() / 2;
int y = SNAKE_HDR + 6;
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(cx, y, "Classic Snake");
y += 14;
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Swipe to steer");
#else
display.drawTextCentered(cx, y, "W/S/A/D to steer");
#endif
y += 11;
display.drawTextCentered(cx, y, "Eat food to grow");
y += 11;
display.drawTextCentered(cx, y, "Steer clear of walls");
y += 16;
if (_hiCount > 0) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(cx, y, "-- High Scores --");
y += 12;
display.setColor(DisplayDriver::GREEN);
#if defined(LilyGo_T5S3_EPaper_Pro)
int showCount = (_hiCount < 5) ? _hiCount : 5;
#else
int showCount = (_hiCount < 10) ? _hiCount : 10;
#endif
for (int i = 0; i < showCount; i++) {
char dateBuf[16];
formatDate(_hiScores[i].timestamp, dateBuf, sizeof(dateBuf));
char line[48];
snprintf(line, sizeof(line), "%d. %d %s", i + 1, _hiScores[i].score, dateBuf);
display.drawTextCentered(cx, y, line);
y += 10;
}
y += 4;
} else {
y += 8;
}
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.drawTextCentered(cx, y, "Tap to start");
#else
display.drawTextCentered(cx, y, "Press Enter to start");
#endif
} else {
if (_state == PLAYING) {
unsigned long now = millis();
if (now - _lastTick >= _tickInterval) { tick(); _lastTick = now; }
}
display.setColor(DisplayDriver::LIGHT);
display.drawRect(_offsetX - 1, _offsetY - 1,
_gridW * SNAKE_CELL + 2, _gridH * SNAKE_CELL + 2);
display.setColor(DisplayDriver::GREEN);
drawCellFilled(display, _food.x, _food.y);
display.setColor(DisplayDriver::LIGHT);
for (int i = 0; i < _length; i++) {
int idx = (_headIdx - i + SNAKE_MAX_LEN) % SNAKE_MAX_LEN;
if (i == 0) drawCellFilled(display, _body[idx].x, _body[idx].y);
else drawCellOutline(display, _body[idx].x, _body[idx].y);
}
if (_state == GAME_OVER) {
int cx = display.width() / 2;
int cy = display.height() / 2;
int boxW = display.width() * 3 / 4;
int boxH = _newHiScore ? 60 : 50;
int boxX = cx - boxW / 2;
int boxY = cy - boxH / 2;
display.setColor(DisplayDriver::DARK);
display.fillRect(boxX, boxY, boxW, boxH);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(boxX, boxY, boxW, boxH);
int ty = boxY + 8;
display.drawTextCentered(cx, ty, "Game Over");
ty += 14;
char finalScore[24];
snprintf(finalScore, sizeof(finalScore), "Score: %d", _score);
display.drawTextCentered(cx, ty, finalScore);
ty += 12;
if (_newHiScore) {
display.setColor(DisplayDriver::YELLOW);
char rankBuf[32];
snprintf(rankBuf, sizeof(rankBuf), "New #%d High Score!", _newHiRank + 1);
display.drawTextCentered(cx, ty, rankBuf);
ty += 12;
}
display.setColor(DisplayDriver::GREEN);
display.drawTextCentered(cx, ty, "Enter:Retry Q:Back");
}
}
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
char footBuf[32];
snprintf(footBuf, sizeof(footBuf), "Score: %d", _score);
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, display.height() - 8, footBuf);
display.setTextSize(1);
#else
display.setTextSize(1);
int fy = display.height() - 12;
display.drawRect(0, fy - 2, display.width(), 1);
if (_state == PLAYING) {
display.setCursor(2, fy);
display.print("Q:Back");
} else if (_state == READY) {
display.setCursor(2, fy);
display.print("Enter:Start Q:Back");
}
#endif
if (_state == PLAYING) return 100;
return 5000;
}
};
+155 -17
View File
@@ -9,6 +9,9 @@
#include "DiscoveryScreen.h"
#include "LastHeardScreen.h"
#include "Tracescreen.h"
#include "GamesMenuScreen.h"
#include "SnakeScreen.h"
#include "MinesweeperScreen.h"
#ifdef MECK_WEB_READER
#include "WebReaderScreen.h"
#endif
@@ -65,6 +68,15 @@
#include "SMSScreen.h"
#include "ModemManager.h"
#endif
#if defined(MECK_AUDIO_VARIANT) || defined(HAS_4G_MODEM)
#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 {
UITask* _task;
@@ -527,20 +539,29 @@ public:
}
}
// Third row: single centred Trace tile (column 1 position only)
// Third row: Trace (col 0) + Games (col 1)
{
int row3y = gridY + 2 * (tileH + gapY);
int col1x = gridX + (tileW + gapX);
// Trace tile (column 0)
int col0x = gridX;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(col1x, row3y, tileW, tileH);
int iconX = col1x + (tileW - HOME_ICON_W) / 2;
display.drawRect(col0x, row3y, tileW, tileH);
int iconX = col0x + (tileW - HOME_ICON_W) / 2;
int iconY = row3y + 2;
display.drawXbm(iconX, iconY, icon_trace, HOME_ICON_W, HOME_ICON_H);
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(col1x + tileW / 2, row3y + 15, "Trace");
display.drawTextCentered(col0x + tileW / 2, row3y + 15, "Trace");
// Games tile (column 1)
int col1x = gridX + (tileW + gapX);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(col1x, row3y, tileW, tileH);
iconX = col1x + (tileW - HOME_ICON_W) / 2;
iconY = row3y + 2;
display.drawXbm(iconX, iconY, icon_gamepad, HOME_ICON_W, HOME_ICON_H);
display.setTextSize(_node_prefs->smallTextSize());
display.drawTextCentered(col1x + tileW / 2, row3y + 15, "Games");
}
// Nav hint at bottom of screen
@@ -623,7 +644,8 @@ public:
#endif
y += menuLH;
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, y, "[R] Trace");
display.setCursor(col1, y); display.print("[R] Trace");
display.setCursor(col2, y); display.print("[J] Games");
display.setColor(DisplayDriver::LIGHT);
y += menuLH;
y += 2;
@@ -661,7 +683,7 @@ public:
#endif
y += 10;
display.setColor(DisplayDriver::YELLOW);
display.drawTextCentered(display.width() / 2, y, "[R] Trace");
display.drawTextCentered(display.width() / 2, y, "[R] Trace [J] Games ");
display.setColor(DisplayDriver::LIGHT);
y += 14;
}
@@ -1380,6 +1402,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
discovery_screen = new DiscoveryScreen(this, &rtc_clock);
last_heard_screen = new LastHeardScreen(&rtc_clock);
trace_screen = new TraceScreen(this, &rtc_clock);
games_menu_screen = new GamesMenuScreen(this);
snake_screen = new SnakeScreen(this, &rtc_clock);
minesweeper_screen = new MinesweeperScreen(this);
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
lock_screen = new LockScreen(this, &rtc_clock, node_prefs);
#endif
@@ -1454,6 +1479,15 @@ void UITask::dismissBootHint() {
}
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)
switch(t){
case UIEventType::contactMessage:
@@ -1520,6 +1554,77 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
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;
}
#elif defined(HAS_4G_MODEM)
if (!suppressNotif) {
const char* customSound = notifSounds.getSoundForChannel(channel_idx);
if (customSound && customSound[0] != '\0') {
int8_t toneIdx = ModemManager::findToneByName(customSound);
if (toneIdx >= 0) {
modemManager.requestNotifTone(toneIdx);
}
s_lastMsgSuppressed = true; // Suppress buzzer -- modem tone 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
// For DMs (channel_idx == 0xFF):
@@ -1541,15 +1646,15 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
if (isRoomMsg) {
// Room server: text already has "Poster: message" format — store as-is
// 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 {
// Regular DM: prefix with sender name
char dmFormatted[CHANNEL_MSG_TEXT_LEN];
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 {
((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
@@ -1572,11 +1677,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
// Messages are stored in channel history, accessible via tile/key
// Suppress toasts for room server messages (bulk sync would spam toasts)
if (!isOnRepeaterAdmin() && !isRoomMsg) {
if (!isOnRepeaterAdmin() && !isRoomMsg && !suppressNotif) {
char alertBuf[40];
snprintf(alertBuf, sizeof(alertBuf), "New: %s", from_name);
showAlert(alertBuf, 2000);
@@ -1586,7 +1691,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
forceRefresh();
}
if (_display != NULL) {
if (_display != NULL && !suppressNotif) {
if (!_display->isOn() && !hasConnection()) {
_display->turnOn();
}
@@ -1602,9 +1707,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
if (_node_prefs->kb_flash_notify && !isRoomMsg) {
if (_node_prefs->kb_flash_notify && !isRoomMsg && !suppressNotif) {
digitalWrite(KB_BL_PIN, HIGH);
_kb_flash_off_at = millis() + 200; // 200ms flash
}
@@ -3035,6 +3140,39 @@ void UITask::gotoTraceScreen() {
_next_refresh = 100;
}
void UITask::gotoGamesMenu() {
GamesMenuScreen* gm = (GamesMenuScreen*)games_menu_screen;
gm->enter();
setCurrScreen(games_menu_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoSnakeScreen() {
SnakeScreen* ss = (SnakeScreen*)snake_screen;
ss->enter();
setCurrScreen(snake_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::gotoMinesweeperScreen() {
MinesweeperScreen* ms = (MinesweeperScreen*)minesweeper_screen;
ms->enter();
setCurrScreen(minesweeper_screen);
if (_display != NULL && !_display->isOn()) {
_display->turnOn();
}
_auto_off = millis() + AUTO_OFF_MILLIS;
_next_refresh = 100;
}
void UITask::onTraceResult(uint32_t tag, uint8_t flags, const uint8_t* path_snrs,
const uint8_t* path_hashes, uint8_t path_len, int8_t final_snr) {
TraceScreen* ts = (TraceScreen*)trace_screen;
+15 -3
View File
@@ -62,7 +62,7 @@ class UITask : public AbstractUITask {
unsigned long _alert_expiry;
bool _hintActive = false; // Boot navigation hint overlay
unsigned long _hintExpiry = 0; // Auto-dismiss time for hint
bool _pendingBootHint = false; // Deferred hint show after splash screen
bool _pendingBootHint = false; // Deferred hint -- show after splash screen
int _msgcount;
unsigned long ui_started_at, next_batt_chck;
uint8_t _low_batt_count = 0; // Consecutive low-voltage readings for debounce
@@ -100,6 +100,9 @@ class UITask : public AbstractUITask {
UIScreen* discovery_screen; // Node discovery scan screen
UIScreen* last_heard_screen; // Last heard passive advert list
UIScreen* trace_screen; // Trace path screen (standalone trace tool)
UIScreen* games_menu_screen; // Games launcher menu
UIScreen* snake_screen; // Snake game screen
UIScreen* minesweeper_screen; // Minesweeper game screen
#ifdef MECK_WEB_READER
UIScreen* web_reader; // Web reader screen (lazy-init, WiFi required)
#endif
@@ -119,7 +122,7 @@ class UITask : public AbstractUITask {
UIScreen* _screenBeforeVKB = nullptr;
unsigned long _vkbOpenedAt = 0;
// Powersaving: light sleep when locked + idle (standalone only no BLE/WiFi)
// Powersaving: light sleep when locked + idle (standalone only -- no BLE/WiFi)
// Wakes on LoRa packet (DIO1), boot button (GPIO0), or 30-min timer
#if !defined(BLE_PIN_CODE) && !defined(MECK_WIFI_COMPANION)
unsigned long _psLastActive = 0; // millis() at last wake or lock entry
@@ -202,6 +205,9 @@ public:
void gotoDiscoveryScreen(); // Navigate to node discovery scan
void gotoLastHeardScreen(); // Navigate to last heard passive list
void gotoTraceScreen(); // Navigate to trace path screen
void gotoGamesMenu(); // Navigate to games launcher menu
void gotoSnakeScreen(); // Navigate to snake game
void gotoMinesweeperScreen(); // Navigate to minesweeper game
#if HAS_GPS
void gotoMapScreen(); // Navigate to map tile screen
#endif
@@ -234,7 +240,7 @@ public:
int getDMUnreadCount(int contactIdx) const;
void clearDMUnread(int contactIdx);
// Flag: suppress roomconversation redirect on next login (L key admin access)
// Flag: suppress room->conversation redirect on next login (L key admin access)
bool _skipRoomRedirect = false;
bool hasDisplay() const { return _display != NULL; }
bool isButtonPressed() const;
@@ -259,6 +265,9 @@ public:
bool isOnDiscoveryScreen() const { return curr == discovery_screen; }
bool isOnLastHeardScreen() const { return curr == last_heard_screen; }
bool isOnTraceScreen() const { return curr == trace_screen; }
bool isOnGamesMenu() const { return curr == games_menu_screen; }
bool isOnSnakeScreen() const { return curr == snake_screen; }
bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; }
bool isOnMapScreen() const { return curr == map_screen; }
#if defined(LilyGo_T5S3_EPaper_Pro) || defined(LilyGo_TDeck_Pro)
bool isLocked() const { return _locked; }
@@ -344,6 +353,9 @@ public:
UIScreen* getDiscoveryScreen() const { return discovery_screen; }
UIScreen* getLastHeardScreen() const { return last_heard_screen; }
UIScreen* getTraceScreen() const { return trace_screen; }
UIScreen* getGamesMenuScreen() const { return games_menu_screen; }
UIScreen* getSnakeScreen() const { return snake_screen; }
UIScreen* getMinesweeperScreen() const { return minesweeper_screen; }
UIScreen* getMapScreen() const { return map_screen; }
#ifdef MECK_WEB_READER
UIScreen* getWebReaderScreen() const { return web_reader; }
+18 -3
View File
@@ -1,6 +1,6 @@
#pragma once
// =============================================================================
// HomeIcons 12x12 icon sprites for T5S3 home screen tiles
// HomeIcons -- 12x12 icon sprites for T5S3 home screen tiles
// MSB-first, 2 bytes per row (same format as emoji sprites)
// =============================================================================
@@ -48,7 +48,7 @@ static const uint8_t icon_search[] PROGMEM = {
0x3C,0x00, 0x03,0x00, 0x01,0x80, 0x00,0xC0, 0x00,0x40, 0x00,0x00,
};
// ⏰ Alarm Clock (AlarmScreen) 12x12 home tile icon
// ⏰ Alarm Clock (AlarmScreen) -- 12x12 home tile icon
static const uint8_t icon_alarm[] PROGMEM = {
0x40,0x40, 0x9E,0x20, 0x20,0x80, 0x44,0x40, 0x44,0x40, 0x46,0x40,
0x40,0x40, 0x20,0x80, 0x1F,0x00, 0x00,0x00, 0x20,0x40, 0x40,0x20,
@@ -60,7 +60,22 @@ static const uint8_t icon_trace[] PROGMEM = {
0xFF,0xF0, 0x00,0xE0, 0x00,0xC0, 0x00,0x80, 0x00,0x00, 0x00,0x00,
};
// 🔔 Bell — 7x8 status bar indicator (alarm enabled)
// 🎮 Gamepad (Games)
// ..########..
// .#........#.
// .#..#..##.#.
// .#.###....#.
// .#..#..##.#.
// .#........#.
// ..##....##..
// ...#....#...
// ...######...
static const uint8_t icon_gamepad[] PROGMEM = {
0x00,0x00, 0x3F,0xC0, 0x40,0x20, 0x49,0xA0, 0x5C,0x20, 0x49,0xA0,
0x40,0x20, 0x30,0xC0, 0x10,0x80, 0x1F,0x80, 0x00,0x00, 0x00,0x00,
};
// 🔔 Bell -- 7x8 status bar indicator (alarm enabled)
// MSB-first, 1 byte per row
#define BELL_ICON_W 7
#define BELL_ICON_H 8
+10 -10
View File
@@ -115,7 +115,7 @@ build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D MECK_AUDIO_VARIANT
@@ -151,7 +151,7 @@ build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MAX_GROUP_CHANNELS=40
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
-D WIFI_DEBUG_LOGGING=1
@@ -159,7 +159,7 @@ build_flags =
-D MECK_AUDIO_VARIANT
-D MECK_WEB_READER=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}
+<helpers/esp32/*.cpp>
-<helpers/esp32/SerialBLEInterface.cpp>
@@ -188,7 +188,7 @@ build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=1
-D MECK_AUDIO_VARIANT
-D MECK_OTA_UPDATE=1
@@ -220,13 +220,13 @@ build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MAX_GROUP_CHANNELS=40
-D BLE_PIN_CODE=123456
-D OFFLINE_QUEUE_SIZE=256
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=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}
+<helpers/esp32/*.cpp>
+<helpers/ui/MomentaryButton.cpp>
@@ -254,7 +254,7 @@ build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MAX_GROUP_CHANNELS=40
-D MECK_WIFI_COMPANION=1
-D TCP_PORT=5000
-D WIFI_DEBUG_LOGGING=1
@@ -262,7 +262,7 @@ build_flags =
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=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}
+<helpers/esp32/*.cpp>
-<helpers/esp32/SerialBLEInterface.cpp>
@@ -291,12 +291,12 @@ build_flags =
${LilyGo_TDeck_Pro.build_flags}
-I examples/companion_radio/ui-new
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=20
-D MAX_GROUP_CHANNELS=40
-D OFFLINE_QUEUE_SIZE=1
-D HAS_4G_MODEM=1
-D MECK_WEB_READER=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}
+<helpers/esp32/*.cpp>
-<helpers/esp32/SerialBLEInterface.cpp>