From 9754f935041e893bbfbc84693b25cda6956c358e Mon Sep 17 00:00:00 2001 From: meck Date: Fri, 10 Jul 2026 09:37:05 +0000 Subject: [PATCH] T-Watch S3: vibrate alarm clock in place of the Maps tile Fixes a regression from the MECK_TWATCH rename. main.cpp's tap dispatch had '#if defined(MECK_TWATCH) && HAS_GPS' wrapped around two unrelated things: the map tap handler AND the early return that stops the watch falling through to the T5S3 tile hit-test. MECK_TOUCH_ENABLED is defined for MECK_TWATCH (main.cpp:712), so on the S3 that early return vanished and a plain tap on the home tile page ran the T5S3 geometry (tileW=40, tileH=22). Split into two gates. The other three HAS_GPS gates were audited and are genuinely map-only. Adds WatchAlarmScreen: four slots, day-of-week presets, vibrate-only. It keeps AlarmScreen.h's conventions (bit 0 = Sunday, dowFromEpoch, effect 14, the 3-buzz / 1200ms / 4000ms-pause cadence) but shares no code, because AlarmScreen is bound to ESP32-audioI2S, an SD card and a 1-21 Audio volume scale, none of which exist here. Config lives on the LittleFS 'maps' partition, already mounted. Alarms are ticked from UITask::loop, not the screen's poll(), so one fires with the display off or another screen showing. Navigating away while ringing counts as a dismiss. DRV2605Haptic.h is copied into the watch variant rather than shared, so MAX builds are untouched. The watch needs no motorEnable(): the DRV2605 sits on BLDO2, which power_init() already brings up. The Maps tile becomes Alarms on LILYGO_TWATCH_S3 only. The Plus keeps Maps. The 'buzz' CLI command now also builds on the watch and takes an optional effect number ('buzz 14'), for characterising the motor. Exact-match behaviour of plain 'buzz' on the MAX is unchanged. --- examples/companion_radio/MyMesh.cpp | 27 +- examples/companion_radio/main.cpp | 20 +- examples/companion_radio/ui-new/UITask.cpp | 43 ++ examples/companion_radio/ui-new/UITask.h | 12 + variants/lilygo_twatch_s3/DRV2605Haptic.h | 86 ++++ variants/lilygo_twatch_s3/WatchAlarmScreen.h | 417 +++++++++++++++++++ 6 files changed, 595 insertions(+), 10 deletions(-) create mode 100644 variants/lilygo_twatch_s3/DRV2605Haptic.h create mode 100644 variants/lilygo_twatch_s3/WatchAlarmScreen.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8627e32e..ca6faaf7 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -12,8 +12,8 @@ #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 +#if defined(LilyGo_TDeck_Pro_Max) || defined(LILYGO_TWATCH_S3) + #include "DRV2605Haptic.h" // inline haptic driver for the 'buzz' CLI test command #endif #define CMD_APP_START 1 @@ -3613,20 +3613,29 @@ void MyMesh::checkCLIRescueCmd() { } } -#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. +#if defined(LilyGo_TDeck_Pro_Max) || defined(LILYGO_TWATCH_S3) + else if (strncmp(cli_command, "buzz", 4) == 0 && + (cli_command[4] == '\0' || cli_command[4] == ' ')) { + // Fire a DRV2605 library-1 effect to confirm the motor works. + // "buzz" uses effect 1 (strong click); "buzz " fires effect n. + // Lazy-inits the driver (and, on the MAX, its motor power rail). static DRV2605Haptic haptic; static bool haptic_ready = false; if (!haptic_ready) { - board.motorEnable(); +#if defined(LilyGo_TDeck_Pro_Max) + board.motorEnable(); // MAX: motor rail is behind an XL9555 pin delay(10); // let the motor rail settle before I2C +#endif haptic_ready = haptic.begin(); } if (haptic_ready) { - haptic.buzz(1); - Serial.println(" > buzz"); + int effect = 1; + const char* arg = cli_command + 4; + while (*arg == ' ') arg++; + if (*arg) effect = atoi(arg); + if (effect < 1 || effect > 123) effect = 1; + haptic.buzz((uint8_t)effect); + Serial.printf(" > buzz: effect %d\n", effect); } else { Serial.println(" > buzz: DRV2605 not found"); } diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f6818c96..92559348 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1125,6 +1125,9 @@ static uint32_t _atoi(const char* sp) { /* GLOBAL OBJECTS */ #ifdef DISPLAY_CLASS #include "UITask.h" + #if defined(LILYGO_TWATCH_S3) + #include "WatchAlarmScreen.h" // After UITask.h -- needs NodePrefs + #endif #if defined(MECK_TWATCH) && HAS_GPS #include "WatchMapScreen.h" // After BLE -- PNGdec headers conflict with BLE if included earlier #elif HAS_GPS && !defined(LILYGO_TECHO_CARD) @@ -1360,6 +1363,18 @@ static void lastHeardToggleContact() { if (wms) wms->handleTap(x, y); return 0; } +#endif +#if defined(LILYGO_TWATCH_S3) + // Alarm screen: rows, +/- steppers and the footer are all tap targets. + // readTouch() already returns logical (UI_ZOOM-divided) coords on the watch, + // which is the space WatchAlarmScreen lays out in. No further scaling. + if (ui_task.isOnWatchAlarmScreen()) { + WatchAlarmScreen* wa = (WatchAlarmScreen*)ui_task.getWatchAlarmScreen(); + if (wa) wa->handleTap(x, y); + return 0; + } +#endif +#if defined(MECK_TWATCH) // Watch: tiles open on long-press and pages change on swipe, so a plain tap // on any home page does nothing -- skip the T5S3 tile hit-test below (wrong // geometry for the 2-column watch grid) and the left/right page cycling. @@ -1806,7 +1821,10 @@ static void lastHeardToggleContact() { if (row == 1 && col == 0) { ui_task.gotoSettingsScreen(); return 0; } if (row == 1 && col == 1) { ui_task.gotoDiscoveryScreen(); return 0; } if (row == 2 && col == 0) { ui_task.gotoTraceScreen(); return 0; } -#if HAS_GPS +#if defined(LILYGO_TWATCH_S3) + // No GNSS on the plain S3, so this slot is the vibrate alarm clock. + if (row == 2 && col == 1) { ui_task.gotoWatchAlarmScreen(); return 0; } +#elif HAS_GPS if (row == 2 && col == 1) { // Maps: mark the tile FS ready (detectZoomRange in enter() needs it) // before opening; GPS centre + markers are populated in the main loop. diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 43a4d133..7c144dc6 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -76,6 +76,9 @@ #include "AudiobookPlayerScreen.h" #include "VoiceMessageScreen.h" #endif +#if defined(LILYGO_TWATCH_S3) +#include "WatchAlarmScreen.h" +#endif #ifdef TWATCH_COMPOSE_ENABLED #include "TWatchComposeScreens.h" #endif @@ -687,7 +690,11 @@ public: const Tile tiles[4][2] = { { {icon_envelope, "Messages", 0x0CB1}, {icon_people, "Contacts", 0xCAA0} }, { {icon_gear, "Settings", 0x0560}, {icon_search, "Discover", 0xF81F} }, +#if defined(LILYGO_TWATCH_S3) + { {icon_trace, "Trace", 0xF800}, {icon_alarm, "Alarms", 0x231D} }, +#else { {icon_trace, "Trace", 0xF800}, {icon_map, "Maps", 0x231D} }, +#endif { {icon_notepad, "Notes", 0x07FF}, {icon_star, "Steps", 0xFFE0} }, }; const uint16_t TILE_FILL = 0x18C5; // dark navy @@ -1693,6 +1700,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no lock_screen = new ClockScreen(this, &rtc_clock, node_prefs); steps_screen = new StepsScreen(this); #endif +#if defined(LILYGO_TWATCH_S3) + // LittleFS ("/maps" partition) is mounted in setup() before UITask::begin(). + watch_alarm_screen = new WatchAlarmScreen(this, &rtc_clock, node_prefs); + ((WatchAlarmScreen*)watch_alarm_screen)->load(); +#endif #ifdef TWATCH_COMPOSE_ENABLED tw_picker = new TWatchChannelPicker(_display); tw_channel = new TWatchChannelScreen(_display); @@ -2383,6 +2395,24 @@ void UITask::loop() { if (buzzer.isPlaying()) buzzer.loop(); #endif +#if defined(LILYGO_TWATCH_S3) + // Alarms are checked every loop, not from the screen's poll(), so one fires + // with the display off or another screen showing. + if (watch_alarm_screen) { + WatchAlarmScreen* wa = (WatchAlarmScreen*)watch_alarm_screen; + if (wa->tick()) { + setCurrScreen(watch_alarm_screen); + if (_display != NULL && !_display->isOn()) _display->turnOn(); + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 0; + } else if (wa->isRinging() && curr != watch_alarm_screen) { + // Navigating away (e.g. the status-bar tap that goes home) counts as a + // dismiss, otherwise the motor would keep buzzing off-screen. + wa->dismiss(); + } + } +#endif + if (curr) curr->poll(); #ifdef TWATCH_COMPOSE_ENABLED @@ -3678,6 +3708,19 @@ void UITask::gotoStepsScreen() { } #endif +#if defined(LILYGO_TWATCH_S3) +void UITask::gotoWatchAlarmScreen() { + WatchAlarmScreen* wa = (WatchAlarmScreen*)watch_alarm_screen; + if (wa) wa->enter(); + setCurrScreen(watch_alarm_screen); + if (_display != NULL && !_display->isOn()) { + _display->turnOn(); + } + _auto_off = millis() + AUTO_OFF_MILLIS; + _next_refresh = 100; +} +#endif + void UITask::gotoGamesMenu() { GamesMenuScreen* gm = (GamesMenuScreen*)games_menu_screen; gm->enter(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 0092d05a..67d9929b 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -123,6 +123,9 @@ class UITask : public AbstractUITask { UIScreen* steps_screen; // Steps screen (big daily step count) int32_t _lastStepDay = 0; // local day-of-epoch, for the midnight step reset uint32_t _stepBaseline = 0; // raw step count at the start of the local day +#endif +#if defined(LILYGO_TWATCH_S3) + UIScreen* watch_alarm_screen; // Vibrate-only alarm clock (no audio hardware) #endif UIScreen* _screenBeforeLock = nullptr; bool _locked = false; @@ -229,6 +232,9 @@ public: void gotoStepsScreen(); // Navigate to the step counter screen uint32_t getTodaySteps(); // Daily step count (raw - baseline) #endif +#if defined(LILYGO_TWATCH_S3) + void gotoWatchAlarmScreen(); // Navigate to the vibrate alarm clock +#endif #if HAS_GPS void gotoMapScreen(); // Navigate to map tile screen #endif @@ -292,6 +298,9 @@ public: bool isOnSnakeScreen() const { return curr == snake_screen; } #if defined(MECK_TWATCH) bool isOnStepsScreen() const { return curr == steps_screen; } +#endif +#if defined(LILYGO_TWATCH_S3) + bool isOnWatchAlarmScreen() const { return curr == watch_alarm_screen; } #endif bool isOnMinesweeperScreen() const { return curr == minesweeper_screen; } bool isOnMapScreen() const { return curr == map_screen; } @@ -384,6 +393,9 @@ public: UIScreen* getSnakeScreen() const { return snake_screen; } #if defined(MECK_TWATCH) UIScreen* getStepsScreen() const { return steps_screen; } +#endif +#if defined(LILYGO_TWATCH_S3) + UIScreen* getWatchAlarmScreen() const { return watch_alarm_screen; } #endif UIScreen* getMinesweeperScreen() const { return minesweeper_screen; } UIScreen* getMapScreen() const { return map_screen; } diff --git a/variants/lilygo_twatch_s3/DRV2605Haptic.h b/variants/lilygo_twatch_s3/DRV2605Haptic.h new file mode 100644 index 00000000..0219397d --- /dev/null +++ b/variants/lilygo_twatch_s3/DRV2605Haptic.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +// Minimal inline DRV2605 haptic driver for the LilyGo T-Watch S3. +// +// Copied from variants/lilygo_tdeck_max/DRV2605Haptic.h so that the MAX build is +// untouched. Register sequence is identical; only the power note differs. +// +// The DRV2605L (schematic sheet 3, U47) sits on the shared Wire bus at I2C 0x5A +// and drives the 0827 coin motor on J11. Unlike the T-Deck Pro MAX, whose motor +// supply is gated by an XL9555 expander pin (board.motorEnable()), the watch +// powers the DRV2605 from the AXP2101 BLDO2 rail, which TWatchS3Board::power_init() +// already brings up at 3300 mV. There is therefore nothing to enable before +// begin(); if it does not ACK, BLDO2 is off or the I2C bus is not up yet. +// +// 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. BLDO2 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; + } +}; diff --git a/variants/lilygo_twatch_s3/WatchAlarmScreen.h b/variants/lilygo_twatch_s3/WatchAlarmScreen.h new file mode 100644 index 00000000..fb9e8a4a --- /dev/null +++ b/variants/lilygo_twatch_s3/WatchAlarmScreen.h @@ -0,0 +1,417 @@ +#pragma once + +// ============================================================================= +// WatchAlarmScreen -- vibrate-only alarm clock for the LilyGo T-Watch S3. +// +// The T-Deck Pro's AlarmScreen.h is bound to ESP32-audioI2S, an SD card and a +// 1-21 volume scale, none of which exist here. This is a separate, much smaller +// screen that keeps AlarmScreen's conventions (bit 0 = Sunday day-of-week mask, +// dowFromEpoch, the 3-buzz / 4s-pause vibrate cadence, effect 14) so the two +// stay recognisably related. +// +// Alarm is the DRV2605L on I2C 0x5A driving the 0827 coin motor. Its BLDO2 rail +// is already up from power_init(), so no motorEnable() call is needed. +// +// Config lives on the LittleFS partition mounted at /maps in main.cpp -- the +// same one the Plus uses for map tiles. The watch has no SD card. +// +// Geometry: the display is 240x240 physical, UI_ZOOM=2, so display.width() and +// display.height() both report 120 logical pixels. +// ============================================================================= + +// NOTE on include order: this header uses NodePrefs, which lives at +// examples/companion_radio/NodePrefs.h and is not on this variant's -I path. +// UITask.h pulls it in via "../NodePrefs.h", so WatchAlarmScreen.h must always +// be included *after* UITask.h. Both call sites (UITask.cpp, main.cpp) do. +// This mirrors how TWatchComposeScreens.h depends on ChannelScreen.h. +#include +#include +#include +#include // mesh::RTCClock +#include +#include +#include +#include "DRV2605Haptic.h" + +class UITask; + +#define WATCH_ALARM_SLOT_COUNT 4 +#define WATCH_ALARM_CFG_PATH "/alarms.cfg" +#define WATCH_ALARM_CFG_MAGIC 0x334B414CUL // "LAK3" +#define WATCH_ALARM_CFG_VERSION 1 + +// Day-of-week bitmask, bit 0 = Sunday (matches AlarmScreen.h) +#define WDOW_SUN (1 << 0) +#define WDOW_MON (1 << 1) +#define WDOW_TUE (1 << 2) +#define WDOW_WED (1 << 3) +#define WDOW_THU (1 << 4) +#define WDOW_FRI (1 << 5) +#define WDOW_SAT (1 << 6) +#define WDOW_ALL 0x7F +#define WDOW_WEEKDAYS (WDOW_MON | WDOW_TUE | WDOW_WED | WDOW_THU | WDOW_FRI) +#define WDOW_WEEKEND (WDOW_SAT | WDOW_SUN) + +// Vibrate cadence, identical to AlarmScreen's silent alarm on the T-Deck MAX. +#define WATCH_ALARM_EFFECT 14 // library 1: strong buzz, ~1s +#define WATCH_ALARM_BUZZ_MS 1200 // spacing between buzz starts +#define WATCH_ALARM_GROUP 3 // buzzes per group +#define WATCH_ALARM_PAUSE_MS 4000 // pause after each group +#define WATCH_ALARM_RINGING_MS 300000 // 5 minute auto-dismiss +#define WATCH_ALARM_FIRE_COOLDOWN 90 // seconds; do not re-fire the same slot + +struct WatchAlarmSlot { + bool enabled; + uint8_t hour; // 0-23 + uint8_t minute; // 0-59 + uint8_t days; // day-of-week bitmask; WDOW_ALL = every day +}; + +struct WatchAlarmConfig { + uint32_t magic; + uint8_t version; + uint8_t _pad[3]; + WatchAlarmSlot slots[WATCH_ALARM_SLOT_COUNT]; +}; + +class WatchAlarmScreen : public UIScreen { +public: + enum Mode { LIST, EDIT, RINGING }; + +private: + UITask* _task; + mesh::RTCClock* _rtc; + NodePrefs* _node_prefs; + + WatchAlarmConfig _cfg; + Mode _mode = LIST; + int _sel = 0; // selected slot in LIST / slot under EDIT + WatchAlarmSlot _edit; // working copy while editing + + // Ringing state + int _ringSlot = -1; + unsigned long _ringStartMs = 0; + unsigned long _vibNextMs = 0; + int _vibCount = 0; + + // Fire tracking + uint32_t _lastFireEpoch[WATCH_ALARM_SLOT_COUNT] = {0}; + + DRV2605Haptic _haptic; + bool _hapticReady = false; + + static int dowFromEpoch(uint32_t epoch) { + // 1970-01-01 was a Thursday (index 4 with 0 = Sunday) + return (int)((epoch / 86400 + 4) % 7); + } + + int32_t localNow() const { + uint32_t utc = _rtc->getCurrentTime(); + if (utc < 1700000000) return -1; // clock not set yet + return (int32_t)utc + ((int32_t)_node_prefs->utc_offset_hours * 3600); + } + + static const char* daysLabel(uint8_t d) { + if (d == WDOW_ALL) return "Daily"; + if (d == WDOW_WEEKDAYS) return "Mon-Fri"; + if (d == WDOW_WEEKEND) return "Sat/Sun"; + if (d == 0) return "Once"; + return "Custom"; + } + + static uint8_t nextDaysPreset(uint8_t d) { + if (d == WDOW_ALL) return WDOW_WEEKDAYS; + if (d == WDOW_WEEKDAYS) return WDOW_WEEKEND; + if (d == WDOW_WEEKEND) return 0; // Once + return WDOW_ALL; + } + + void loadDefaults() { + memset(&_cfg, 0, sizeof(_cfg)); + _cfg.magic = WATCH_ALARM_CFG_MAGIC; + _cfg.version = WATCH_ALARM_CFG_VERSION; + for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) { + _cfg.slots[i].enabled = false; + _cfg.slots[i].hour = 7; + _cfg.slots[i].minute = 0; + _cfg.slots[i].days = WDOW_ALL; + } + } + +public: + WatchAlarmScreen(UITask* task, mesh::RTCClock* rtc, NodePrefs* node_prefs) + : _task(task), _rtc(rtc), _node_prefs(node_prefs) { + loadDefaults(); + } + + // Read the config from LittleFS. Called once from UITask::begin() after the + // /maps partition is mounted. A missing or malformed file leaves the defaults. + void load() { + File f = LittleFS.open(WATCH_ALARM_CFG_PATH, "r"); + if (!f) return; + WatchAlarmConfig tmp; + size_t n = f.read((uint8_t*)&tmp, sizeof(tmp)); + f.close(); + if (n != sizeof(tmp)) return; + if (tmp.magic != WATCH_ALARM_CFG_MAGIC) return; + if (tmp.version != WATCH_ALARM_CFG_VERSION) return; + for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) { + if (tmp.slots[i].hour > 23 || tmp.slots[i].minute > 59) return; // reject junk + } + _cfg = tmp; + } + + void save() { + File f = LittleFS.open(WATCH_ALARM_CFG_PATH, "w"); + if (!f) { + Serial.println("WatchAlarmScreen: save FAILED (LittleFS)"); + return; + } + f.write((const uint8_t*)&_cfg, sizeof(_cfg)); + f.close(); + } + + Mode getMode() const { return _mode; } + bool isRinging() const { return _mode == RINGING; } + + void enter() { _mode = LIST; } + + // --------------------------------------------------------------------------- + // Firing. Driven from UITask::loop() so an alarm still fires with the display + // off or another screen showing. Returns true on the loop where it fires. + // --------------------------------------------------------------------------- + bool tick() { + if (_mode == RINGING) { + driveVibrate(); + if (millis() - _ringStartMs > WATCH_ALARM_RINGING_MS) dismiss(); + return false; + } + + int32_t local = localNow(); + if (local < 0) return false; + + int hrs = (local / 3600) % 24; if (hrs < 0) hrs += 24; + int mins = (local / 60) % 60; if (mins < 0) mins += 60; + int dow = dowFromEpoch((uint32_t)local); + uint32_t nowEpoch = (uint32_t)local; + + for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) { + const WatchAlarmSlot& s = _cfg.slots[i]; + if (!s.enabled) continue; + if (s.hour != hrs || s.minute != mins) continue; + if (s.days != 0 && !(s.days & (1 << dow))) continue; // days==0 means Once + if (nowEpoch - _lastFireEpoch[i] < WATCH_ALARM_FIRE_COOLDOWN) continue; + + _lastFireEpoch[i] = nowEpoch; + if (_cfg.slots[i].days == 0) { // one-shot: disarm after firing + _cfg.slots[i].enabled = false; + save(); + } + startRinging(i); + return true; + } + return false; + } + + void startRinging(int slot) { + _mode = RINGING; + _ringSlot = slot; + _ringStartMs = millis(); + _vibNextMs = 0; + _vibCount = 0; + } + + void dismiss() { + _mode = LIST; + _ringSlot = -1; + } + +private: + void driveVibrate() { + if (!_hapticReady) { + _hapticReady = _haptic.begin(); // BLDO2 is already up; no motorEnable() + if (!_hapticReady) return; + } + if (millis() < _vibNextMs) return; + + _haptic.buzz(WATCH_ALARM_EFFECT); + if (++_vibCount >= WATCH_ALARM_GROUP) { + _vibCount = 0; + _vibNextMs = millis() + WATCH_ALARM_PAUSE_MS; + } else { + _vibNextMs = millis() + WATCH_ALARM_BUZZ_MS; + } + } + + // --------------------------------------------------------------------------- + // Rendering. 120x120 logical. + // --------------------------------------------------------------------------- + static const int ROW_Y0 = 22; // first list row top + static const int ROW_H = 22; // list row height + static const int TOGGLE_X = 88; // left edge of the ON/OFF badge column + + void renderList(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 6, "Alarms"); + + char buf[16]; + for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) { + int y = ROW_Y0 + i * ROW_H; + const WatchAlarmSlot& s = _cfg.slots[i]; + + if (i == _sel) { + d->setRawColor(0x231D); // same blue as the home tile + d->drawRoundRect(1, y, display.width() - 2, ROW_H - 2, 3); + } + + d->setRawColor(s.enabled ? 0xFFFF : 0x7BEF); // white when armed, grey when off + snprintf(buf, sizeof(buf), "%02d:%02d", s.hour, s.minute); + display.setCursor(5, y + 3); + display.print(buf); + + d->setRawColor(0x7BEF); + d->printSmallFont(5, y + 14, daysLabel(s.days)); + + d->setRawColor(s.enabled ? 0xC618 : 0x4208); + d->printSmallFont(TOGGLE_X + 4, y + 8, s.enabled ? "ON" : "OFF"); + } + + display.setColor(DisplayDriver::LIGHT); + d->setRawColor(0x7BEF); + d->printSmallFont(4, display.height() - 7, "Tap time:edit ON/OFF:arm"); + } + + void renderEdit(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 4, "Edit alarm"); + + // Up arrows / value / down arrows. Hour occupies x 10..54, minute 66..110. + d->setRawColor(0xC618); + display.drawTextCentered(32, 22, "+"); + display.drawTextCentered(88, 22, "+"); + display.drawTextCentered(32, 74, "-"); + display.drawTextCentered(88, 74, "-"); + + char buf[8]; + d->setRawColor(0xFFFF); + display.setTextSize(2); + snprintf(buf, sizeof(buf), "%02d", _edit.hour); + display.drawTextCentered(32, 44, buf); + snprintf(buf, sizeof(buf), "%02d", _edit.minute); + display.drawTextCentered(88, 44, buf); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, 48, ":"); + + // Footer: left half cycles the day preset, right half saves. + d->setRawColor(0x231D); + d->drawRoundRect(1, 92, 58, 18, 3); + d->drawRoundRect(61, 92, 58, 18, 3); + d->setRawColor(0xC618); + d->printSmallFont(6, 100, daysLabel(_edit.days)); + d->setRawColor(0xFFFF); + d->printSmallFont(78, 100, "Save"); + } + + void renderRinging(DisplayDriver& display) { + LGFXDisplay* d = (LGFXDisplay*)&display; + char buf[8]; + + d->setRawColor(0xFFFF); + if (_ringSlot >= 0) { + snprintf(buf, sizeof(buf), "%02d:%02d", _cfg.slots[_ringSlot].hour, + _cfg.slots[_ringSlot].minute); + } else { + strcpy(buf, "--:--"); + } + d->printClockFont(display.width() / 2, display.height() / 2 - 22, buf); + + display.setColor(DisplayDriver::LIGHT); + display.setTextSize(1); + display.drawTextCentered(display.width() / 2, display.height() - 24, "ALARM"); + ((LGFXDisplay*)&display)->printSmallFont(24, display.height() - 10, "Tap to dismiss"); + } + +public: + int render(DisplayDriver& display) override { + switch (_mode) { + case LIST: renderList(display); return 500; + case EDIT: renderEdit(display); return 200; + case RINGING: renderRinging(display); return 250; + } + return 500; + } + + // Physical tap in logical (display.width()) coordinates. Returns true if the + // tap was consumed. The status-bar strip is handled by main.cpp before this. + bool handleTap(int lx, int ly) { + if (_mode == RINGING) { dismiss(); return true; } + + if (_mode == LIST) { + for (int i = 0; i < WATCH_ALARM_SLOT_COUNT; i++) { + int y = ROW_Y0 + i * ROW_H; + if (ly < y || ly >= y + ROW_H - 2) continue; + if (lx >= TOGGLE_X) { // ON/OFF badge: arm / disarm + _cfg.slots[i].enabled = !_cfg.slots[i].enabled; + _sel = i; + save(); + } else { // time area: open the editor + _sel = i; + _edit = _cfg.slots[i]; + _mode = EDIT; + } + return true; + } + return false; + } + + // EDIT + if (ly >= 92) { // footer + if (lx < 60) { + _edit.days = nextDaysPreset(_edit.days); + } else { + commitEdit(); + } + return true; + } + bool hourCol = (lx < display_mid()); + if (ly < 40) { // upper half: increment + if (hourCol) _edit.hour = (_edit.hour + 1) % 24; + else _edit.minute = (_edit.minute + 1) % 60; + return true; + } + if (ly >= 62) { // lower half: decrement + if (hourCol) _edit.hour = (_edit.hour + 23) % 24; + else _edit.minute = (_edit.minute + 59) % 60; + return true; + } + return false; + } + + bool handleInput(char c) override { + // The PMU short press arrives as KEY_ENTER. + if (_mode == RINGING) { dismiss(); return true; } + if (_mode == EDIT && c == KEY_ENTER) { commitEdit(); return true; } + if (_mode == LIST && c == KEY_ENTER) { + _edit = _cfg.slots[_sel]; + _mode = EDIT; + return true; + } + return false; + } + +private: + int display_mid() const { return 60; } // half of the 120px logical width + + void commitEdit() { + _cfg.slots[_sel] = _edit; + _cfg.slots[_sel].enabled = true; // saving an edit arms the alarm + save(); + _mode = LIST; + } +};