Files

1244 lines
39 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
// =============================================================================
// AlarmScreen.h — Alarm clock app for LilyGo T-Deck Pro (audio variant)
//
// Features:
// - 5 configurable alarm slots with per-alarm enable, time, day-of-week
// - MP3 alarm tones loaded from /alarms/ on SD card
// - Binary config persistence at /alarms/.alarmcfg
// - Shared Audio* with audiobook player (lazy-init in main.cpp)
// - Ringing mode: ANY key press instantly silences alarm
// - Auto-timeout: alarm silences after 5 minutes if unattended
// - Snooze: press Z during ringing to snooze for 5 minutes
// - Background alarm check runs in main loop() every ~10 seconds
//
// Keyboard controls:
// ALARM_LIST: W/S = scroll slots, Enter = edit selected alarm,
// E = toggle enable/disable, Shift+Del = exit to home
// EDIT_ALARM: W/S = move between fields, A/D = adjust value,
// Enter = open sound picker (on sound field) or save & exit,
// Shift+Del = cancel edit
// PICK_SOUND: W/S = scroll sounds, Enter = select, Shift+Del = cancel
// RINGING: ANY key = dismiss, Z = snooze 5 minutes
//
// Library dependencies: ESP32-audioI2S (shared with AudiobookPlayerScreen)
// =============================================================================
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
// Audio library — ESP32-audioI2S by schreibfaul1 (shared with AudiobookPlayerScreen)
#include "Audio.h"
#include "variant.h"
// Forward declarations
class UITask;
#ifdef HAS_ES8311_AUDIO
// Defined in target.cpp (where the board object + ES8311 driver are visible).
// Forward-declared here so this UI header doesn't need the heavy target.h.
void meck_audio_route_amp();
void meck_audio_codec_init();
#endif
#if defined(LilyGo_TDeck_Pro_Max)
// Silent (vibrate) alarm haptic shims, defined in target.cpp for the same
// reason as the audio shims above -- this UI header cannot see the board.
void meck_alarm_haptic_begin();
void meck_alarm_haptic_buzz();
void meck_alarm_haptic_stop();
#endif
// Lock-state query, defined in main.cpp. This UI header only forward-declares
// UITask, so it cannot call _task->isLocked() directly; the shim bridges it.
bool meck_alarm_is_locked();
// ============================================================================
// Configuration
// ============================================================================
#define ALARMS_FOLDER "/alarms"
#define ALARMS_CONFIG_FILE "/alarms/.alarmcfg"
#define ALARM_SLOT_COUNT 5
#define ALARM_SOUND_MAX 128 // Max filename length for alarm tone
#define ALARM_RINGING_TIMEOUT_MS 300000 // 5 minutes auto-dismiss
#define ALARM_SNOOZE_MS 300000 // 5 minutes snooze
#define ALARM_CHECK_INTERVAL_MS 10000 // Check alarms every 10 seconds
#define ALARM_FIRE_COOLDOWN_S 90 // Don't re-fire same alarm within 90s
#if defined(LilyGo_TDeck_Pro_Max)
// Silent (vibrate) alarm. The chosen-sound slot stores this one-byte sentinel
// instead of a filename; 0x01 can never be the first byte of a real filename.
// Same value as NotifSounds' marker, kept local so this header need not pull in
// NotifSounds.h.
#define ALARM_VIBRATE_MARKER "\x01"
#define ALARM_VIBRATE_GROUP 3 // buzzes per group
#define ALARM_VIBRATE_BUZZ_MS 1200 // spacing between buzz starts (~1s effect + margin)
#define ALARM_VIBRATE_PAUSE_MS 4000 // pause after each group before repeating
#endif
// Config file magic + version for forward compatibility
#define ALARM_CFG_MAGIC 0x4D4B414C // "MKAL"
#define ALARM_CFG_VERSION 2 // v2: ALARM_SOUND_MAX increased to 128
// Day-of-week bitmask (bit 0 = Sunday, bit 6 = Saturday)
#define DOW_SUN (1 << 0)
#define DOW_MON (1 << 1)
#define DOW_TUE (1 << 2)
#define DOW_WED (1 << 3)
#define DOW_THU (1 << 4)
#define DOW_FRI (1 << 5)
#define DOW_SAT (1 << 6)
#define DOW_ALL 0x7F
#define DOW_WEEKDAYS (DOW_MON | DOW_TUE | DOW_WED | DOW_THU | DOW_FRI)
#define DOW_WEEKEND (DOW_SAT | DOW_SUN)
// ============================================================================
// Data structures
// ============================================================================
struct AlarmSlot {
bool enabled;
uint8_t hour; // 023
uint8_t minute; // 059
uint8_t days; // Day-of-week bitmask (0x7F = every day)
uint8_t volume; // 121 (Audio library scale)
char sound[ALARM_SOUND_MAX]; // Filename in /alarms/ (empty = first available)
};
struct AlarmConfig {
uint32_t magic;
uint8_t version;
AlarmSlot slots[ALARM_SLOT_COUNT];
uint8_t _pad[3]; // Alignment padding
};
// ============================================================================
// AlarmScreen
// ============================================================================
class AlarmScreen : public UIScreen {
public:
enum Mode { ALARM_LIST, EDIT_ALARM, PICK_SOUND, RINGING };
// Edit fields when in EDIT_ALARM mode
enum EditField { FIELD_ENABLED, FIELD_HOUR, FIELD_MINUTE, FIELD_DAYS, FIELD_VOLUME, FIELD_SOUND, FIELD_COUNT };
private:
UITask* _task;
Mode _mode;
bool _sdReady;
// Alarm data
AlarmConfig _config;
int _selectedSlot; // 04 in ALARM_LIST
int _scrollOffset;
// Edit state
int _editSlot; // Which slot is being edited
EditField _editField; // Current field cursor
AlarmSlot _editCopy; // Working copy during edit
// Direct digit entry (for hour/minute fields)
bool _digitEntry; // Currently typing digits
char _digitBuf[4]; // Up to 3 chars + null
int _digitPos; // Cursor in digit buffer
// Sound picker state
std::vector<String> _soundFiles;
int _soundSelected;
int _soundScroll;
// Ringing state
bool _ringing;
int _ringingSlot; // Which alarm triggered
unsigned long _ringingStart; // millis() when alarm started
bool _snoozed;
unsigned long _snoozeUntil; // millis() for snooze wake-up
// Fire tracking — prevent re-trigger within cooldown
uint32_t _lastFiredEpoch[ALARM_SLOT_COUNT];
// Audio — shared with audiobook player, managed by main.cpp
// We do NOT own this pointer; main.cpp creates and shares it.
Audio* _audio;
bool _alarmAudioActive; // True when alarm is driving the Audio object
String _resolvedSoundPath; // Full path of currently playing alarm sound
int _restartAttempts; // Retry counter for audio restart loop
// Synthetic rows shown above the file list in the sound picker.
// On MAX this is the "Buzzer (vibrate)" row; elsewhere there are none.
#if defined(LilyGo_TDeck_Pro_Max)
static const int kVibrateRows = 1;
#else
static const int kVibrateRows = 0;
#endif
#if defined(LilyGo_TDeck_Pro_Max)
// Silent (vibrate) alarm cadence state
bool _vibrating;
int _vibBuzzCount; // Buzzes fired in the current group (0..GROUP)
unsigned long _vibNextMs; // millis() of the next buzz/pause transition
static bool slotIsVibrate(const AlarmSlot& s) {
return (uint8_t)s.sound[0] == (uint8_t)ALARM_VIBRATE_MARKER[0];
}
void startVibrate() {
meck_alarm_haptic_begin();
_vibrating = true;
meck_alarm_haptic_buzz();
_vibBuzzCount = 1;
_vibNextMs = millis() + ALARM_VIBRATE_BUZZ_MS;
}
#endif
// ---- Day-of-week helpers ----
static const char* dowShort(int dow) {
static const char* names[] = {"Su","Mo","Tu","We","Th","Fr","Sa"};
return (dow >= 0 && dow < 7) ? names[dow] : "??";
}
// Day of week from epoch (0=Sunday)
static int dowFromEpoch(uint32_t epoch) {
// Jan 1 1970 was a Thursday (4)
return (int)((epoch / 86400 + 4) % 7);
}
// Format days bitmask as human string
static void formatDays(uint8_t days, char* buf, int bufLen) {
if (days == DOW_ALL) { strncpy(buf, "Every day", bufLen); return; }
if (days == DOW_WEEKDAYS) { strncpy(buf, "Weekdays", bufLen); return; }
if (days == DOW_WEEKEND) { strncpy(buf, "Weekend", bufLen); return; }
if (days == 0) { strncpy(buf, "Never", bufLen); return; }
buf[0] = '\0';
for (int i = 0; i < 7; i++) {
if (days & (1 << i)) {
if (buf[0] != '\0') strncat(buf, " ", bufLen - strlen(buf) - 1);
strncat(buf, dowShort(i), bufLen - strlen(buf) - 1);
}
}
}
// ---- Config persistence ----
void loadConfig() {
// Initialise all slots to defaults
memset(&_config, 0, sizeof(_config));
_config.magic = ALARM_CFG_MAGIC;
_config.version = ALARM_CFG_VERSION;
for (int i = 0; i < ALARM_SLOT_COUNT; i++) {
_config.slots[i].enabled = false;
_config.slots[i].hour = 7;
_config.slots[i].minute = 0;
_config.slots[i].days = DOW_WEEKDAYS;
_config.slots[i].volume = 21;
_config.slots[i].sound[0] = '\0';
}
if (!SD.exists(ALARMS_CONFIG_FILE)) {
Serial.println("ALARM: No config file, using defaults");
return;
}
File f = SD.open(ALARMS_CONFIG_FILE, FILE_READ);
if (!f) return;
AlarmConfig tmp;
int bytesRead = f.read((uint8_t*)&tmp, sizeof(tmp));
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (bytesRead == sizeof(tmp) && tmp.magic == ALARM_CFG_MAGIC && tmp.version == ALARM_CFG_VERSION) {
memcpy(&_config, &tmp, sizeof(_config));
Serial.printf("ALARM: Loaded config (%d slots)\n", ALARM_SLOT_COUNT);
// Sanitise loaded values
for (int i = 0; i < ALARM_SLOT_COUNT; i++) {
_config.slots[i].hour = _config.slots[i].hour % 24;
_config.slots[i].minute = _config.slots[i].minute % 60;
_config.slots[i].days &= DOW_ALL;
if (_config.slots[i].volume == 0 || _config.slots[i].volume > 21)
_config.slots[i].volume = 21;
}
} else {
Serial.println("ALARM: Config invalid or wrong version, using defaults");
}
}
void saveConfig() {
// Ensure folder exists
if (!SD.exists(ALARMS_FOLDER)) {
SD.mkdir(ALARMS_FOLDER);
}
File f = SD.open(ALARMS_CONFIG_FILE, FILE_WRITE);
if (!f) {
Serial.println("ALARM: Failed to save config");
return;
}
f.write((uint8_t*)&_config, sizeof(_config));
f.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.println("ALARM: Config saved");
}
// ---- Sound file scanner ----
void scanSoundFiles() {
_soundFiles.clear();
if (!SD.exists(ALARMS_FOLDER)) {
SD.mkdir(ALARMS_FOLDER);
Serial.printf("ALARM: Created %s\n", 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();
// Skip dotfiles (config, etc.)
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);
// Sort alphabetically
std::sort(_soundFiles.begin(), _soundFiles.end());
Serial.printf("ALARM: Found %d sound files\n", (int)_soundFiles.size());
}
// ---- DAC power (same pattern as AudiobookPlayerScreen) ----
void enableDAC() {
#ifndef HAS_ES8311_AUDIO
pinMode(41, OUTPUT);
digitalWrite(41, HIGH);
delay(50);
#endif
}
void disableDAC() {
#ifndef HAS_ES8311_AUDIO
digitalWrite(41, LOW);
#endif
}
// ---- Audio control for alarm ringing ----
void startAlarmAudio(int slotIdx) {
if (!_audio) {
Serial.println("ALARM: No Audio object!");
return;
}
const AlarmSlot& slot = _config.slots[slotIdx];
String soundFile;
// Determine which file to play
if (slot.sound[0] != '\0') {
soundFile = String(ALARMS_FOLDER) + "/" + String(slot.sound);
// Verify file still exists
if (!SD.exists(soundFile.c_str())) {
Serial.printf("ALARM: Sound '%s' missing, falling back\n", slot.sound);
soundFile = "";
}
digitalWrite(SDCARD_CS, HIGH); // Release SD after exists() check
}
// Fallback: use first available sound
if (soundFile.length() == 0) {
if (_soundFiles.empty()) scanSoundFiles();
if (!_soundFiles.empty()) {
soundFile = String(ALARMS_FOLDER) + "/" + _soundFiles[0];
}
}
if (soundFile.length() == 0) {
Serial.println("ALARM: No sound files available!");
return;
}
Serial.printf("ALARM: Starting audio: '%s' vol=%d\n", soundFile.c_str(), slot.volume);
// Stop any previous audio (stale audiobook state, etc.)
_audio->stopSong();
// Power on DAC and wait for it to stabilise
enableDAC();
delay(100); // Cold-start needs longer than 50ms
// Configure I2S pins (must be done after any stopSong that resets I2S)
#ifdef HAS_ES8311_AUDIO
// MAX: route to the ES8311 + enable the speaker amp, then configure I2S
// WITH MCLK (the ES8311 is clock slave and needs MCLK on BOARD_I2S_MCLK).
// The codec registers are initialised after connecttoFS starts the clocks.
meck_audio_route_amp();
bool ok = _audio->setPinout(BOARD_I2S_BCLK, BOARD_I2S_LRC, BOARD_I2S_DOUT,
I2S_PIN_NO_CHANGE, BOARD_I2S_MCLK);
#else
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);
}
#endif
if (!ok) {
Serial.println("ALARM: setPinout FAILED");
}
// Connect to file FIRST, then set volume (matches audiobook working pattern)
_audio->connecttoFS(SD, soundFile.c_str());
#ifdef HAS_ES8311_AUDIO
// MAX: I2S clocks are now running, so initialise the ES8311 codec (once;
// idempotent). Without this, alarm audio is silent until a notification
// tone happens to bring the codec up.
meck_audio_codec_init();
#endif
_audio->setVolume(slot.volume);
_alarmAudioActive = true;
_resolvedSoundPath = soundFile; // Store for restart loop
_restartAttempts = 0;
Serial.printf("ALARM: Playing '%s' at volume %d\n", soundFile.c_str(), slot.volume);
}
void stopAlarmAudio() {
if (_audio && _alarmAudioActive) {
_audio->stopSong();
disableDAC();
_alarmAudioActive = false;
_resolvedSoundPath = "";
_restartAttempts = 0;
Serial.println("ALARM: Audio stopped");
}
#if defined(LilyGo_TDeck_Pro_Max)
if (_vibrating) {
_vibrating = false;
_vibBuzzCount = 0;
meck_alarm_haptic_stop();
}
#endif
}
// ---- Standard footer (matching all Meck screens) ----
// GxEPD footer rule: only setTextSize(1) works at screen bottom.
void drawFooter(DisplayDriver& display, const char* left, const char* right) {
display.setTextSize(1);
int footerY = display.height() - 12;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, footerY);
display.print(left);
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
// ---- Render: Alarm list ----
void renderAlarmList(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("Alarm Clock");
if (_soundFiles.empty() && _sdReady) {
// Show hint if no sounds yet
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
display.setCursor(0, 13);
display.print("Place 44kHz .mp3 in /alarms/");
}
display.setColor(DisplayDriver::LIGHT);
display.setTextSize(0);
int itemHeight = 18; // Two lines per slot: time + info
int listTop = _soundFiles.empty() ? 22 : 13;
int listBottom = display.height() - 14;
int visibleItems = (listBottom - listTop) / itemHeight;
if (visibleItems < 1) visibleItems = 1;
// Keep selection visible
if (_selectedSlot < _scrollOffset) _scrollOffset = _selectedSlot;
if (_selectedSlot >= _scrollOffset + visibleItems) _scrollOffset = _selectedSlot - visibleItems + 1;
for (int i = 0; i < visibleItems && (_scrollOffset + i) < ALARM_SLOT_COUNT; i++) {
int idx = _scrollOffset + i;
int y = listTop + i * itemHeight;
const AlarmSlot& slot = _config.slots[idx];
// Selection highlight
if (idx == _selectedSlot) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(slot.enabled ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
}
// Line 1: Alarm number + time + enabled
char line1[40];
snprintf(line1, sizeof(line1), "%d. %s %02d:%02d",
idx + 1, slot.enabled ? "ON " : "OFF", slot.hour, slot.minute);
display.setCursor(0, y);
display.print(line1);
// Line 2: days + sound name
if (idx == _selectedSlot) {
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
char daysBuf[24];
formatDays(slot.days, daysBuf, sizeof(daysBuf));
char line2[48];
if (slot.sound[0] != '\0') {
// Truncate sound name for display
char sndShort[16];
strncpy(sndShort, slot.sound, 15);
sndShort[15] = '\0';
// Strip extension for cleaner display
char* dot = strrchr(sndShort, '.');
if (dot) *dot = '\0';
snprintf(line2, sizeof(line2), " %s | %s", daysBuf, sndShort);
} else {
snprintf(line2, sizeof(line2), " %s", daysBuf);
}
display.setCursor(0, y + 8);
display.print(line2);
}
drawFooter(display, "O:On/Off Enter:Edit", "Sh+Del:X");
}
// ---- Render: Edit alarm ----
void renderEditAlarm(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char hdr[24];
snprintf(hdr, sizeof(hdr), "Edit Alarm %d", _editSlot + 1);
display.print(hdr);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
display.setTextSize(0);
int y = 16;
int lineH = 10;
// Field labels and values
struct {
const char* label;
char value[32];
} fields[FIELD_COUNT];
snprintf(fields[FIELD_ENABLED].value, 32, "%s", _editCopy.enabled ? "ON" : "OFF");
fields[FIELD_ENABLED].label = "Enabled";
snprintf(fields[FIELD_HOUR].value, 32, "%02d", _editCopy.hour);
fields[FIELD_HOUR].label = "Hour";
snprintf(fields[FIELD_MINUTE].value, 32, "%02d", _editCopy.minute);
fields[FIELD_MINUTE].label = "Minute";
char daysBuf[24];
formatDays(_editCopy.days, daysBuf, sizeof(daysBuf));
strncpy(fields[FIELD_DAYS].value, daysBuf, 31);
fields[FIELD_DAYS].value[31] = '\0';
fields[FIELD_DAYS].label = "Days";
snprintf(fields[FIELD_VOLUME].value, 32, "%d", _editCopy.volume);
fields[FIELD_VOLUME].label = "Volume";
#if defined(LilyGo_TDeck_Pro_Max)
if (slotIsVibrate(_editCopy)) {
strncpy(fields[FIELD_SOUND].value, "Buzzer (vibrate)", 31);
fields[FIELD_SOUND].value[31] = '\0';
} else
#endif
if (_editCopy.sound[0] != '\0') {
char sndDisplay[28];
strncpy(sndDisplay, _editCopy.sound, 27);
sndDisplay[27] = '\0';
char* dot = strrchr(sndDisplay, '.');
if (dot) *dot = '\0';
strncpy(fields[FIELD_SOUND].value, sndDisplay, 31);
} else {
strcpy(fields[FIELD_SOUND].value, "(default)");
}
fields[FIELD_SOUND].label = "Sound";
for (int f = 0; f < FIELD_COUNT; f++) {
int fy = y + f * lineH;
if (f == (int)_editField) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, fy + 5, display.width(), lineH - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
char line[48];
snprintf(line, sizeof(line), "%-8s: %s", fields[f].label, fields[f].value);
display.setCursor(0, fy);
display.print(line);
// Show A/D arrows on selected field
if (f == (int)_editField) {
display.setCursor(display.width() - 18, fy);
if (_editField == FIELD_SOUND) {
display.print(".."); // Indicate Enter opens picker
} else {
display.print("<>");
}
}
}
// Days detail when on DAYS field
if (_editField == FIELD_DAYS) {
int detailY = y + FIELD_COUNT * lineH + 4;
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, detailY);
display.print("A/D: toggle day ");
// Show individual day toggles
display.setCursor(0, detailY + 9);
for (int d = 0; d < 7; d++) {
bool on = (_editCopy.days & (1 << d));
display.setColor(on ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
char db[4];
snprintf(db, sizeof(db), "%s ", dowShort(d));
display.print(db);
}
}
// Digit entry overlay (for hour/minute direct input)
if (_digitEntry) {
int bx = 10, by = 40, bw = display.width() - 20, bh = 30;
display.setColor(DisplayDriver::DARK);
display.fillRect(bx, by, bw, bh);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(bx, by, bw, bh);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
const char* prompt = (_editField == FIELD_HOUR) ? "Hour (0-23):" : "Min (0-59):";
display.setCursor(bx + 4, by + 4);
display.print(prompt);
// Show typed digits with cursor
display.setColor(DisplayDriver::LIGHT);
char inputDisplay[8];
snprintf(inputDisplay, sizeof(inputDisplay), "%s_", _digitBuf);
display.setCursor(bx + 4, by + 16);
display.print(inputDisplay);
drawFooter(display, "Type digits", "Enter:OK Sh+Del:Cancel");
} else {
drawFooter(display, "A/D:Adjust Enter:Type", "Sh+Del:Save");
}
}
// ---- Render: Sound picker ----
void renderSoundPicker(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("Pick Alarm Sound");
display.setColor(DisplayDriver::LIGHT);
if (_soundFiles.empty() && kVibrateRows == 0) {
display.setTextSize(0);
display.setCursor(0, 20);
display.print("No .mp3 files found.");
display.setCursor(0, 30);
display.print("Place 44kHz .mp3 in");
display.setCursor(0, 38);
display.print("/alarms/ on SD card");
drawFooter(display, "0 files", "Sh+Del:Back");
return;
}
display.setTextSize(0);
int itemHeight = 8;
int listTop = 13;
int listBottom = display.height() - 14;
int visibleItems = (listBottom - listTop) / itemHeight;
int totalItems = kVibrateRows + (int)_soundFiles.size();
if (_soundSelected < _soundScroll) _soundScroll = _soundSelected;
if (_soundSelected >= _soundScroll + visibleItems) _soundScroll = _soundSelected - visibleItems + 1;
for (int i = 0; i < visibleItems && (_soundScroll + i) < totalItems; i++) {
int idx = _soundScroll + i;
int y = listTop + i * itemHeight;
if (idx == _soundSelected) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, y + 5, display.width(), itemHeight - 1);
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
String displayName;
if (kVibrateRows && idx == 0) {
displayName = "Buzzer (vibrate)";
} else {
// Display filename without extension
displayName = _soundFiles[idx - kVibrateRows];
int dot = displayName.lastIndexOf('.');
if (dot > 0) displayName = displayName.substring(0, dot);
}
// Truncate if too long
if (displayName.length() > 34) displayName = displayName.substring(0, 34);
display.setCursor(0, y);
display.print(displayName.c_str());
}
char countBuf[12];
snprintf(countBuf, sizeof(countBuf), "%d files", (int)_soundFiles.size());
drawFooter(display, countBuf, "Enter:Pick Sh+Del:X");
}
// ---- Render: Ringing ----
void renderRinging(DisplayDriver& display) {
const AlarmSlot& slot = _config.slots[_ringingSlot];
// Big centered time
display.setColor(DisplayDriver::GREEN);
display.setTextSize(2);
char timeBuf[8];
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", slot.hour, slot.minute);
display.drawTextCentered(display.width() / 2, 10, timeBuf);
// Alarm label
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
char label[24];
snprintf(label, sizeof(label), "Alarm %d", _ringingSlot + 1);
display.drawTextCentered(display.width() / 2, 34, label);
// Sound name
#if defined(LilyGo_TDeck_Pro_Max)
if (slotIsVibrate(slot)) {
display.setTextSize(0);
display.drawTextCentered(display.width() / 2, 48, "Buzzer (vibrate)");
} else
#endif
if (slot.sound[0] != '\0') {
display.setTextSize(0);
char sndDisplay[24];
strncpy(sndDisplay, slot.sound, 23);
sndDisplay[23] = '\0';
char* dot = strrchr(sndDisplay, '.');
if (dot) *dot = '\0';
display.drawTextCentered(display.width() / 2, 48, sndDisplay);
}
// Dismiss instruction — large and obvious
display.setColor(DisplayDriver::GREEN);
display.setTextSize(1);
if (meck_alarm_is_locked()) {
// Keys are ignored while locked; the user unlocks with the button first.
display.drawTextCentered(display.width() / 2, 64, "Unlock to dismiss");
} else {
display.drawTextCentered(display.width() / 2, 64, "ANY KEY: Dismiss");
display.setTextSize(0);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 80, "Z: Snooze 5 min");
}
// No footer in ringing mode — keep it clean and urgent
}
// ---- Input: Alarm list ----
bool handleAlarmListInput(char c) {
// A/D - scroll up/down (w/s are global nav keys)
if (c == 'a' || c == 0xF2) {
if (_selectedSlot > 0) _selectedSlot--;
return true;
}
if (c == 'd' || c == 0xF1) {
if (_selectedSlot < ALARM_SLOT_COUNT - 1) _selectedSlot++;
return true;
}
// O - toggle enable
if (c == 'o') {
_config.slots[_selectedSlot].enabled = !_config.slots[_selectedSlot].enabled;
saveConfig();
return true;
}
// Enter - edit alarm
if (c == '\r' || c == '\n') {
_editSlot = _selectedSlot;
memcpy(&_editCopy, &_config.slots[_editSlot], sizeof(AlarmSlot));
_editField = FIELD_ENABLED;
_mode = EDIT_ALARM;
return true;
}
return false;
}
// ---- Input: Edit alarm ----
bool handleEditAlarmInput(char c) {
// ---- Digit entry sub-mode (typing hour or minute directly) ----
if (_digitEntry) {
// Digits 0-9
if (c >= '0' && c <= '9' && _digitPos < 2) {
_digitBuf[_digitPos++] = c;
_digitBuf[_digitPos] = '\0';
return true;
}
// Backspace
if (c == '\b' || c == 0x7F) {
if (_digitPos > 0) {
_digitBuf[--_digitPos] = '\0';
}
return true;
}
// Enter - confirm digit entry and advance to next field
if (c == '\r' || c == '\n') {
if (_digitPos > 0) {
int val = atoi(_digitBuf);
if (_editField == FIELD_HOUR) {
_editCopy.hour = constrain(val, 0, 23);
} else if (_editField == FIELD_MINUTE) {
_editCopy.minute = constrain(val, 0, 59);
}
}
_digitEntry = false;
// Auto-advance to next field so Enter doesn't re-open digit entry
int f = (int)_editField;
if (f < FIELD_COUNT - 1) _editField = (EditField)(f + 1);
return true;
}
// Shift+Del - cancel digit entry
if (c == KEY_CANCEL) {
_digitEntry = false;
return true;
}
return true; // Consume all other keys while in digit mode
}
// ---- Normal edit field navigation ----
// W/S - move between fields
if (c == 'w' || c == 0xF2) {
int f = (int)_editField;
if (f > 0) _editField = (EditField)(f - 1);
return true;
}
if (c == 's' || c == 0xF1) {
int f = (int)_editField;
if (f < FIELD_COUNT - 1) _editField = (EditField)(f + 1);
return true;
}
// A/D - adjust value
if (c == 'a' || c == 'd') {
int dir = (c == 'd') ? 1 : -1;
switch (_editField) {
case FIELD_ENABLED:
_editCopy.enabled = !_editCopy.enabled;
break;
case FIELD_HOUR:
_editCopy.hour = (_editCopy.hour + 24 + dir) % 24;
break;
case FIELD_MINUTE:
_editCopy.minute = (_editCopy.minute + 60 + dir) % 60;
break;
case FIELD_DAYS: {
// Cycle through individual days: A goes backward, D forward
static int dayCursor = 0;
dayCursor = (dayCursor + 7 + dir) % 7;
_editCopy.days ^= (1 << dayCursor); // Toggle that day
break;
}
case FIELD_VOLUME:
_editCopy.volume = constrain((int)_editCopy.volume + dir, 1, 21);
break;
case FIELD_SOUND:
// A/D on sound field: open picker instead of adjust
scanSoundFiles();
_soundSelected = 0;
_soundScroll = 0;
#if defined(LilyGo_TDeck_Pro_Max)
if (slotIsVibrate(_editCopy)) {
_soundSelected = 0;
} else
#endif
if (_editCopy.sound[0] != '\0') {
for (int i = 0; i < (int)_soundFiles.size(); i++) {
if (_soundFiles[i] == String(_editCopy.sound)) {
_soundSelected = kVibrateRows + i;
break;
}
}
}
_mode = PICK_SOUND;
break;
default: break;
}
return true;
}
// Enter - context-dependent: digit entry for hour/minute, picker for sound, save otherwise
if (c == '\r' || c == '\n') {
if (_editField == FIELD_HOUR || _editField == FIELD_MINUTE) {
// Start digit entry — pre-fill with current value
_digitEntry = true;
_digitPos = 0;
_digitBuf[0] = '\0';
return true;
}
if (_editField == FIELD_SOUND) {
// Open sound picker
scanSoundFiles();
_soundSelected = 0;
_soundScroll = 0;
#if defined(LilyGo_TDeck_Pro_Max)
if (slotIsVibrate(_editCopy)) {
_soundSelected = 0;
} else
#endif
if (_editCopy.sound[0] != '\0') {
for (int i = 0; i < (int)_soundFiles.size(); i++) {
if (_soundFiles[i] == String(_editCopy.sound)) {
_soundSelected = kVibrateRows + i;
break;
}
}
}
_mode = PICK_SOUND;
} else {
// Save and exit edit
memcpy(&_config.slots[_editSlot], &_editCopy, sizeof(AlarmSlot));
saveConfig();
_mode = ALARM_LIST;
}
return true;
}
// Shift+Del - save and exit edit
if (c == KEY_CANCEL) {
memcpy(&_config.slots[_editSlot], &_editCopy, sizeof(AlarmSlot));
saveConfig();
_mode = ALARM_LIST;
return true;
}
return false;
}
// ---- Input: Sound picker ----
bool handleSoundPickerInput(char c) {
if (c == 'w' || c == 0xF2) {
if (_soundSelected > 0) _soundSelected--;
return true;
}
if (c == 's' || c == 0xF1) {
if (_soundSelected < kVibrateRows + (int)_soundFiles.size() - 1) _soundSelected++;
return true;
}
// Enter - pick sound
if (c == '\r' || c == '\n') {
#if defined(LilyGo_TDeck_Pro_Max)
if (_soundSelected == 0) {
// Synthetic "Buzzer (vibrate)" row
strncpy(_editCopy.sound, ALARM_VIBRATE_MARKER, ALARM_SOUND_MAX - 1);
_editCopy.sound[ALARM_SOUND_MAX - 1] = '\0';
} else
#endif
{
int fileIdx = _soundSelected - kVibrateRows;
if (fileIdx >= 0 && fileIdx < (int)_soundFiles.size()) {
strncpy(_editCopy.sound, _soundFiles[fileIdx].c_str(), ALARM_SOUND_MAX - 1);
_editCopy.sound[ALARM_SOUND_MAX - 1] = '\0';
}
}
_mode = EDIT_ALARM;
return true;
}
// Shift+Del - cancel
if (c == KEY_CANCEL) {
_mode = EDIT_ALARM;
return true;
}
return false;
}
// ---- Input: Ringing (ANY key dismisses, Z snoozes) ----
bool handleRingingInput(char c) {
if (c == 'z') {
// Snooze
stopAlarmAudio();
_ringing = false;
_snoozed = true;
_snoozeUntil = millis() + ALARM_SNOOZE_MS;
Serial.println("ALARM: Snoozed for 5 minutes");
_mode = ALARM_LIST;
return true;
}
// ANY other key: dismiss
dismiss();
return true;
}
public:
AlarmScreen(UITask* task) : _task(task) {
_mode = ALARM_LIST;
_sdReady = false;
_selectedSlot = 0;
_scrollOffset = 0;
_editSlot = 0;
_editField = FIELD_ENABLED;
_digitEntry = false;
_digitBuf[0] = '\0';
_digitPos = 0;
_soundSelected = 0;
_soundScroll = 0;
_ringing = false;
_ringingSlot = 0;
_ringingStart = 0;
_snoozed = false;
_snoozeUntil = 0;
_audio = nullptr;
_alarmAudioActive = false;
_resolvedSoundPath = "";
_restartAttempts = 0;
#if defined(LilyGo_TDeck_Pro_Max)
_vibrating = false;
_vibBuzzCount = 0;
_vibNextMs = 0;
#endif
memset(_lastFiredEpoch, 0, sizeof(_lastFiredEpoch));
memset(&_editCopy, 0, sizeof(_editCopy));
loadConfig();
}
void setSDReady(bool ready) { _sdReady = ready; }
void setAudio(Audio* audio) { _audio = audio; }
// Called from main.cpp when entering the screen
void enter(DisplayDriver& display) {
if (_ringing) {
_mode = RINGING;
} else {
_mode = ALARM_LIST;
// Refresh sound file list on entry
if (_sdReady) scanSoundFiles();
}
}
// ---- Public state queries ----
bool isRinging() const { return _ringing; }
bool isAlarmAudioActive() const { return _alarmAudioActive; }
bool isSnoozed() const { return _snoozed; }
Mode getMode() const { return _mode; }
// How many alarms are enabled (for home screen indicator)
int enabledCount() const {
int n = 0;
for (int i = 0; i < ALARM_SLOT_COUNT; i++) {
if (_config.slots[i].enabled) n++;
}
return n;
}
// ---- Dismiss alarm (callable from main.cpp for any-key handling) ----
void dismiss() {
stopAlarmAudio();
_ringing = false;
_snoozed = false;
_mode = ALARM_LIST;
Serial.println("ALARM: Dismissed");
}
// ---- Background alarm check (called from main loop every ~10s) ----
// Returns the slot index if an alarm should fire NOW, or -1 if not.
int checkAlarms(uint32_t rtcEpoch, int8_t utcOffsetHours) {
if (rtcEpoch < 1704067200UL) return -1; // No valid time
// Apply timezone offset
int32_t localEpoch = (int32_t)rtcEpoch + ((int32_t)utcOffsetHours * 3600);
if (localEpoch < 0) return -1;
int localHour = ((uint32_t)localEpoch / 3600) % 24;
int localMinute = ((uint32_t)localEpoch / 60) % 60;
int dow = dowFromEpoch((uint32_t)localEpoch);
// Check snooze wake-up first
if (_snoozed && millis() >= _snoozeUntil) {
_snoozed = false;
return _ringingSlot; // Re-fire the snoozed alarm
}
for (int i = 0; i < ALARM_SLOT_COUNT; i++) {
const AlarmSlot& slot = _config.slots[i];
if (!slot.enabled) continue;
if (slot.hour != localHour || slot.minute != localMinute) continue;
if (!(slot.days & (1 << dow))) continue;
// Cooldown: don't re-fire if we fired this alarm recently
if (rtcEpoch - _lastFiredEpoch[i] < ALARM_FIRE_COOLDOWN_S) continue;
return i;
}
return -1;
}
// ---- Fire alarm (called from main loop when checkAlarms returns >= 0) ----
void fireAlarm(int slotIdx) {
_ringingSlot = slotIdx;
_ringing = true;
_ringingStart = millis();
_lastFiredEpoch[slotIdx] = millis() / 1000; // Approximate — replaced by RTC below
_mode = RINGING;
#if defined(LilyGo_TDeck_Pro_Max)
if (slotIsVibrate(_config.slots[slotIdx])) {
startVibrate();
} else
#endif
{
startAlarmAudio(slotIdx);
}
Serial.printf("ALARM: Firing alarm %d (%02d:%02d)\n",
slotIdx + 1, _config.slots[slotIdx].hour, _config.slots[slotIdx].minute);
}
// Update last-fired with actual RTC epoch (called right after fireAlarm)
void setLastFiredEpoch(int slotIdx, uint32_t epoch) {
if (slotIdx >= 0 && slotIdx < ALARM_SLOT_COUNT) {
_lastFiredEpoch[slotIdx] = epoch;
}
}
// ---- Audio tick (called from main loop for alarm playback) ----
void alarmAudioTick() {
#if defined(LilyGo_TDeck_Pro_Max)
if (_vibrating) {
// Auto-timeout (mirrors the audio path's 5-minute auto-dismiss)
if (_ringing && (millis() - _ringingStart > ALARM_RINGING_TIMEOUT_MS)) {
dismiss();
return;
}
// ALARM_VIBRATE_GROUP buzzes spaced ALARM_VIBRATE_BUZZ_MS apart, then a
// ALARM_VIBRATE_PAUSE_MS pause, repeating until dismiss/snooze/timeout.
if ((long)(millis() - _vibNextMs) >= 0) {
meck_alarm_haptic_buzz();
_vibBuzzCount++;
if (_vibBuzzCount >= ALARM_VIBRATE_GROUP) {
_vibBuzzCount = 0;
_vibNextMs = millis() + ALARM_VIBRATE_PAUSE_MS;
} else {
_vibNextMs = millis() + ALARM_VIBRATE_BUZZ_MS;
}
}
return;
}
#endif
if (!_audio || !_alarmAudioActive) return;
_audio->loop();
// Auto-timeout
if (_ringing && (millis() - _ringingStart > ALARM_RINGING_TIMEOUT_MS)) {
Serial.println("ALARM: Auto-dismiss (timeout)");
dismiss();
return;
}
// If audio ended (short file), loop it by restarting.
// Grace period: don't check isRunning() in first 2 seconds (file may still
// be buffering/decoding headers, and isRunning() can be false briefly).
if (_alarmAudioActive && (millis() - _ringingStart > 2000) && !_audio->isRunning()) {
if (_restartAttempts >= 3) {
// Give up after 3 failed restarts — file is broken or unsupported
Serial.println("ALARM: Audio restart failed 3 times, giving up");
_alarmAudioActive = false;
return;
}
_restartAttempts++;
if (_resolvedSoundPath.length() > 0) {
Serial.printf("ALARM: Audio ended, restarting loop (attempt %d)\n", _restartAttempts);
_audio->connecttoFS(SD, _resolvedSoundPath.c_str());
_audio->setVolume(_config.slots[_ringingSlot].volume);
} else {
Serial.println("ALARM: No resolved path for restart");
_alarmAudioActive = false;
}
}
}
// ---- UIScreen interface ----
int render(DisplayDriver& display) override {
if (!_sdReady) {
display.setTextSize(1);
display.setColor(DisplayDriver::RED);
display.setCursor(0, 20);
display.print("No SD card");
display.setCursor(0, 35);
display.print("Insert SD card and");
display.setCursor(0, 43);
display.print("create /alarms/");
return 5000;
}
switch (_mode) {
case ALARM_LIST: renderAlarmList(display); break;
case EDIT_ALARM: renderEditAlarm(display); break;
case PICK_SOUND: renderSoundPicker(display); break;
case RINGING: renderRinging(display); break;
}
// Refresh rates: fast during ringing (for timeout), normal otherwise
if (_mode == RINGING) return 1000;
return 5000;
}
bool handleInput(char c) override {
switch (_mode) {
case ALARM_LIST: return handleAlarmListInput(c);
case EDIT_ALARM: return handleEditAlarmInput(c);
case PICK_SOUND: return handleSoundPickerInput(c);
case RINGING: return handleRingingInput(c);
}
return false;
}
};