From fb4d8273a90bd9866e54f55db15bdbe1cc331ed2 Mon Sep 17 00:00:00 2001 From: pelgraine <140762863+pelgraine@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:54:07 +1000 Subject: [PATCH] Max - New buzzer(vibrate) option in custom channel notification preferences - silent buzz notification. --- examples/companion_radio/MyMesh.cpp | 26 +++++- examples/companion_radio/ui-new/NotifSounds.h | 16 ++++ .../companion_radio/ui-new/Settingsscreen.h | 56 ++++++++++--- examples/companion_radio/ui-new/UITask.cpp | 21 ++++- variants/lilygo_tdeck_max/DRV2605Haptic.h | 80 +++++++++++++++++++ 5 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 variants/lilygo_tdeck_max/DRV2605Haptic.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8634995f..a9f00e7a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -12,6 +12,10 @@ #include "ModemManager.h" // Serial CLI modem commands #endif +#if defined(LilyGo_TDeck_Pro_Max) + #include "DRV2605Haptic.h" // TEMP: inline haptic driver for the 'buzz' CLI test command +#endif + #define CMD_APP_START 1 #define CMD_SEND_TXT_MSG 2 #define CMD_SEND_CHANNEL_TXT_MSG 3 @@ -3506,7 +3510,27 @@ void MyMesh::checkCLIRescueCmd() { } - } else if (strcmp(cli_command, "reboot") == 0) { + } +#if defined(LilyGo_TDeck_Pro_Max) + else if (strcmp(cli_command, "buzz") == 0) { + // TEMP: fire the DRV2605 haptic motor once to confirm it works. + // Lazy-inits the driver (and motor power rail) on first invocation. + static DRV2605Haptic haptic; + static bool haptic_ready = false; + if (!haptic_ready) { + board.motorEnable(); + delay(10); // let the motor rail settle before I2C + haptic_ready = haptic.begin(); + } + if (haptic_ready) { + haptic.buzz(1); + Serial.println(" > buzz"); + } else { + Serial.println(" > buzz: DRV2605 not found"); + } + } +#endif + else if (strcmp(cli_command, "reboot") == 0) { board.reboot(); // doesn't return } else { Serial.println(" Error: unknown command (try 'help')"); diff --git a/examples/companion_radio/ui-new/NotifSounds.h b/examples/companion_radio/ui-new/NotifSounds.h index b53e509d..4e08f68f 100644 --- a/examples/companion_radio/ui-new/NotifSounds.h +++ b/examples/companion_radio/ui-new/NotifSounds.h @@ -38,6 +38,10 @@ #endif #define NOTIF_SOUND_NAME_MAX 32 +// Reserved one-byte sentinel stored in a channel's name slot to mean +// "Buzzer (vibrate)" instead of a sound filename. 0x01 can never be the first +// byte of a real filename, so it is unambiguous and needs no config change. +#define NOTIF_VIBRATE_MARKER "\x01" #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" @@ -100,6 +104,18 @@ public: setSoundForChannel(channel_idx, nullptr); } + // --- Vibrate (haptic) selection --- + // "Buzzer (vibrate)" is stored as a reserved marker in the channel's name + // slot, so it persists through the existing config save/load unchanged. + bool isVibrateForChannel(uint8_t channel_idx) const { + const char* s = getSoundForChannel(channel_idx); + return s && (uint8_t)s[0] == (uint8_t)NOTIF_VIBRATE_MARKER[0]; + } + + void setVibrateForChannel(uint8_t channel_idx) { + setSoundForChannel(channel_idx, NOTIF_VIBRATE_MARKER); + } + // --- Sound file scanning --- void scanSoundFiles() { diff --git a/examples/companion_radio/ui-new/Settingsscreen.h b/examples/companion_radio/ui-new/Settingsscreen.h index a9916120..a13dcbfc 100644 --- a/examples/companion_radio/ui-new/Settingsscreen.h +++ b/examples/companion_radio/ui-new/Settingsscreen.h @@ -2345,9 +2345,14 @@ public: int maxVisible = (listBot - listTop) / lineH; if (maxVisible < 3) maxVisible = 3; - // Total items: 1 ("Default") + sound file count + // Total items: "Default" (+ "Buzzer (vibrate)" on MAX) + sound file count const auto& files = notifSounds.getSoundFiles(); - int totalItems = 1 + (int)files.size(); +#if defined(LilyGo_TDeck_Pro_Max) + const int kFileBase = 2; // 0=Default, 1=Buzzer(vibrate), 2+=files +#else + const int kFileBase = 1; // 0=Default, 1+=files +#endif + int totalItems = kFileBase + (int)files.size(); // Centre scroll on selection _notifSoundScroll = max(0, min(_notifSoundSelected - maxVisible / 2, @@ -2370,9 +2375,13 @@ public: display.setCursor(bx + 6, sy); if (i == 0) { display.print("Default (silent)"); +#if defined(LilyGo_TDeck_Pro_Max) + } else if (i == 1) { + display.print("Buzzer (vibrate)"); +#endif } else { // Show filename without extension - String displayName = files[i - 1]; + String displayName = files[i - kFileBase]; int dot = displayName.lastIndexOf('.'); if (dot > 0) displayName = displayName.substring(0, dot); if (displayName.length() > 28) displayName = displayName.substring(0, 28); @@ -2954,7 +2963,12 @@ public: #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 defined(LilyGo_TDeck_Pro_Max) + const int kFileBase = 2; // 0=Default, 1=Buzzer(vibrate), 2+=files +#else + const int kFileBase = 1; // 0=Default, 1+=files +#endif + int totalItems = kFileBase + (int)files.size(); if (c == 'w' || c == 'W' || c == 0xF2 || c == KEY_UP) { if (_notifSoundSelected > 0) _notifSoundSelected--; @@ -2965,11 +2979,15 @@ public: return true; } if (c == '\r' || c == 13) { - // Select: 0 = clear (default silent), 1+ = file + // Select: 0 = clear (default silent), [MAX: 1 = vibrate], rest = file if (_notifSoundSelected == 0) { notifSounds.clearSoundForChannel(_notifSoundChannel); +#if defined(LilyGo_TDeck_Pro_Max) + } else if (_notifSoundSelected == 1) { + notifSounds.setVibrateForChannel(_notifSoundChannel); +#endif } else { - int fileIdx = _notifSoundSelected - 1; + int fileIdx = _notifSoundSelected - kFileBase; if (fileIdx >= 0 && fileIdx < (int)files.size()) { notifSounds.setSoundForChannel(_notifSoundChannel, files[fileIdx].c_str()); } @@ -3904,14 +3922,26 @@ public: notifSounds.scanSoundFiles(); _notifSoundSelected = 0; // 0 = "Default (silent)" _notifSoundScroll = 0; +#if defined(LilyGo_TDeck_Pro_Max) + const int kFileBase = 2; // 0=Default, 1=Buzzer(vibrate), 2+=files +#else + const int kFileBase = 1; // 0=Default, 1+=files +#endif // 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; +#if defined(LilyGo_TDeck_Pro_Max) + if (notifSounds.isVibrateForChannel(_notifSoundChannel)) { + _notifSoundSelected = 1; // 1 = "Buzzer (vibrate)" + } else +#endif + { + 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 + kFileBase; + break; + } } } } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 18815104..043a5ed3 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -19,6 +19,9 @@ #include "MapScreen.h" #endif #include "target.h" +#if defined(LilyGo_TDeck_Pro_Max) + #include "DRV2605Haptic.h" // haptic motor for "Buzzer (vibrate)" channels +#endif #if defined(LilyGo_T5S3_EPaper_Pro) || defined(MECK_AUDIO_VARIANT) #include "HomeIcons.h" #endif @@ -1645,6 +1648,21 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i // 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. +#if defined(LilyGo_TDeck_Pro_Max) + // Channel set to "Buzzer (vibrate)": pulse the motor instead of a tone. + if (!suppressNotif && notifSounds.isVibrateForChannel(channel_idx)) { + static DRV2605Haptic s_haptic; + static bool s_hapticReady = false; + if (!s_hapticReady) { + board.motorEnable(); + delay(10); // let the motor rail settle before I2C + s_hapticReady = s_haptic.begin(); + } + if (s_hapticReady) s_haptic.buzz(1); + s_lastMsgSuppressed = true; // suppress the default RTTTL buzzer + } else +#endif + { #ifdef MECK_AUDIO_VARIANT if (!suppressNotif) { const char* customSound = notifSounds.getSoundForChannel(channel_idx); @@ -1677,7 +1695,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i #else s_lastMsgSuppressed = suppressNotif; #endif - + } + // Add to channel history screen with channel index, path data, and SNR // For DMs (channel_idx == 0xFF): // - Regular DMs: prefix text with sender name ("NodeName: hello") diff --git a/variants/lilygo_tdeck_max/DRV2605Haptic.h b/variants/lilygo_tdeck_max/DRV2605Haptic.h new file mode 100644 index 00000000..0c94dc41 --- /dev/null +++ b/variants/lilygo_tdeck_max/DRV2605Haptic.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include + +// Minimal inline DRV2605 haptic driver for the T-Deck Pro MAX. +// +// The DRV2605 sits on the shared Wire bus at I2C 0x5A. Its motor supply is +// gated by the XL9555 expander (board.motorEnable()), which MUST be switched on +// before begin() or the device will not ACK. +// +// The register sequence mirrors Adafruit_DRV2605::begin() followed by +// selectLibrary(1) + setMode(INTTRIG) - i.e. exactly what the LilyGo basic.ino +// example does: an ERM motor, open-loop, internal-trigger, effect library 1. +class DRV2605Haptic { +public: + DRV2605Haptic(uint8_t addr = 0x5A, TwoWire* wire = &Wire) + : _addr(addr), _wire(wire) {} + + // Returns false if the DRV2605 does not ACK (e.g. motor rail still off). + bool begin() { + _wire->beginTransmission(_addr); + if (_wire->endTransmission() != 0) return false; + + writeReg(REG_MODE, 0x00); // exit standby, internal trigger + writeReg(REG_RTPIN, 0x00); // no real-time playback input + writeReg(REG_WAVESEQ1, 1); // default effect: strong click + writeReg(REG_WAVESEQ2, 0); // end of sequence + writeReg(REG_OVERDRIVE, 0); + writeReg(REG_SUSTAINPOS, 0); + writeReg(REG_SUSTAINNEG, 0); + writeReg(REG_BREAK, 0); + writeReg(REG_AUDIOMAX, 0x64); + writeReg(REG_FEEDBACK, readReg(REG_FEEDBACK) & 0x7F); // N_ERM_LRA = 0 -> ERM + writeReg(REG_CONTROL3, readReg(REG_CONTROL3) | 0x20); // ERM_OPEN_LOOP = 1 + writeReg(REG_LIBRARY, 1); // ERM effect library 1 + writeReg(REG_MODE, 0x00); // internal trigger + return true; + } + + // Fire a single library effect (1 = strong click, 100%). + void buzz(uint8_t effect = 1) { + writeReg(REG_WAVESEQ1, effect); + writeReg(REG_WAVESEQ2, 0); + writeReg(REG_GO, 1); + } + +private: + static const uint8_t REG_MODE = 0x01; + static const uint8_t REG_RTPIN = 0x02; + static const uint8_t REG_LIBRARY = 0x03; + static const uint8_t REG_WAVESEQ1 = 0x04; + static const uint8_t REG_WAVESEQ2 = 0x05; + static const uint8_t REG_GO = 0x0C; + static const uint8_t REG_OVERDRIVE = 0x0D; + static const uint8_t REG_SUSTAINPOS = 0x0E; + static const uint8_t REG_SUSTAINNEG = 0x0F; + static const uint8_t REG_BREAK = 0x10; + static const uint8_t REG_AUDIOMAX = 0x13; + static const uint8_t REG_FEEDBACK = 0x1A; + static const uint8_t REG_CONTROL3 = 0x1D; + + uint8_t _addr; + TwoWire* _wire; + + void writeReg(uint8_t reg, uint8_t val) { + _wire->beginTransmission(_addr); + _wire->write(reg); + _wire->write(val); + _wire->endTransmission(); + } + + uint8_t readReg(uint8_t reg) { + _wire->beginTransmission(_addr); + _wire->write(reg); + _wire->endTransmission(false); + _wire->requestFrom(_addr, (uint8_t)1); + return _wire->available() ? _wire->read() : 0; + } +};