mirror of
https://github.com/pelgraine/Meck.git
synced 2026-07-29 04:42:41 +02:00
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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user