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.
This commit is contained in:
meck
2026-07-10 09:37:05 +00:00
committed by pelgraine
parent df00c435f9
commit 9754f93504
6 changed files with 595 additions and 10 deletions
+18 -9
View File
@@ -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 <n>" 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");
}
+19 -1
View File
@@ -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.
@@ -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();
+12
View File
@@ -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; }
+86
View File
@@ -0,0 +1,86 @@
#pragma once
#include <Arduino.h>
#include <Wire.h>
// 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;
}
};
@@ -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 <Arduino.h>
#include <string.h>
#include <LittleFS.h>
#include <MeshCore.h> // mesh::RTCClock
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/LGFXDisplay.h>
#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;
}
};