diff --git a/boards/lilygo_twatch_s3.json b/boards/lilygo_twatch_s3.json new file mode 100644 index 00000000..6eb2b165 --- /dev/null +++ b/boards/lilygo_twatch_s3.json @@ -0,0 +1,54 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default_16MB.csv", + "memory_type": "qio_opi" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "psram_type": "opi", + "hwids": [ + [ + "0x303A", + "0x1001" + ] + ], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": [ + "wifi", + "bluetooth" + ], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": [ + "esp-builtin" + ], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": [ + "arduino", + "espidf" + ], + "name": "LilyGo T-Watch S3 (16M Flash 8M PSRAM)", + "upload": { + "flash_size": "16MB", + "maximum_ram_size": 327680, + "maximum_size": 16777216, + "require_upload_port": true, + "use_1200bps_touch": true, + "wait_for_upload_port": true, + "speed": 921600 + }, + "url": "https://www.lilygo.cc/products/t-watch-s3", + "vendor": "LilyGo" +} diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 02132e95..43a4d133 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2259,6 +2259,12 @@ void UITask::loop() { } else { c = checkDisplayOn(KEY_NEXT); } +#elif defined(MECK_PMU_BUTTON) + // T-Watch S3: the only key is the AXP2101 PWRON, so a short press is the + // sole button gesture available. It acts as enter/select/confirm (in the + // contacts screen, that opens the path editor). A >=6s hold is a hardware + // power-off and never reaches here. + c = checkDisplayOn(KEY_ENTER); #else c = checkDisplayOn(KEY_NEXT); #endif diff --git a/variants/lilygo_twatch_s3/CPUPowerManager.h b/variants/lilygo_twatch_s3/CPUPowerManager.h new file mode 100644 index 00000000..444a90bd --- /dev/null +++ b/variants/lilygo_twatch_s3/CPUPowerManager.h @@ -0,0 +1,113 @@ +#pragma once + +#include + +// CPU Frequency Scaling for ESP32-S3 +// +// Typical current draw (CPU only, rough): +// 240 MHz ~70-80 mA +// 160 MHz ~50-60 mA +// 80 MHz ~30-40 mA +// 40 MHz ~15-20 mA (low-power / lock screen mode) +// +// SPI peripherals and UART use their own clock dividers from the APB clock, +// so LoRa, e-ink, and GPS serial all work fine at 80MHz and 40MHz. + +#ifdef ESP32 + +#ifndef CPU_FREQ_IDLE +#define CPU_FREQ_IDLE 80 // MHz — normal mesh listening +#endif + +#ifndef CPU_FREQ_BOOST +#define CPU_FREQ_BOOST 240 // MHz — heavy processing +#endif + +#ifndef CPU_FREQ_LOW_POWER +#define CPU_FREQ_LOW_POWER 80 // MHz — lock screen / idle standby (40 MHz breaks I2C) +#endif + +#ifndef CPU_BOOST_TIMEOUT_MS +#define CPU_BOOST_TIMEOUT_MS 10000 // 10 seconds +#endif + +class CPUPowerManager { +public: + CPUPowerManager() : _boosted(false), _lowPower(false), _boost_started(0) {} + + void begin() { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + _lowPower = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + + void loop() { + if (_boosted && (millis() - _boost_started >= CPU_BOOST_TIMEOUT_MS)) { + // Return to low-power if locked, otherwise normal idle + if (_lowPower) { + setCpuFrequencyMhz(CPU_FREQ_LOW_POWER); + MESH_DEBUG_PRINTLN("CPU power: boost expired, returning to low-power %d MHz", CPU_FREQ_LOW_POWER); + } else { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + _boosted = false; + } + } + + void setBoost() { + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_BOOST); + _boosted = true; + MESH_DEBUG_PRINTLN("CPU power: boosted to %d MHz", CPU_FREQ_BOOST); + } + _boost_started = millis(); + } + + void setIdle() { + if (_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + _boosted = false; + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz", CPU_FREQ_IDLE); + } + if (_lowPower) { + _lowPower = false; + } + } + + // Low-power mode — drops CPU to 40 MHz for lock screen standby. + // If currently boosted, the boost timeout will return to 40 MHz + // instead of 80 MHz. + void setLowPower() { + _lowPower = true; + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_LOW_POWER); + MESH_DEBUG_PRINTLN("CPU power: low-power at %d MHz", CPU_FREQ_LOW_POWER); + } + // If boosted, the loop() timeout will drop to low-power instead of idle + } + + // Exit low-power mode — returns to normal idle (80 MHz). + // If currently boosted, the boost timeout will return to idle + // instead of low-power. + void clearLowPower() { + _lowPower = false; + if (!_boosted) { + setCpuFrequencyMhz(CPU_FREQ_IDLE); + MESH_DEBUG_PRINTLN("CPU power: idle at %d MHz (low-power cleared)", CPU_FREQ_IDLE); + } + // If boosted, the loop() timeout will drop to idle as normal + } + + bool isBoosted() const { return _boosted; } + bool isLowPower() const { return _lowPower; } + uint32_t getFrequencyMHz() const { return getCpuFrequencyMhz(); } + +private: + bool _boosted; + bool _lowPower; + unsigned long _boost_started; +}; + +#endif // ESP32 \ No newline at end of file diff --git a/variants/lilygo_twatch_s3/PMUButton.h b/variants/lilygo_twatch_s3/PMUButton.h new file mode 100644 index 00000000..97b2ebf2 --- /dev/null +++ b/variants/lilygo_twatch_s3/PMUButton.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include // BUTTON_EVENT_* constants +#include "TWatchS3Board.h" + +// PMUButton -- a MomentaryButton work-alike for the LilyGo T-Watch S3, whose +// only control is the side PWR key. That key is not a GPIO: schematic sheet 1 +// wires SW7 to net PWR_KEY, which lands on AXP2101 pin 30 (PWRON). Presses are +// therefore reported as PMU interrupts and read back over I2C. +// +// It exposes the three methods UITask uses on `user_btn` (begin, isPressed, +// check), so it drops into target.h in place of MomentaryButton. +// +// Event mapping, set up in TWatchS3Board::power_init(): +// press < 1s PKEY_SHORT_IRQ -> BUTTON_EVENT_CLICK +// 1s <= press < 6s PKEY_LONG_IRQ masked; no event +// press >= 6s hardware power-off never reaches firmware +// +// BUTTON_EVENT_LONG_PRESS, _DOUBLE_CLICK and _TRIPLE_CLICK are never returned: +// the long hold belongs to the PMU's power-off, and the AXP2101 reports no +// multi-click. +// +// The IRQ line (PIN_PMU_IRQ, GPIO21) is not used. check() polls the PMU's +// latched status registers directly, which removes any dependence on that pin +// being wired, pulled up, or serviced by an ISR. Three I2C register reads every +// POLL_INTERVAL_MS is negligible on a bus already shared with the RTC and +// accelerometer. Note that clearIrqStatus() is write-1-to-clear across all +// three status registers, so an edge landing inside the ~200us between the read +// and the clear is dropped; press and release are ~100ms apart, so this cannot +// swallow a real event. +class PMUButton { + TWatchS3Board& _board; + unsigned long _last_poll = 0; + bool _down = false; + + static const unsigned long POLL_INTERVAL_MS = 30; + +public: + PMUButton(TWatchS3Board& board) : _board(board) {} + + // The PWRON key needs no pin setup; the PMU is configured in power_init(), + // which board.begin() runs before UITask::begin() reaches here. + void begin() {} + + // True while the key is held down. Updated by check(), so it is at most + // POLL_INTERVAL_MS stale. UITask uses this to abort a pending shutdown. + bool isPressed() const { return _down; } + + int check() { + if (millis() - _last_poll < POLL_INTERVAL_MS) return BUTTON_EVENT_NONE; + _last_poll = millis(); + + XPowersAXP2101* pmu = _board.getPMU(); + if (pmu == NULL) return BUTTON_EVENT_NONE; + + pmu->getIrqStatus(); // latch INTSTS1..3 into the driver's buffer + + // Apply the edges before the click, so that a poll which catches press, + // release and short-press together still leaves _down false. + if (pmu->isPekeyNegativeIrq()) _down = true; // PWRON is active low: press + if (pmu->isPekeyPositiveIrq()) _down = false; // release + + int ev = pmu->isPekeyShortPressIrq() ? BUTTON_EVENT_CLICK : BUTTON_EVENT_NONE; + + pmu->clearIrqStatus(); + return ev; + } +}; diff --git a/variants/lilygo_twatch_s3/TWatchComposeScreens.h b/variants/lilygo_twatch_s3/TWatchComposeScreens.h new file mode 100644 index 00000000..ade7eceb --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchComposeScreens.h @@ -0,0 +1,632 @@ +#pragma once +// ============================================================================= +// TWatchComposeScreens — touch keyboard compose system for the LilyGo +// T-Watch S3 Plus (ST7789 240x240, capacitive touch, companion_radio ui-new). +// +// Three UIScreen subclasses, compiled only when TWATCH_COMPOSE_ENABLED is +// defined (companion build only). All drawing and hit-testing is in the +// LGFXDisplay virtual coordinate space, which is 120x120 on this build +// (240px panel / UI_ZOOM=2). getTouch() returns coords already divided by +// UI_ZOOM, so a tap coordinate is directly comparable to draw coordinates. +// +// TWatchChannelPicker — long-press on the home screen opens this. Tap the +// top half to move the highlight up, the bottom half +// to move it down; long-press selects the highlighted +// channel; the back arrow (top-left) returns home. +// TWatchChannelScreen -- shows the most recent messages (sent + received) +// for the selected channel, read live from the shared +// ChannelScreen history store. Tap a line to ticker- +// scroll it; tap the compose bar to open the keyboard; +// back arrow returns home. +// TWatchKeyboardScreen — on-screen QWERTY. Bottom-left mode key cycles +// lower -> UPPER -> SYM. Bottom row is mode | space | +// enter | backspace. Enter sends on the channel; back +// arrow returns home. +// +// Each screen reads getTouch() itself in poll() and does its own release-edge +// detection, so actions fire on touch release (finger up) — this keeps the +// held finger from carrying into the next screen after a transition. Send/exit +// are delegated to UITask via the consume/flag pattern; message history and +// unread counts are read from the shared ChannelScreen store. +// ============================================================================= + +#ifdef TWATCH_COMPOSE_ENABLED + +#include +#include +#include +#include +#include +#include "ChannelScreen.h" // shared message history store (-I examples/companion_radio/ui-new) + +// ---- Tunables --------------------------------------------------------------- +#define TW_LONG_PRESS_MS 600 // hold to select a channel in the picker +#define TW_OUT_BUF_LEN 134 // MeshCore per-channel msg cap (~133) + NUL +#define TW_TICKER_MS_PER_PX 20 // channel-screen ticker scroll speed (ms per pixel) +#define TW_CH_NAME_LEN 32 +#define TW_PICKER_MAX 20 // matches MAX_GROUP_CHANNELS + +// ============================================================================= +// TWatchChannelPicker +// ============================================================================= +class TWatchChannelPicker : public UIScreen { + DisplayDriver* _display; + + struct Entry { uint8_t idx; char name[TW_CH_NAME_LEN]; }; + Entry _channels[TW_PICKER_MAX]; + uint8_t _numChannels; + uint8_t _highlighted; + uint8_t _scrollTop; + ChannelScreen* _store; // unread badge source + + // touch edge state + bool _touchDown; + int _downX, _downY; + unsigned long _downAt; + + // cross-screen flags (UITask polls these) + bool _confirmed; + bool _wantsExit; + + static const int HEADER_H = 14; + static const int ROW_H = 14; + + int visibleRows() const { + int h = _display ? _display->height() : 120; + int rows = (h - HEADER_H) / ROW_H; + return rows < 1 ? 1 : rows; + } + void ensureVisible() { + int vis = visibleRows(); + if (_highlighted < _scrollTop) _scrollTop = _highlighted; + else if (_highlighted >= _scrollTop + vis) _scrollTop = (uint8_t)(_highlighted - vis + 1); + } + void moveUp() { + if (_numChannels == 0) return; + _highlighted = (_highlighted == 0) ? (uint8_t)(_numChannels - 1) : (uint8_t)(_highlighted - 1); + ensureVisible(); + } + void moveDown() { + if (_numChannels == 0) return; + _highlighted = (uint8_t)((_highlighted + 1) % _numChannels); + ensureVisible(); + } + +public: + TWatchChannelPicker(DisplayDriver* display) + : _display(display), _numChannels(0), _highlighted(0), _scrollTop(0), + _store(nullptr), + _touchDown(false), _downX(0), _downY(0), _downAt(0), + _confirmed(false), _wantsExit(false) { + memset(_channels, 0, sizeof(_channels)); + } + + // Shared history store, used for the per-channel unread badges. + void setStore(ChannelScreen* store) { _store = store; } + + // Called by UITask before showing the screen. + void beginChannelSelect() { + _numChannels = 0; _highlighted = 0; _scrollTop = 0; + _touchDown = false; _confirmed = false; _wantsExit = false; + } + void addChannel(uint8_t idx, const char* name) { + if (_numChannels >= TW_PICKER_MAX) return; + _channels[_numChannels].idx = idx; + strncpy(_channels[_numChannels].name, name ? name : "", TW_CH_NAME_LEN - 1); + _channels[_numChannels].name[TW_CH_NAME_LEN - 1] = 0; + _numChannels++; + } + + // UITask bridges + bool isConfirmed() const { return _confirmed; } + void acknowledgeConfirm() { _confirmed = false; } + uint8_t getSelectedChannelIdx() const { return _channels[_highlighted].idx; } + const char* getSelectedChannelName() const { return _channels[_highlighted].name; } + bool wantsExit() const { return _wantsExit; } + void acknowledgeExit() { _wantsExit = false; } + + bool handleInput(char c) override { return false; } // touch handled in poll() + + void poll() override { + if (!_display) return; + int x, y; + bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y); + if (now && !_touchDown) { + _touchDown = true; _downX = x; _downY = y; _downAt = millis(); + } else if (!now && _touchDown) { + _touchDown = false; + unsigned long held = millis() - _downAt; + if (_downX < 20 && _downY < HEADER_H) { _wantsExit = true; return; } // back arrow + if (held >= TW_LONG_PRESS_MS) { + if (_numChannels > 0) _confirmed = true; // select highlighted + } else { + if (_downY < _display->height() / 2) moveUp(); else moveDown(); // tap zones + } + } + } + + int render(DisplayDriver& display) override { + const int W = display.width(); + display.setTextSize(1); + + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, 0); + display.print("<"); + display.setColor(DisplayDriver::GREEN); + display.drawTextCentered(W / 2, 0, "CHANNELS"); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, HEADER_H - 2, W, 1); + + if (_numChannels == 0) { + display.setCursor(2, HEADER_H + 2); + display.print("(no channels)"); + return 500; + } + + int vis = visibleRows(); + int y = HEADER_H; + for (int i = 0; i < vis; i++) { + int ci = _scrollTop + i; + if (ci >= _numChannels) break; + if (ci == _highlighted) { + display.setColor(DisplayDriver::GREEN); + display.fillRect(0, y, W, ROW_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + int nameMaxW = W - 6; + int unread = _store ? _store->getUnreadForChannel(_channels[ci].idx) : 0; + if (unread > 0) { + char cnt[8]; + snprintf(cnt, sizeof(cnt), "%d", unread > 99 ? 99 : unread); + int cw = display.getTextWidth(cnt); + display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::BLUE); + display.drawTextRightAlign(W - 3, y + 2, cnt); + display.setColor((ci == _highlighted) ? DisplayDriver::DARK : DisplayDriver::LIGHT); + nameMaxW = W - 6 - cw - 4; + } + display.drawTextEllipsized(3, y + 2, nameMaxW, _channels[ci].name); + y += ROW_H; + } + return 500; + } +}; + +// ============================================================================= +// TWatchChannelScreen +// ============================================================================= +class TWatchChannelScreen : public UIScreen { + DisplayDriver* _display; + ChannelScreen* _store; // shared message history store + uint8_t _channelIdx; + char _channelName[TW_CH_NAME_LEN]; + char _selfPrefix[TW_CH_NAME_LEN + 2]; // "NodeName: " -- marks sent lines + + bool _touchDown; + int _downX, _downY; + + bool _wantsCompose; + bool _wantsExit; + + int _selectedN; // recency index shown as ticker, -1 = none + unsigned long _tickerStartMs; + const void* _lastNewest; // newest store entry seen last render + uint32_t _lastNewestTs; // (a change dismisses the ticker) + + static const int HEADER_H = 14; + static const int COMPOSE_BAR_H = 18; + static const int MSG_LINE_H = 11; + static const int MSG_TOP = HEADER_H + 2; + + int rowsThatFit() const { + int h = _display ? _display->height() : 120; + int rows = (h - COMPOSE_BAR_H - 1 - MSG_TOP) / MSG_LINE_H; + return rows < 1 ? 1 : rows; + } + + // Store messages available for this channel, capped at what fits on screen. + int visibleCount() const { + if (!_store) return 0; + int maxRows = rowsThatFit(); + int n = 0; + while (n < maxRows && _store->getChannelMsgByRecency(_channelIdx, n)) n++; + return n; + } + + // Sent messages are stored with path_len 0 and a "NodeName: " prefix. + bool isSent(const ChannelScreen::ChannelMessage* m) const { + return m->path_len == 0 && _selfPrefix[0] != 0 && + strncmp(m->text, _selfPrefix, strlen(_selfPrefix)) == 0; + } + + void drawMsgLine(DisplayDriver& display, int y, const char* text, bool sent) { + const int W = display.width(); + const int maxW = W - 4; + display.setColor(DisplayDriver::LIGHT); + if (!sent) { + display.drawTextEllipsized(2, y, maxW, text); // incoming: left-aligned + return; + } + if (display.getTextWidth(text) <= maxW) { // sent: right-aligned + display.drawTextRightAlign(W - 2, y, text); + return; + } + char buf[CHANNEL_MSG_TEXT_LEN + 4]; + strncpy(buf, text, sizeof(buf) - 4); + buf[sizeof(buf) - 4] = 0; + int ellW = display.getTextWidth("..."); + int len = (int)strlen(buf); + while (len > 0 && display.getTextWidth(buf) > maxW - ellW) { buf[--len] = 0; } + strcat(buf, "..."); + display.drawTextRightAlign(W - 2, y, buf); + } + + void drawTicker(DisplayDriver& display, int y, const char* text, bool sent) { + const int W = display.width(); + int textW = display.getTextWidth(text); + display.setColor(DisplayDriver::LIGHT); + if (textW <= W - 4) { // fits -> nothing to scroll, keep alignment + if (sent) display.drawTextRightAlign(W - 2, y, text); + else { display.setCursor(2, y); display.print(text); } + return; + } + int period = textW + 24; // full text width + trailing gap + unsigned long elapsed = millis() - _tickerStartMs; + int off = (int)((elapsed / TW_TICKER_MS_PER_PX) % (unsigned long)period); + // Wrap off while the marquee draws: a long line must clip at the screen + // edge, not wrap onto the rows below. Restored straight after. + ((LGFXDisplay*)_display)->setTextWrap(false); + display.setCursor(2 - off, y); + display.print(text); + display.setCursor(2 - off + period, y); + display.print(text); + ((LGFXDisplay*)_display)->setTextWrap(true); + } + +public: + TWatchChannelScreen(DisplayDriver* display) + : _display(display), _store(nullptr), _channelIdx(0), + _touchDown(false), _downX(0), _downY(0), + _wantsCompose(false), _wantsExit(false), + _selectedN(-1), _tickerStartMs(0), + _lastNewest(nullptr), _lastNewestTs(0) { + _channelName[0] = 0; + _selfPrefix[0] = 0; + } + + // Shared history store this screen renders from. + void setStore(ChannelScreen* store) { _store = store; } + + // Own node name, used to right-align sent lines ("NodeName: text"). + void setSelfName(const char* name) { + if (!name || !name[0]) { _selfPrefix[0] = 0; return; } + snprintf(_selfPrefix, sizeof(_selfPrefix), "%s: ", name); + } + + // Switch to a channel (called when a channel is selected in the picker). + // Opening a channel marks its messages as read. + void activate(uint8_t idx, const char* name) { + _channelIdx = idx; + strncpy(_channelName, name ? name : "", TW_CH_NAME_LEN - 1); + _channelName[TW_CH_NAME_LEN - 1] = 0; + _touchDown = false; _wantsCompose = false; _wantsExit = false; + _selectedN = -1; + _lastNewest = nullptr; _lastNewestTs = 0; + if (_store) _store->markChannelRead(_channelIdx); + } + + uint8_t getChannelIdx() const { return _channelIdx; } + const char* getChannelName() const { return _channelName; } + + bool wantsCompose() const { return _wantsCompose; } + void acknowledgeCompose() { _wantsCompose = false; } + bool wantsExit() const { return _wantsExit; } + void acknowledgeExit() { _wantsExit = false; } + + bool handleInput(char c) override { return false; } + + void poll() override { + if (!_display) return; + int x, y; + bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y); + if (now && !_touchDown) { + _touchDown = true; _downX = x; _downY = y; + } else if (!now && _touchDown) { + _touchDown = false; + int H = _display->height(); + if (_downX < 20 && _downY < HEADER_H) { _selectedN = -1; _wantsExit = true; return; } // back arrow + if (_downY >= H - COMPOSE_BAR_H) { _selectedN = -1; _wantsCompose = true; return; } // compose bar + // message area -> tap to open/close ticker + if (_downY >= MSG_TOP && _downY < H - COMPOSE_BAR_H) { + int visualRow = (_downY - MSG_TOP) / MSG_LINE_H; + int count = visibleCount(); + if (visualRow >= 0 && visualRow < count) { + int n = (count - 1) - visualRow; // top row = oldest visible + if (_selectedN == n) _selectedN = -1; + else { _selectedN = n; _tickerStartMs = millis(); } + } + } + } + } + + int render(DisplayDriver& display) override { + const int W = display.width(); + const int H = display.height(); + display.setTextSize(1); + + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, 0); + display.print("<"); + display.setColor(DisplayDriver::GREEN); + display.drawTextCentered(W / 2, 0, _channelName); + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, HEADER_H - 2, W, 1); + + // A new message (sent or received) shifts the rows, so dismiss any open + // ticker when the newest store entry for this channel changes. + const ChannelScreen::ChannelMessage* newest = + _store ? _store->getChannelMsgByRecency(_channelIdx, 0) : nullptr; + if (newest != _lastNewest || (newest && newest->timestamp != _lastNewestTs)) { + _lastNewest = newest; + _lastNewestTs = newest ? newest->timestamp : 0; + _selectedN = -1; + } + + int count = visibleCount(); + if (count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(2, MSG_TOP); + display.print("(no messages)"); + } else { + int y = MSG_TOP; + for (int n = count - 1; n >= 0; n--) { // oldest visible (top) -> newest (bottom) + const ChannelScreen::ChannelMessage* m = _store->getChannelMsgByRecency(_channelIdx, n); + if (!m) continue; + bool sent = isSent(m); + if (n == _selectedN) drawTicker(display, y, m->text, sent); + else drawMsgLine(display, y, m->text, sent); + y += MSG_LINE_H; + } + } + + // compose bar + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, H - COMPOSE_BAR_H, W, COMPOSE_BAR_H - 1); + display.drawTextEllipsized(3, H - COMPOSE_BAR_H + 4, W - 6, "Tap to compose"); + return (_selectedN >= 0) ? 60 : 500; + } +}; + +// ============================================================================= +// TWatchKeyboardScreen +// ============================================================================= +class TWatchKeyboardScreen : public UIScreen { + DisplayDriver* _display; + uint8_t _channelIdx; + +public: + enum Purpose { TWKB_CHANNEL, TWKB_DM, TWKB_ADMIN_PASSWORD, TWKB_ADMIN_CLI }; + +private: + Purpose _purpose; + int _contextIdx; // channel idx (channel) or contact idx (DM/admin) + bool _mask; // render composed text as '*' (admin password) + unsigned long _lastCharAt; // when the last char was typed (for reveal window) + + static const unsigned long PW_REVEAL_MS = 2000; // show last char this long before masking + + char _outBuf[TW_OUT_BUF_LEN]; + uint16_t _outLen; + + enum Mode { LOWER, UPPER, SYM }; + Mode _mode; + + bool _touchDown; + int _downX, _downY; + + bool _wantsSend; + bool _wantsExit; + + static const int MAXLEN = TW_OUT_BUF_LEN - 1; // 133 + + // layout geometry (120x120 virtual space) + static const int TOPBAR_H = 15; + static const int KEY_W = 12; // 10 * 12 = 120 wide + static const int KEY_H = 26; + static const int GRID_Y = 15; // row0 top; rows at GRID_Y + row*KEY_H + + const char* const* layout() const { + static const char* const lower[3] = { "qwertyuiop", "asdfghjkl'", "zxcvbnm,.?" }; + static const char* const upper[3] = { "QWERTYUIOP", "ASDFGHJKL\"", "ZXCVBNM<>/" }; + static const char* const syms[3] = { "123!@#$%^&", "456()[]{}-", "7890+=_:;|" }; + return _mode == SYM ? syms : (_mode == UPPER ? upper : lower); + } + const char* modeLabel() const { + return _mode == SYM ? "SYM" : (_mode == UPPER ? "A-Z" : "a-z"); + } + + void appendChar(char ch) { + if (_outLen < MAXLEN) { _outBuf[_outLen++] = ch; _outBuf[_outLen] = 0; _lastCharAt = millis(); } + } + void backspace() { + if (_outLen > 0) { _outLen--; _outBuf[_outLen] = 0; _lastCharAt = 0; } + } + void cycleMode() { + _mode = (_mode == LOWER) ? UPPER : (_mode == UPPER ? SYM : LOWER); + } + // bottom row zones: mode 0..24 | space 24..72 | enter 72..96 | backspace 96..120 + void handleBottomRow(int x) { + if (x < 24) cycleMode(); + else if (x < 72) appendChar(' '); + else if (x < 96) { if (_outLen > 0) _wantsSend = true; } + else backspace(); + } + +public: + TWatchKeyboardScreen(DisplayDriver* display) + : _display(display), _channelIdx(0), + _purpose(TWKB_CHANNEL), _contextIdx(0), _mask(false), _lastCharAt(0), + _outLen(0), _mode(LOWER), + _touchDown(false), _downX(0), _downY(0), + _wantsSend(false), _wantsExit(false) { + _outBuf[0] = 0; + } + + // Enter compose on the given channel (called when the compose bar is tapped). + void activate(uint8_t idx, const char* name) { + (void)name; + _channelIdx = idx; + _purpose = TWKB_CHANNEL; + _contextIdx = idx; + _mask = false; + _outLen = 0; _outBuf[0] = 0; + _mode = LOWER; + _touchDown = false; _wantsSend = false; _wantsExit = false; + } + + // Enter text for a non-channel purpose (DM / admin password / admin CLI). + void activateFor(Purpose purpose, int contextIdx) { + _purpose = purpose; + _contextIdx = contextIdx; + _channelIdx = 0; + _mask = (purpose == TWKB_ADMIN_PASSWORD); + _outLen = 0; _outBuf[0] = 0; + _mode = LOWER; + _touchDown = false; _wantsSend = false; _wantsExit = false; + } + + Purpose getPurpose() const { return _purpose; } + int getContextIdx() const { return _contextIdx; } + + uint8_t getChannelIdx() const { return _channelIdx; } + bool consumeSendRequest(const char** textOut) { + if (!_wantsSend) return false; + _wantsSend = false; + if (textOut) *textOut = _outBuf; + return true; + } + void clearOutBuf() { _outLen = 0; _outBuf[0] = 0; } + bool wantsExit() const { return _wantsExit; } + void acknowledgeExit() { _wantsExit = false; } + + bool handleInput(char c) override { return false; } + + void poll() override { + if (!_display) return; + int x, y; + bool now = ((LGFXDisplay*)_display)->getTouch(&x, &y); + if (now && !_touchDown) { + _touchDown = true; _downX = x; _downY = y; + } else if (!now && _touchDown) { + _touchDown = false; + int x0 = _downX, y0 = _downY; + if (x0 < 20 && y0 < TOPBAR_H) { _wantsExit = true; return; } // back arrow + if (y0 < GRID_Y) return; // top bar (text) area + int row = (y0 - GRID_Y) / KEY_H; + if (row <= 2) { + int col = x0 / KEY_W; + char ch = layout()[row][col]; + if (ch) appendChar(ch); + } else { + handleBottomRow(x0); // bottom row + } + } + } + + int render(DisplayDriver& display) override { + const int W = display.width(); + display.setTextSize(1); + + // ---- top bar: back arrow + composed text tail + cursor ---- + display.setColor(DisplayDriver::YELLOW); + display.setCursor(0, 0); + display.print("<"); + + display.setColor(DisplayDriver::LIGHT); + { + // Mask the composed text with '*' for the admin password purpose. + char masked[TW_OUT_BUF_LEN]; + const char* src = _outBuf; + if (_mask) { + int m = (_outLen < TW_OUT_BUF_LEN - 1) ? _outLen : TW_OUT_BUF_LEN - 1; + for (int i = 0; i < m; i++) masked[i] = '*'; + // Reveal the most recently typed char briefly so it can be read. + if (m > 0 && (millis() - _lastCharAt < PW_REVEAL_MS)) masked[m - 1] = _outBuf[m - 1]; + masked[m] = 0; + src = masked; + } + const int avail = W - 16 - 4; // room after the back arrow, minus cursor + int len = _outLen; + int fit = 0; + for (int n = 1; n <= len; n++) { + if (display.getTextWidth(src + (len - n)) > avail) break; + fit = n; + } + char tail[TW_OUT_BUF_LEN + 2]; + strncpy(tail, src + (len - fit), sizeof(tail) - 2); + tail[sizeof(tail) - 2] = 0; + size_t tl = strlen(tail); + tail[tl] = '_'; tail[tl + 1] = 0; + display.setCursor(16, 2); + display.print(tail); + } + + // ---- key grid rows 0..2 ---- + const char* const* lay = layout(); + for (int row = 0; row < 3; row++) { + int ry = GRID_Y + row * KEY_H; + const char* r = lay[row]; + for (int col = 0; col < 10; col++) { + int kx = col * KEY_W; + display.setColor(DisplayDriver::GREEN); + display.drawRect(kx, ry, KEY_W, KEY_H); + char ch[2] = { r[col], 0 }; + display.setColor(DisplayDriver::LIGHT); + int tw = display.getTextWidth(ch); + display.setCursor(kx + (KEY_W - tw) / 2, ry + (KEY_H - 8) / 2); + display.print(ch); + } + } + + // ---- bottom row: mode | space | enter | backspace ---- + int by = GRID_Y + 3 * KEY_H; + + display.setColor(DisplayDriver::GREEN); + display.fillRect(0, by, 24, KEY_H); + display.setColor(DisplayDriver::DARK); + { + const char* ml = modeLabel(); + int tw = display.getTextWidth(ml); + display.setCursor((24 - tw) / 2, by + (KEY_H - 8) / 2); + display.print(ml); + } + + display.setColor(DisplayDriver::LIGHT); + display.drawRect(24, by, 48, KEY_H); // space + + display.setColor(DisplayDriver::GREEN); + display.fillRect(72, by, 24, KEY_H); + display.setColor(DisplayDriver::DARK); + { + const char* el = "OK"; + int tw = display.getTextWidth(el); + display.setCursor(72 + (24 - tw) / 2, by + (KEY_H - 8) / 2); + display.print(el); + } + + display.setColor(DisplayDriver::ORANGE); + display.fillRect(96, by, 24, KEY_H); + display.setColor(DisplayDriver::DARK); + { + const char* bl = "DEL"; + int tw = display.getTextWidth(bl); + display.setCursor(96 + (24 - tw) / 2, by + (KEY_H - 8) / 2); + display.print(bl); + } + + return 500; + } +}; + +#endif // TWATCH_COMPOSE_ENABLED \ No newline at end of file diff --git a/variants/lilygo_twatch_s3/TWatchS3Board.cpp b/variants/lilygo_twatch_s3/TWatchS3Board.cpp new file mode 100644 index 00000000..59fd8726 --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchS3Board.cpp @@ -0,0 +1,205 @@ +#include +#include "TWatchS3Board.h" +#include +#include // power-debug: esp_bt_controller_get_status() + +volatile bool TWatchS3Board::_tilt_flag = false; + +void IRAM_ATTR TWatchS3Board::onTiltISR() { _tilt_flag = true; } + +// ---- Wrapper-free BMA423 step counter (raw I2C) ---------------------------- +// SensorLib's SensorBMA423 step-counter methods do not compile in this build, +// so the step counter is driven directly over I2C. Register/offset/mask values +// are from the Bosch BMA423 driver. +#define BMA423_REG_STEP_CNT_OUT 0x1E // 4-byte little-endian step count output +#define BMA423_REG_FEATURE_CONFIG 0x5E // 64-byte feature config stream +#define BMA423_FEATURE_LEN 64 +#define BMA423_STEP_EN_BYTE 0x37 // BMA423_STEP_CNTR_OFFSET(0x36) + 1 +#define BMA423_STEP_EN_BIT 0x10 // BMA423_STEP_CNTR_EN_MSK + +#define BMA423_REG_POWER_CONF 0x7C // BMA4_POWER_CONF_ADDR +#define BMA423_ADV_PWR_SAVE_BIT 0x01 // BMA4_ADVANCE_POWER_SAVE_MSK + +static bool bma423ReadRegs(uint8_t reg, uint8_t* buf, uint8_t len) { + Wire.beginTransmission(I2C_ADDR_ACCEL); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((int)I2C_ADDR_ACCEL, (int)len) != len) return false; + for (uint8_t i = 0; i < len; i++) buf[i] = Wire.read(); + return true; +} + +static bool bma423WriteRegs(uint8_t reg, const uint8_t* buf, uint8_t len) { + Wire.beginTransmission(I2C_ADDR_ACCEL); + Wire.write(reg); + for (uint8_t i = 0; i < len; i++) Wire.write(buf[i]); + return Wire.endTransmission() == 0; +} + +// Enable the step counter by setting its enable bit in the feature config, +// preserving every other byte (tilt lives at a different offset, 0x3A, so it is +// untouched). The feature config can only be written with advanced-power-save +// disabled, so we bracket the write and restore the prior power state after. +static void bma423EnableStepCounter() { + uint8_t pc; + if (!bma423ReadRegs(BMA423_REG_POWER_CONF, &pc, 1)) return; // save power state + uint8_t off = pc & ~BMA423_ADV_PWR_SAVE_BIT; // disable adv power save + bma423WriteRegs(BMA423_REG_POWER_CONF, &off, 1); + delay(2); // wake from low-power (>=450us) + + uint8_t cfg[BMA423_FEATURE_LEN]; + if (bma423ReadRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN)) { + cfg[BMA423_STEP_EN_BYTE] |= BMA423_STEP_EN_BIT; + bma423WriteRegs(BMA423_REG_FEATURE_CONFIG, cfg, BMA423_FEATURE_LEN); + delay(1); // write settle + } + + bma423WriteRegs(BMA423_REG_POWER_CONF, &pc, 1); // restore power state +} + +void TWatchS3Board::begin() { + ESP32Board::begin(); + power_init(); + + // BMA423 accelerometer (always-on I2C, 0x19): enable the tilt / wrist-raise + // feature and its interrupt (routed to PIN1 -> GPIO14) for raise-to-wake. + _accel = new SensorBMA423(); + if (_accel->begin(Wire, I2C_ADDR_ACCEL, PIN_BOARD_SDA, PIN_BOARD_SCL)) { + _accel->setRemapAxes(SensorRemap::BOTTOM_LAYER_TOP_RIGHT_CORNER); + _accel->configAccelerometer(OperationMode::NORMAL, AccelFullScaleRange::FS_2G, + 50.0f, AccelBandwidth::OSR2_AVG2, AccelPerfMode::CIC_AVG_MODE); + // INT1 pin electrical config: level trigger, active high, push-pull, + // output enabled. INT1_IO_CTRL resets to output-disabled, so without + // this the pin never drives and INPUT_PULLDOWN reads low forever. + _accel->setInterruptPinConfig(InterruptPinMap::PIN1, false, false, true, false); + pinMode(PIN_ACCEL_IRQ, INPUT_PULLDOWN); + // Attach the edge ISR BEFORE enabling the tilt source, so the first + // assertion cannot occur before the handler is armed (a missed first edge + // on a self-clearing line otherwise locks tilt-wake out permanently). + attachInterrupt(digitalPinToInterrupt(PIN_ACCEL_IRQ), onTiltISR, RISING); + _accel->enableTiltDetector(true, true); + // Enable the hardware step counter via raw I2C (SensorLib's wrapper method + // does not compile in this build). It then counts in the BMA423 feature + // engine with no CPU cost, even while the display is off. + bma423EnableStepCounter(); + } + + esp_reset_reason_t reason = esp_reset_reason(); + if (reason == ESP_RST_DEEPSLEEP) { + long wakeup_source = esp_sleep_get_ext1_wakeup_status(); + if (wakeup_source & (1 << P_LORA_DIO_1)) { + startup_reason = BD_STARTUP_RX_PACKET; + } + rtc_gpio_hold_dis((gpio_num_t)P_LORA_NSS); + rtc_gpio_deinit((gpio_num_t)P_LORA_DIO_1); + } +} + +bool TWatchS3Board::power_init() { + PMU = new XPowersAXP2101(Wire, PIN_BOARD_SDA, PIN_BOARD_SCL, I2C_ADDR_PMU); + if (!PMU->init()) { + MESH_DEBUG_PRINTLN("Warning: Failed to find AXP2101 power management"); + delete PMU; + PMU = NULL; + return false; + } + + PMU->setChargingLedMode(XPOWERS_CHG_LED_CTRL_CHG); + + // Power rails per the T-Watch S3 PowerManage table, cross-checked against the + // schematic (rev 25-03-24): + // ALDO1 = unused, ALDO2 = display backlight, + // ALDO3 = display + touch, ALDO4 = LoRa (schematic net LDO4 -> HPD16B3 VCC), + // BLDO1 = unused (no GNSS), BLDO2 = DRV2605 haptic, + // DLDO1 = MAX98357A speaker amp VDD (schematic sheet 6, net SPK_VDD), + // VBACKUP = MS412FE rechargeable coin cell backing the PCF8563 RTC domain. + // + // LilyGo's hardware doc lists DLDO1 as unused. The schematic disagrees: it is + // the speaker rail. Meck compiles no audio, so it stays off, which fully + // unpowers the amp rather than merely idling it. + PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 3300); // LoRa radio + PMU->enablePowerOutput(XPOWERS_ALDO4); + PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // display + touch + PMU->enablePowerOutput(XPOWERS_ALDO3); + PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // display backlight + PMU->enablePowerOutput(XPOWERS_ALDO2); + PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // DRV2605 haptic + PMU->enablePowerOutput(XPOWERS_BLDO2); + + PMU->disablePowerOutput(XPOWERS_DCDC2); + PMU->disablePowerOutput(XPOWERS_DCDC3); + PMU->disablePowerOutput(XPOWERS_DCDC4); + PMU->disablePowerOutput(XPOWERS_DCDC5); + PMU->disablePowerOutput(XPOWERS_ALDO1); // unused + PMU->disablePowerOutput(XPOWERS_BLDO1); // GNSS rail on the Plus; unpopulated here + PMU->disablePowerOutput(XPOWERS_DLDO1); // MAX98357A speaker amp -- audio not compiled in + PMU->disablePowerOutput(XPOWERS_DLDO2); + + // RTC backup cell. The PCF8563 has a single VDD pin (no separate battery + // input), and the schematic diode-ORs it against the MS412FE on J12, which is + // charged from the AXP2101 BACKUP pin. Leaving this off drains the cell with + // nothing to replenish it. 3300 mV matches LilyGo's own firmware. + // setPowerChannelVoltage/enablePowerOutput on XPOWERS_VBACKUP map onto + // setButtonBatteryChargeVoltage()/enableButtonBatteryCharge(). + PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300); + PMU->enablePowerOutput(XPOWERS_VBACKUP); + + // PWR key. The side switch (schematic SW7) is wired to PWRON, not a GPIO. + // press < 1s -> PKEY_SHORT_IRQ, consumed by PMUButton as a click + // 1s <= press < 6s -> nothing (PKEY_LONG_IRQ is left masked) + // press >= 6s -> hardware power-off, firmware never sees it + // hold 2s from off -> power-on + // Matches the 2S ON / 6S OFF behaviour printed on LilyGo's own pin diagram. + PMU->setPowerKeyPressOnTime(XPOWERS_POWERON_2S); + PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_6S); + PMU->setIrqLevelTime(XPOWERS_AXP2101_IRQ_TIME_1S); + + PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); + PMU->clearIrqStatus(); + // SHORT gives the click; NEGATIVE/POSITIVE are the press/release edges that + // back PMUButton::isPressed(). + PMU->enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ | + XPOWERS_AXP2101_PKEY_NEGATIVE_IRQ | + XPOWERS_AXP2101_PKEY_POSITIVE_IRQ); + + PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_125MA); + PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + + PMU->disableTSPinMeasure(); + PMU->enableSystemVoltageMeasure(); + PMU->enableVbusVoltageMeasure(); + PMU->enableBattVoltageMeasure(); + + Serial.printf("[PWR] rails: ALDO2(bl)=%d ALDO3(disp/touch)=%d ALDO4(LoRa)=%d BLDO2(haptic)=%d DLDO1(spk)=%d VBACKUP(rtc)=%d\n", + PMU->isPowerChannelEnable(XPOWERS_ALDO2), + PMU->isPowerChannelEnable(XPOWERS_ALDO3), + PMU->isPowerChannelEnable(XPOWERS_ALDO4), + PMU->isPowerChannelEnable(XPOWERS_BLDO2), + PMU->isPowerChannelEnable(XPOWERS_DLDO1), + PMU->isPowerChannelEnable(XPOWERS_VBACKUP)); + return true; +} + +void TWatchS3Board::printPowerDebug() { + if (!PMU) return; + Serial.printf("[PWR] batt=%dmV %d%% vbus=%dmV charging=%d cpu=%dMHz bt=%d\n", + PMU->getBattVoltage(), PMU->getBatteryPercent(), + PMU->getVbusVoltage(), PMU->isCharging(), + getCpuFrequencyMhz(), (int)esp_bt_controller_get_status()); +} + +bool TWatchS3Board::tiltFired() { + if (_tilt_flag) { // set by the GPIO14 rising-edge ISR + _tilt_flag = false; + _accel->update(); // reading the status clears the sensor INT + return true; + } + return false; +} + +uint32_t TWatchS3Board::getStepCount() { + uint8_t d[4]; + if (!bma423ReadRegs(BMA423_REG_STEP_CNT_OUT, d, 4)) return 0; + return (uint32_t)d[0] | ((uint32_t)d[1] << 8) | + ((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24); +} diff --git a/variants/lilygo_twatch_s3/TWatchS3Board.h b/variants/lilygo_twatch_s3/TWatchS3Board.h new file mode 100644 index 00000000..42645e88 --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchS3Board.h @@ -0,0 +1,77 @@ +#pragma once + +#include "variant.h" // Board-specific pin definitions (I2C, addresses, IRQs) + +#include +#include +#include "XPowersLib.h" +#include "helpers/ESP32Board.h" +#include + +// LilyGo T-Watch S3 board (non-GPS, 470 mAh). +// +// Power is managed by an AXP2101 PMU on the main I2C bus. The PMU is held as the +// concrete XPowersAXP2101 rather than XPowersLibInterface because PMUButton +// needs isPekeyNegativeIrq()/isPekeyPositiveIrq(), which the interface does not +// declare. +class SensorBMA423; // full include kept in the .cpp to avoid a BLE-build header clash + +class TWatchS3Board : public ESP32Board { + XPowersAXP2101* PMU = NULL; + SensorBMA423* _accel = nullptr; + static volatile bool _tilt_flag; + static void IRAM_ATTR onTiltISR(); // defined in the .cpp (IRAM relocation) + + bool power_init(); + +public: + void begin(); + + // Returns true once when the BMA423 tilt (wrist-raise) interrupt has fired. + bool tiltFired(); + + // The AXP2101 handle, for PMUButton. NULL if the PMU failed to init. + XPowersAXP2101* getPMU() { return PMU; } + + void enterDeepSleep(uint32_t secs, int pin_wake_btn) { + esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); + + rtc_gpio_set_direction((gpio_num_t)P_LORA_DIO_1, RTC_GPIO_MODE_INPUT_ONLY); + rtc_gpio_pulldown_en((gpio_num_t)P_LORA_DIO_1); + rtc_gpio_hold_en((gpio_num_t)P_LORA_NSS); + + // NOTE: the PWR key is not a GPIO on this board, so it cannot be added to + // the ext1 mask. pin_wake_btn is accepted for signature compatibility with + // ESP32Board but callers on this variant pass -1. + if (pin_wake_btn < 0) { + esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1), ESP_EXT1_WAKEUP_ANY_HIGH); + } else { + esp_sleep_enable_ext1_wakeup((1ULL << P_LORA_DIO_1) | (1ULL << pin_wake_btn), ESP_EXT1_WAKEUP_ANY_HIGH); + } + + if (secs > 0) { + esp_sleep_enable_timer_wakeup(secs * 1000000ULL); + } + + esp_deep_sleep_start(); + } + + uint16_t getBattMilliVolts() override { + return PMU ? PMU->getBattVoltage() : 0; + } + + uint8_t getBatteryPercent() override { + return PMU ? PMU->getBatteryPercent() : 0; + } + + // Wrapper-free BMA423 step count (raw I2C; defined in the .cpp). + uint32_t getStepCount(); + + // Power-debug probe: battery, VBUS, CPU clock, BT controller state and live + // rail states. Called periodically from UITask::loop. + void printPowerDebug(); + + const char* getManufacturerName() const override { + return "LilyGo T-Watch S3"; + } +}; diff --git a/variants/lilygo_twatch_s3/TWatchS3Display.h b/variants/lilygo_twatch_s3/TWatchS3Display.h new file mode 100644 index 00000000..dd1ad140 --- /dev/null +++ b/variants/lilygo_twatch_s3/TWatchS3Display.h @@ -0,0 +1,96 @@ +#pragma once + +// LovyanGFX display + touch for the LilyGo T-Watch S3. +// ST7789V 240x240 on SPI3 + PWM backlight (GPIO45) + FT6336U capacitive touch +// on a separate I2C bus (Wire1: SDA 39 / SCL 40, INT 16, no RST pin). +// +// LGFXDisplay.h pulls in LovyanGFX (it defines LGFX_USE_V1 and includes +// LovyanGFX.hpp), so we do not redefine those here. + +#include + +class LGFX_TWatchS3 : public lgfx::LGFX_Device { + lgfx::Panel_ST7789 _panel_instance; + lgfx::Bus_SPI _bus_instance; + lgfx::Light_PWM _light_instance; + lgfx::Touch_FT5x06 _touch_instance; + +public: + LGFX_TWatchS3(void) { + { + auto cfg = _bus_instance.config(); + cfg.spi_host = SPI3_HOST; + cfg.spi_mode = 0; + cfg.freq_write = 40000000; + cfg.freq_read = 16000000; + cfg.spi_3wire = true; + cfg.use_lock = true; + cfg.dma_channel = SPI_DMA_CH_AUTO; + cfg.pin_sclk = 18; + cfg.pin_mosi = 13; + cfg.pin_miso = -1; + cfg.pin_dc = 38; + _bus_instance.config(cfg); + _panel_instance.setBus(&_bus_instance); + } + + { + auto cfg = _panel_instance.config(); + cfg.pin_cs = 12; + cfg.pin_rst = -1; + cfg.pin_busy = -1; + // ST7789 GRAM is 240x320; memory_height must be 320 so the 80px rotation + // offset is applied (otherwise a strip of the panel shows noise). + cfg.memory_width = 240; + cfg.memory_height = 320; + cfg.panel_width = 240; + cfg.panel_height = 240; + cfg.offset_x = 0; + cfg.offset_y = 0; + cfg.offset_rotation = 1; + cfg.readable = false; + cfg.invert = true; + cfg.rgb_order = false; + cfg.dlen_16bit = false; + cfg.bus_shared = false; + _panel_instance.config(cfg); + } + + { + auto cfg = _light_instance.config(); + cfg.pin_bl = 45; + cfg.invert = false; + cfg.freq = 44100; + cfg.pwm_channel = 7; + _light_instance.config(cfg); + _panel_instance.setLight(&_light_instance); + } + + { + auto cfg = _touch_instance.config(); + cfg.x_min = 0; + cfg.x_max = 239; + cfg.y_min = 0; + cfg.y_max = 239; + cfg.pin_int = 16; + cfg.pin_rst = -1; + cfg.bus_shared = false; + cfg.offset_rotation = 2; // touch IC mounted 180deg vs the LCD horizontal axis + cfg.i2c_port = 1; + cfg.i2c_addr = 0x38; + cfg.pin_sda = 39; + cfg.pin_scl = 40; + cfg.freq = 400000; + _touch_instance.config(cfg); + _panel_instance.setTouch(&_touch_instance); + } + + setPanel(&_panel_instance); + } +}; + +class TWatchS3Display : public LGFXDisplay { + LGFX_TWatchS3 disp; +public: + TWatchS3Display() : LGFXDisplay(240, 240, disp) {} +}; \ No newline at end of file diff --git a/variants/lilygo_twatch_s3/meck_twatch_16mb.csv b/variants/lilygo_twatch_s3/meck_twatch_16mb.csv new file mode 100644 index 00000000..f14bfb43 --- /dev/null +++ b/variants/lilygo_twatch_s3/meck_twatch_16mb.csv @@ -0,0 +1,21 @@ +# Meck -- LilyGo T-Watch S3 16 MB custom partition layout +# +# Single factory app slot (no OTA): the watch has no SD card and does not +# define MECK_OTA_UPDATE, so no firmware-OTA path is compiled -- an OTA +# app1 slot would only waste flash. Maps get a dedicated LittleFS partition; +# the DataStore keeps its own SPIFFS partition. +# +# Both data partitions use the 'spiffs' subtype (the standard ESP32 data +# subtype). The DataStore is mounted as SPIFFS via SPIFFS.begin() (first +# spiffs-subtype partition by offset); maps is mounted as LittleFS by the +# explicit "maps" label, so the mount type is chosen at mount time, not by +# subtype. +# +# NOTE: flashing this shifts the layout and wipes the existing SPIFFS -- +# re-provision identity/contacts after flashing. +# +# Name, Type, SubType, Offset, Size +nvs, data, nvs, 0x9000, 0x5000 +app0, app, factory, 0x10000, 0x200000 +spiffs, data, spiffs, 0x210000, 0x400000 +maps, data, spiffs, 0x610000, 0x9F0000 diff --git a/variants/lilygo_twatch_s3/platformio.ini b/variants/lilygo_twatch_s3/platformio.ini new file mode 100644 index 00000000..cd0adca4 --- /dev/null +++ b/variants/lilygo_twatch_s3/platformio.ini @@ -0,0 +1,139 @@ +[LilyGo_TWatchS3] +extends = esp32_base +extra_scripts = post:merge_firmware.py +board = lilygo_twatch_s3 +board_build.flash_mode = qio +board_build.f_flash = 80000000L +board_build.arduino.memory_type = qio_opi +board_upload.flash_size = 16MB +board_build.partitions = variants/lilygo_twatch_s3/meck_twatch_16mb.csv +build_flags = + ${esp32_base.build_flags} + ${sensor_base.build_flags} + -I variants/lilygo_twatch_s3 + ; MECK_TWATCH gates the watch form factor (240x240, UI_ZOOM=2, touch, lock + ; screen, tile grid, grey palette) and is shared with the S3 Plus. + ; LILYGO_TWATCH_S3 gates hardware unique to this board. + -D MECK_TWATCH + -D LILYGO_TWATCH_S3 + ; The only key is the AXP2101 PWRON. Replaces PIN_USER_BTN, which is undefined + ; here because there is no GPIO button (see variant.h). + -D MECK_PMU_BUTTON + -D TWATCH_COMPOSE_ENABLED + -D MECK_RX_DUTY_CYCLE + -D BOARD_HAS_PSRAM=1 + -D CORE_DEBUG_LEVEL=1 + -D FORMAT_SPIFFS_IF_FAILED=1 + -D FORMAT_LITTLEFS_IF_FAILED=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + ; ---- LoRa SX1262 ---- + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D LORA_TX_POWER=22 + -D SX126X_DIO2_AS_RF_SWITCH + -D SX126X_DIO3_TCXO_VOLTAGE=1.8f + -D SX126X_CURRENT_LIMIT=140 + -D SX126X_RX_BOOSTED_GAIN=1 + -D P_LORA_NSS=5 + -D P_LORA_DIO_1=9 + -D P_LORA_RESET=8 + -D P_LORA_BUSY=7 + -D P_LORA_SCLK=3 + -D P_LORA_MISO=4 + -D P_LORA_MOSI=1 + ; ---- Display (ST7789V 240x240) + FT6336U touch via LovyanGFX ---- + -D DISPLAY_CLASS=TWatchS3Display + -D UI_ZOOM=2 + ; ---- Misc ---- + -D AUTO_SHUTDOWN_MILLIVOLTS=2800 + -D ARDUINO_LOOP_STACK_SIZE=32768 + ; ---- No GPS ---- + ; HAS_GPS and ENV_INCLUDE_GPS are deliberately left undefined. The watch map + ; screen, the GPS home page and the BLDO1 rail control are all gated on them. + -D ENV_INCLUDE_AHTX0=0 + -D ENV_INCLUDE_BME280=0 + -D ENV_INCLUDE_BMP280=0 + -D ENV_INCLUDE_SHTC3=0 + -D ENV_INCLUDE_SHT4X=0 + -D ENV_INCLUDE_LPS22HB=0 + -D ENV_INCLUDE_INA3221=0 + -D ENV_INCLUDE_INA219=0 + -D ENV_INCLUDE_INA226=0 + -D ENV_INCLUDE_INA260=0 + -D ENV_INCLUDE_MLX90614=0 + -D ENV_INCLUDE_VL53L0X=0 + -D ENV_INCLUDE_BME680=0 + -D ENV_INCLUDE_BMP085=0 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/lilygo_twatch_s3> + + +lib_deps = + ${esp32_base.lib_deps} + ${sensor_base.lib_deps} + lovyan03/LovyanGFX @ ^1.2.0 + lewisxhe/XPowersLib @ ^0.2.7 + lewisxhe/SensorLib @ ^0.4.1 + adafruit/Adafruit GFX Library @ ^1.11.0 + bitbank2/PNGdec @ ^1.0.1 + WebServer + DNSServer + Update + +; --------------------------------------------------------------------------- +; Standalone (no BLE companion), touch + display + lock screen. +; MAX_CONTACTS=2000 -- contact + sort arrays allocated in PSRAM via +; BaseChatMesh::initContacts(). +; --------------------------------------------------------------------------- +[env:meck_twatch_s3_standalone] +extends = LilyGo_TWatchS3 +build_flags = + ${LilyGo_TWatchS3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=2000 + -D MAX_GROUP_CHANNELS=40 + -D OFFLINE_QUEUE_SIZE=1 + -D ESP32_CPU_FREQ=80 + -D FIRMWARE_VERSION='"Meck TWatch S3 v0.1"' +build_src_filter = ${LilyGo_TWatchS3.build_src_filter} + + + - + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + +lib_deps = + ${LilyGo_TWatchS3.lib_deps} + densaugeo/base64 @ ~1.4.0 +lib_ignore = + AsyncTCP + ESPAsyncWebServer + ESP32 BLE Arduino + +; --------------------------------------------------------------------------- +; BLE companion build -- same touch UI as the standalone, plus the BLE serial +; interface so a phone app can connect on occasion. +; Flash: pio run -e meck_twatch_s3_ble -t upload +; --------------------------------------------------------------------------- +[env:meck_twatch_s3_ble] +extends = LilyGo_TWatchS3 +build_flags = + ${LilyGo_TWatchS3.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=2000 + -D MAX_GROUP_CHANNELS=20 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 + -D ESP32_CPU_FREQ=80 + -D FIRMWARE_VERSION='"Meck TWatch S3 BLE v0.1"' +build_src_filter = ${LilyGo_TWatchS3.build_src_filter} + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> + + +lib_deps = + ${LilyGo_TWatchS3.lib_deps} + densaugeo/base64 @ ~1.4.0 +lib_ignore = + AsyncTCP + ESPAsyncWebServer diff --git a/variants/lilygo_twatch_s3/target.cpp b/variants/lilygo_twatch_s3/target.cpp new file mode 100644 index 00000000..2ba9cd38 --- /dev/null +++ b/variants/lilygo_twatch_s3/target.cpp @@ -0,0 +1,64 @@ +#include +#include "variant.h" +#include "target.h" + +TWatchS3Board board; + +// LoRa SX1262 (HPD16B3 module) on its own SPI bus (the display uses SPI3_HOST +// separately). +static SPIClass loraSpi; +RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, loraSpi); + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +// No GNSS and no environment sensors on this board, so the base SensorManager +// is used. node_lat/node_lon stay at 0 and advert location policy has nothing +// to publish. +SensorManager sensors; + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + PMUButton user_btn(board); +#endif + +bool radio_init() { + // NOTE: board.begin() is called by main.cpp setup() before radio_init(); + // Wire is already initialised there with the correct pins. + fallback_clock.begin(); + rtc_clock.begin(Wire); + + loraSpi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + return radio.std_init(&loraSpi); +} + +uint32_t radio_get_rng_seed() { + return radio.random(0x7FFFFFFF); +} + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); + + // Longer preamble for low SF improves reliability -- each symbol is shorter + // at low SF, so more symbols are needed for reliable detection. + uint16_t preamble = (sf <= 9) ? 32 : 16; + radio.setPreambleLength(preamble); +} + +void radio_set_tx_power(uint8_t dbm) { + radio.setOutputPower(dbm); +} + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + +void radio_reset_agc() { + radio.setRxBoostedGainMode(true); +} diff --git a/variants/lilygo_twatch_s3/target.h b/variants/lilygo_twatch_s3/target.h new file mode 100644 index 00000000..9a7a6b98 --- /dev/null +++ b/variants/lilygo_twatch_s3/target.h @@ -0,0 +1,35 @@ +#pragma once + +// Include variant.h first to ensure all board-specific defines are available +#include "variant.h" + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include + +#ifdef DISPLAY_CLASS + #include + #include +#endif + +extern TWatchS3Board board; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern SensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + // Not a MomentaryButton: the only key on this board is the AXP2101 PWRON. + extern PMUButton user_btn; +#endif + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); +void radio_reset_agc(); diff --git a/variants/lilygo_twatch_s3/variant.h b/variants/lilygo_twatch_s3/variant.h new file mode 100644 index 00000000..30915516 --- /dev/null +++ b/variants/lilygo_twatch_s3/variant.h @@ -0,0 +1,87 @@ +#pragma once + +// ============================================================================= +// LilyGo T-Watch S3 (non-GPS, 470 mAh) - Board-level pin definitions +// +// Sources, in order of authority: +// 1. T_WATCH-S3 schematic (rev 25-03-24) +// 2. Xinyuan-LilyGO/TTGO_TWatch_Library, branch t-watch-s3, src/utilities.h +// 3. LilyGo hardware doc docs/hardware/lilygo-t-watch-s3.md +// +// Every pin below is identical to the T-Watch S3 Plus. The two boards differ in +// what is *populated*, not in how the ESP32-S3 is wired: +// - no onboard GNSS (the Plus has a MIA-M10Q on BLDO1; here BLDO1 is unused +// and GPIO 41/42 go to an optional external GPS shield) +// - no GPIO user button; the only control is the side PWR key, which is wired +// to the AXP2101 PWRON pin (schematic sheet 1: SW7 -> PWR_KEY -> pin 30) +// - MAX98357A speaker amp on DLDO1, PDM mic on +3V3, IR LED on GPIO2 -- none +// of which Meck compiles in +// +// NOTE: LoRa (P_LORA_*) pins and the SX126x radio parameters are supplied as +// -D build flags in this variant's platformio.ini, matching the T-Watch S3 Plus +// and T-Deck Pro convention. The display SPI/backlight and FT6336U touch pins +// live inline in TWatchS3Display.h because LovyanGFX needs them in its panel +// config. +// ============================================================================= + +// ----------------------------------------------------------------------------- +// Main I2C bus (shared): AXP2101 PMU, PCF8563 RTC, BMA423 accel, DRV2605 haptic +// The FT6336U touch panel is on a SEPARATE bus (Wire1), configured in the +// display class -- it is not on this bus. +// ----------------------------------------------------------------------------- +#define I2C_SDA 10 +#define I2C_SCL 11 + +// Aliases for ESP32Board base class compatibility +#define PIN_BOARD_SDA I2C_SDA +#define PIN_BOARD_SCL I2C_SCL + +// ----------------------------------------------------------------------------- +// I2C device addresses (7-bit) +// ----------------------------------------------------------------------------- +#define I2C_ADDR_PMU 0x34 // AXP2101 power management +#define I2C_ADDR_RTC 0x51 // PCF8563 real-time clock +#define I2C_ADDR_ACCEL 0x19 // BMA423 accelerometer +#define I2C_ADDR_HAPTIC 0x5A // DRV2605 haptic driver +#define I2C_ADDR_TOUCH 0x38 // FT6336U capacitive touch (on Wire1) + +// ----------------------------------------------------------------------------- +// Interrupt / control pins +// ----------------------------------------------------------------------------- +#ifndef PIN_PMU_IRQ + #define PIN_PMU_IRQ 21 // AXP2101 interrupt (open-drain, active LOW) +#endif +#define PIN_RTC_IRQ 17 // PCF8563 interrupt +#define PIN_ACCEL_IRQ 14 // BMA423 interrupt + +// User button: deliberately NOT defined. +// +// The T-Watch S3 has no GPIO button. Schematic sheet 1 wires the side tact +// switch SW7 to net PWR_KEY, which lands on AXP2101 pin 30 (PWRON). Button +// events therefore arrive over I2C as PMU interrupts, not as a pin level, and +// are read by PMUButton (see PMUButton.h). MECK_PMU_BUTTON is defined in this +// variant's platformio.ini in place of PIN_USER_BTN. +// +// #define PIN_USER_BTN + +// ----------------------------------------------------------------------------- +// Display dimensions (ST7789V 1.54" IPS, 240x240) +// ----------------------------------------------------------------------------- +#define LCD_HOR_SIZE 240 +#define LCD_VER_SIZE 240 + +// ----------------------------------------------------------------------------- +// Storage +// ----------------------------------------------------------------------------- +// The T-Watch S3 has no SD card slot. Notes/Reader/Epub screens reference +// SDCARD_CS unconditionally, so it must be defined for them to compile. -1 is a +// safe no-op on ESP32 (digitalWrite/pinMode reject out-of-range pins), and SD +// mounts will simply fail at runtime. +#define SDCARD_CS -1 + +// ----------------------------------------------------------------------------- +// GPS: none. HAS_GPS and ENV_INCLUDE_GPS are left undefined in platformio.ini, +// which drops the map screen, the GPS home page and the BLDO1 rail control. +// The optional external GPS shield lands on GPIO41 (RX) / GPIO42 (TX) if it is +// ever wired up. +// -----------------------------------------------------------------------------