From 09d1f6409f70c64e3f8ded78ac65d9f495a8553f Mon Sep 17 00:00:00 2001 From: GlassOnTin Date: Fri, 27 Mar 2026 12:18:46 +0000 Subject: [PATCH] Add R-Watch (T-Watch Ultra) board support with LoRa, GPS, PMU, RTC, and deep sleep Board definition (BOARD_TWATCH_ULT = 0x45) for LilyGo T-Watch Ultra with verified pin mapping from LilyGoLib hardware docs. Working subsystems: - SX1262 LoRa radio: online at 868 MHz, tested with rnsd/Reticulum - AXP2101 PMU: all power rails configured, battery monitoring, charging - MIA-M10Q GPS: UART at 38400 baud, TinyGPSPlus NMEA parsing - PCF85063A RTC: time read/write, GPS sync infrastructure - XL9555 GPIO expander: I2C driver, LoRa antenna switch - BLE: initialized, KISS protocol responsive - Deep sleep: button wake (PMU IRQ GPIO 7), timer wake for beacon - Beacon sleep cycle: periodic wake for GPS beacon TX in standalone mode New files: - VISION.md: R-Watch product vision document - XL9555.h: minimal I2C GPIO expander driver - CO5300.h: QSPI AMOLED display driver (not yet functional) Display driver (CO5300.h) is written but disabled (HAS_DISPLAY=false). QSPI init succeeds but pixel writes don't reach the display controller. Suspected XL9555/BHI260AP GPIO expander pin mapping issue under investigation. --- Boards.h | 93 +++++++++++++ CO5300.h | 332 +++++++++++++++++++++++++++++++++++++++++++++ Display.h | 105 ++++++++++++++ GPS.h | 38 +++--- Makefile | 10 ++ Power.h | 132 ++++++++++++++++-- RNode_Firmware.ino | 155 ++++++++++++++++++++- RTC.h | 60 +++++--- Utilities.h | 15 +- VISION.md | 232 +++++++++++++++++++++++++++++++ XL9555.h | 106 +++++++++++++++ sx126x.cpp | 6 +- 12 files changed, 1225 insertions(+), 59 deletions(-) create mode 100644 CO5300.h create mode 100644 VISION.md create mode 100644 XL9555.h diff --git a/Boards.h b/Boards.h index adead7c..377ee71 100644 --- a/Boards.h +++ b/Boards.h @@ -70,6 +70,11 @@ #define MODEL_DE 0xDE // Xiao ESP32S3 with Wio-SX1262 module, 433 MHz #define MODEL_DD 0xDD // Xiao ESP32S3 with Wio-SX1262 module, 868 MHz + #define PRODUCT_TWATCH_ULT 0xEC + #define BOARD_TWATCH_ULT 0x45 + #define MODEL_D5 0xD5 // LilyGO T-Watch Ultra, 433 MHz + #define MODEL_DA 0xDA // LilyGO T-Watch Ultra, 868 MHz + #define PRODUCT_T32_10 0xB2 #define BOARD_LORA32_V1_0 0x39 #define MODEL_BA 0xBA // LilyGO T3 v1.0, 433 MHz @@ -730,6 +735,94 @@ #endif #endif + #elif BOARD_MODEL == BOARD_TWATCH_ULT + #define IS_ESP32S3 true + #define MODEM SX1262 + #define DIO2_AS_RF_SWITCH true + #define HAS_BUSY true + #define HAS_TCXO true + + #define HAS_DISPLAY false // CO5300 QSPI driver written but not yet functional + #define HAS_CONSOLE true + #define HAS_WIFI true + #define HAS_BLUETOOTH false + #define HAS_BLE true + #define HAS_PMU true + #define HAS_NP false + #define HAS_SD true + #define HAS_EEPROM true + + #define HAS_INPUT true + #define HAS_SLEEP true + #define PIN_WAKEUP GPIO_NUM_0 + #define WAKEUP_LEVEL 0 + + #define HAS_GPS true + #define PIN_GPS_TX 43 + #define PIN_GPS_RX 44 + #define PIN_GPS_PPS 13 + #define GPS_BAUD_RATE 38400 + + #define HAS_RTC true + + // I2C bus (shared: PMU, touch, IMU, RTC, haptic, GPIO expander) + #define PMU_IRQ 7 + #define I2C_SCL 2 + #define I2C_SDA 3 + + // LoRa SPI (shared bus with NFC and SD) + const int pin_cs = 36; + const int pin_reset = 47; + const int pin_sclk = 35; + const int pin_mosi = 34; + const int pin_miso = 33; + const int pin_tcxo_enable = -1; + const int pin_dio = 14; + const int pin_busy = 48; + + // Display (CO5300 QSPI) + #define DISP_CS 41 + #define DISP_RST 37 + #define DISP_SCK 40 + #define DISP_D0 38 + #define DISP_D1 39 + #define DISP_D2 42 + #define DISP_D3 45 + #define DISP_TE 6 + #define DISP_W 410 + #define DISP_H 502 + + // Touch (CST9217, I2C addr 0x1A) + // INT and RST managed via XL9555 GPIO expander + + // IMU (BHI260AP, I2C addr 0x28) + #define SENSOR_INT 8 + + // RTC (PCF85063A, I2C addr 0x51) + #define RTC_INT 1 + + // NFC (ST25R3916, shared SPI bus) + #define NFC_CS 4 + #define NFC_INT 5 + + // SD card (shared SPI bus) + const int SD_MISO = 33; + const int SD_MOSI = 34; + const int SD_CLK = 35; + const int SD_CS = 21; + + // Audio (MAX98357A I2S) + #define I2S_BCLK 9 + #define I2S_WCLK 10 + #define I2S_DOUT 11 + + // Buttons + const int pin_btn_usr1 = 0; + + // No discrete LEDs on watch + const int pin_led_rx = -1; + const int pin_led_tx = -1; + #else #error An unsupported ESP32 board was selected. Cannot compile RNode firmware. #endif diff --git a/CO5300.h b/CO5300.h new file mode 100644 index 0000000..678be5c --- /dev/null +++ b/CO5300.h @@ -0,0 +1,332 @@ +// CO5300 QSPI AMOLED Display Driver for T-Watch Ultra +// 410x502 pixels, 16-bit RGB565, QSPI interface +// Based on LilyGoLib display implementation (MIT license) + +#ifndef CO5300_H +#define CO5300_H + +#if BOARD_MODEL == BOARD_TWATCH_ULT + +#include +#include "driver/spi_master.h" +#include "driver/gpio.h" + +#define CO5300_CMD_SLPIN 0x10 +#define CO5300_CMD_SLPOUT 0x11 +#define CO5300_CMD_CASET 0x2A +#define CO5300_CMD_RASET 0x2B +#define CO5300_CMD_RAMWR 0x2C +#define CO5300_CMD_MADCTL 0x36 +#define CO5300_CMD_BRIGHTNESS 0x51 + +#define CO5300_WIDTH 410 +#define CO5300_HEIGHT 502 +#define CO5300_OFFSET_X 22 +#define CO5300_OFFSET_Y 0 + +#define CO5300_SPI_HOST SPI3_HOST +#define CO5300_SPI_FREQ_MHZ 45 +#define CO5300_SEND_BUF_SIZE 16384 + +// Init command table entry +typedef struct { + uint8_t cmd; + uint8_t data[20]; + uint8_t len; // bit 7 = delay 120ms after command +} co5300_cmd_t; + +static const co5300_cmd_t co5300_init_cmds[] = { + {0xFE, {0x00}, 0x01}, + {0xC4, {0x80}, 0x01}, + {0x3A, {0x55}, 0x01}, // RGB565 + {0x35, {0x00}, 0x01}, // Tearing effect on + {0x53, {0x20}, 0x01}, // Brightness control enable + {0x63, {0xFF}, 0x01}, + {0x2A, {0x00, 0x16, 0x01, 0xAF}, 0x04}, // Column: 22 to 431 + {0x2B, {0x00, 0x00, 0x01, 0xF5}, 0x04}, // Row: 0 to 501 + {0x11, {0}, 0x80}, // Sleep out + delay + {0x29, {0}, 0x80}, // Display on + delay + {0x51, {0x00}, 0x01}, // Brightness = 0 +}; +#define CO5300_INIT_CMD_COUNT (sizeof(co5300_init_cmds) / sizeof(co5300_init_cmds[0])) + +static spi_device_handle_t co5300_spi = NULL; +static bool co5300_ready = false; +static uint8_t co5300_brightness = 0; + +// Send a command with optional data bytes via QSPI +static void co5300_write_cmd(uint8_t cmd, uint8_t *data, uint32_t len) { + digitalWrite(DISP_CS, LOW); + spi_transaction_t t = {}; + t.flags = SPI_TRANS_MULTILINE_CMD | SPI_TRANS_MULTILINE_ADDR; + t.cmd = 0x02; // QSPI write command + t.addr = cmd << 8; // Display command in address field + if (len > 0 && data) { + t.tx_buffer = data; + t.length = 8 * len; + } + spi_device_polling_transmit(co5300_spi, &t); + digitalWrite(DISP_CS, HIGH); +} + +// Set the pixel address window +static void co5300_set_window(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { + x1 += CO5300_OFFSET_X; + x2 += CO5300_OFFSET_X; + y1 += CO5300_OFFSET_Y; + y2 += CO5300_OFFSET_Y; + + uint8_t caset[] = {highByte(x1), lowByte(x1), highByte(x2), lowByte(x2)}; + uint8_t raset[] = {highByte(y1), lowByte(y1), highByte(y2), lowByte(y2)}; + co5300_write_cmd(CO5300_CMD_CASET, caset, 4); + co5300_write_cmd(CO5300_CMD_RASET, raset, 4); + co5300_write_cmd(CO5300_CMD_RAMWR, NULL, 0); +} + +// Push pixel data to the display (RGB565, big-endian) +void co5300_push_pixels(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t *pixels) { + if (!co5300_ready) return; + + spi_device_acquire_bus(co5300_spi, portMAX_DELAY); + co5300_set_window(x, y, x + w - 1, y + h - 1); + + uint32_t total = w * h; + uint16_t *p = pixels; + bool first = true; + + digitalWrite(DISP_CS, LOW); + while (total > 0) { + uint32_t chunk = (total > CO5300_SEND_BUF_SIZE) ? CO5300_SEND_BUF_SIZE : total; + spi_transaction_ext_t t = {}; + if (first) { + t.base.flags = SPI_TRANS_MODE_QIO; + t.base.cmd = 0x32; + t.base.addr = 0x002C00; + first = false; + } else { + t.base.flags = SPI_TRANS_MODE_QIO | SPI_TRANS_VARIABLE_CMD | + SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_VARIABLE_DUMMY; + t.command_bits = 0; + t.address_bits = 0; + t.dummy_bits = 0; + } + t.base.tx_buffer = p; + t.base.length = chunk * 16; + spi_device_polling_transmit(co5300_spi, (spi_transaction_t *)&t); + p += chunk; + total -= chunk; + } + digitalWrite(DISP_CS, HIGH); + + spi_device_release_bus(co5300_spi); +} + +// Fill a rectangle with a solid colour +void co5300_fill_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { + if (!co5300_ready) return; + + // Polling SPI doesn't need DMA memory — use PSRAM-capable heap + uint32_t total = w * h; + uint32_t buf_size = (total > CO5300_SEND_BUF_SIZE) ? CO5300_SEND_BUF_SIZE : total; + uint16_t *buf = (uint16_t *)heap_caps_malloc(buf_size * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!buf) { + // Fallback to internal RAM + buf = (uint16_t *)malloc(buf_size * 2); + if (!buf) return; + } + + for (uint32_t i = 0; i < buf_size; i++) buf[i] = color; + + spi_device_acquire_bus(co5300_spi, portMAX_DELAY); + co5300_set_window(x, y, x + w - 1, y + h - 1); + + uint32_t remaining = total; + bool first = true; + digitalWrite(DISP_CS, LOW); + while (remaining > 0) { + uint32_t chunk = (remaining > buf_size) ? buf_size : remaining; + spi_transaction_ext_t t = {}; + if (first) { + t.base.flags = SPI_TRANS_MODE_QIO; + t.base.cmd = 0x32; + t.base.addr = 0x002C00; + first = false; + } else { + t.base.flags = SPI_TRANS_MODE_QIO | SPI_TRANS_VARIABLE_CMD | + SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_VARIABLE_DUMMY; + t.command_bits = 0; + t.address_bits = 0; + t.dummy_bits = 0; + } + t.base.tx_buffer = buf; + t.base.length = chunk * 16; + spi_device_polling_transmit(co5300_spi, (spi_transaction_t *)&t); + remaining -= chunk; + } + digitalWrite(DISP_CS, HIGH); + spi_device_release_bus(co5300_spi); + + heap_caps_free(buf); +} + +// Clear the entire display to black +void co5300_clear() { + co5300_fill_rect(0, 0, CO5300_WIDTH, CO5300_HEIGHT, 0x0000); +} + +void co5300_set_brightness(uint8_t level) { + if (!co5300_ready) return; + co5300_brightness = level; + co5300_write_cmd(CO5300_CMD_BRIGHTNESS, &level, 1); +} + +void co5300_sleep() { + if (!co5300_ready) return; + co5300_write_cmd(CO5300_CMD_SLPIN, NULL, 0); +} + +void co5300_wakeup() { + if (!co5300_ready) return; + co5300_write_cmd(CO5300_CMD_SLPOUT, NULL, 0); + delay(120); +} + +bool co5300_init() { + pinMode(DISP_CS, OUTPUT); + digitalWrite(DISP_CS, HIGH); + + // Hardware reset on GPIO 37 (direct pin, not XL9555 expander) + pinMode(DISP_RST, OUTPUT); + digitalWrite(DISP_RST, HIGH); + delay(200); + digitalWrite(DISP_RST, LOW); + delay(300); + digitalWrite(DISP_RST, HIGH); + delay(200); + + // Configure QSPI bus + spi_bus_config_t bus_cfg = {}; + bus_cfg.data0_io_num = DISP_D0; + bus_cfg.data1_io_num = DISP_D1; + bus_cfg.sclk_io_num = DISP_SCK; + bus_cfg.data2_io_num = DISP_D2; + bus_cfg.data3_io_num = DISP_D3; + bus_cfg.data4_io_num = -1; + bus_cfg.data5_io_num = -1; + bus_cfg.data6_io_num = -1; + bus_cfg.data7_io_num = -1; + bus_cfg.max_transfer_sz = 0x40000 + 8; + bus_cfg.flags = SPICOMMON_BUSFLAG_MASTER | SPICOMMON_BUSFLAG_GPIO_PINS; + + spi_device_interface_config_t dev_cfg = {}; + dev_cfg.command_bits = 8; + dev_cfg.address_bits = 24; + dev_cfg.mode = SPI_MODE0; + dev_cfg.clock_speed_hz = CO5300_SPI_FREQ_MHZ * 1000 * 1000; + dev_cfg.spics_io_num = -1; // CS managed manually + dev_cfg.flags = SPI_DEVICE_HALFDUPLEX; + dev_cfg.queue_size = 17; + + if (spi_bus_initialize(CO5300_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO) != ESP_OK) { + return false; + } + if (spi_bus_add_device(CO5300_SPI_HOST, &dev_cfg, &co5300_spi) != ESP_OK) { + return false; + } + + // Send init sequence (twice, per LilyGoLib pattern) + for (int pass = 0; pass < 2; pass++) { + for (uint32_t i = 0; i < CO5300_INIT_CMD_COUNT; i++) { + co5300_write_cmd(co5300_init_cmds[i].cmd, + (uint8_t *)co5300_init_cmds[i].data, + co5300_init_cmds[i].len & 0x1F); + if (co5300_init_cmds[i].len & 0x80) { + delay(120); + } + } + } + + // Set rotation to portrait (default for watch) + uint8_t madctl = 0x00; // RGB order, no mirror/swap + co5300_write_cmd(CO5300_CMD_MADCTL, &madctl, 1); + + co5300_ready = true; + + // Clear screen to black + co5300_clear(); + + // Set initial brightness + co5300_set_brightness(128); + + return true; +} + +void co5300_end() { + if (co5300_spi) { + spi_bus_remove_device(co5300_spi); + spi_bus_free(CO5300_SPI_HOST); + co5300_spi = NULL; + } + co5300_ready = false; +} + +// ---- Simple text rendering using Adafruit GFX fonts ---- +// Only available when HAS_DISPLAY is true (Display.h includes Adafruit_GFX.h) + +#if HAS_DISPLAY + +// Draw a single character using Adafruit GFX font at a given position +// Returns the advance width in pixels +static uint16_t co5300_draw_char(uint16_t *fb, uint16_t fb_w, uint16_t fb_h, + int16_t cx, int16_t cy, char c, + uint16_t fg, const GFXfont *font) { + if (c < font->first || c > font->last) return 0; + GFXglyph *glyph = &font->glyph[c - font->first]; + uint8_t *bitmap = font->bitmap; + uint16_t bo = glyph->bitmapOffset; + uint8_t gw = glyph->width, gh = glyph->height; + int8_t xo = glyph->xOffset, yo = glyph->yOffset; + uint8_t bits = 0, bit = 0; + + for (int16_t yy = 0; yy < gh; yy++) { + for (int16_t xx = 0; xx < gw; xx++) { + if (!(bit++ & 7)) bits = bitmap[bo++]; + if (bits & 0x80) { + int16_t px = cx + xo + xx; + int16_t py = cy + yo + yy; + if (px >= 0 && px < fb_w && py >= 0 && py < fb_h) { + fb[py * fb_w + px] = fg; + } + } + bits <<= 1; + } + } + return glyph->xAdvance; +} + +// Draw a string using Adafruit GFX font, returns total width +uint16_t co5300_draw_string(uint16_t *fb, uint16_t fb_w, uint16_t fb_h, + int16_t x, int16_t y, const char *str, + uint16_t fg, const GFXfont *font) { + int16_t cx = x; + while (*str) { + cx += co5300_draw_char(fb, fb_w, fb_h, cx, y, *str, fg, font); + str++; + } + return cx - x; +} + +// RGB565 colour helpers +#define CO5300_BLACK 0x0000 +#define CO5300_WHITE 0xFFFF +#define CO5300_RED 0xF800 +#define CO5300_GREEN 0x07E0 +#define CO5300_BLUE 0x001F +#define CO5300_CYAN 0x07FF +#define CO5300_YELLOW 0xFFE0 +#define CO5300_GREY 0x7BEF + +#endif // HAS_DISPLAY + +#endif // BOARD_MODEL == BOARD_TWATCH_ULT +#endif // CO5300_H diff --git a/Display.h b/Display.h index 15c7db7..e5e510b 100644 --- a/Display.h +++ b/Display.h @@ -13,6 +13,109 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +#if BOARD_MODEL == BOARD_TWATCH_ULT + // T-Watch Ultra uses CO5300 QSPI AMOLED — separate display path + #include + #include + #include "Fonts/Org_01.h" + #include "XL9555.h" + #include "CO5300.h" + + // Stubs for variables/functions referenced by other firmware modules + bool disp_ext_fb = false; + bool display_tx = false; + bool recondition_display = false; + void ext_fb_enable() { } + void ext_fb_disable() { } + uint8_t fb[0]; // empty framebuffer stub + + bool display_blanked = false; + uint32_t last_unblank_event = 0; + uint32_t display_blanking_timeout = 15000; + + // Partial framebuffer for clock region (410 x 60 = ~49KB, fits in DMA memory) + #define CLOCK_FB_W CO5300_WIDTH + #define CLOCK_FB_H 60 + static uint16_t *clock_fb = NULL; + + void display_unblank() { + if (display_blanked) { + co5300_wakeup(); + xl9555_set(EXPANDS_DISP_EN, true); + co5300_set_brightness(128); + display_blanked = false; + } + last_unblank_event = millis(); + } + + bool display_init() { + if (!co5300_init()) return false; + co5300_set_brightness(128); + + // Allocate partial framebuffer for clock region (PSRAM preferred for large buffers) + clock_fb = (uint16_t *)heap_caps_malloc(CLOCK_FB_W * CLOCK_FB_H * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!clock_fb) clock_fb = (uint16_t *)malloc(CLOCK_FB_W * CLOCK_FB_H * 2); + + return true; + } + + void update_display(bool force = false) { + if (!co5300_ready) return; + + // Handle display blanking + if (display_blanking_enabled && !display_blanked) { + if (millis() - last_unblank_event > display_blanking_timeout) { + co5300_set_brightness(0); + co5300_sleep(); + xl9555_set(EXPANDS_DISP_EN, false); + display_blanked = true; + return; + } + } + if (display_blanked && !force) return; + if (!clock_fb) return; + + display_updating = true; + + // Render clock in partial framebuffer + memset(clock_fb, 0, CLOCK_FB_W * CLOCK_FB_H * 2); + + char time_str[6]; + snprintf(time_str, sizeof(time_str), "%02d:%02d", rtc_hour, rtc_minute); + + // Draw time centered, large font + const GFXfont *font = &FreeSansBold24pt7b; + uint16_t tw = co5300_draw_string(clock_fb, CLOCK_FB_W, CLOCK_FB_H, + 0, 45, time_str, CO5300_WHITE, font); + // Re-render centered + if (tw > 0 && tw < CLOCK_FB_W) { + memset(clock_fb, 0, CLOCK_FB_W * CLOCK_FB_H * 2); + co5300_draw_string(clock_fb, CLOCK_FB_W, CLOCK_FB_H, + (CLOCK_FB_W - tw) / 2, 45, time_str, CO5300_WHITE, font); + } + + // Push clock region to display (upper area) + co5300_push_pixels(0, 80, CLOCK_FB_W, CLOCK_FB_H, clock_fb); + + // Render status line below clock + memset(clock_fb, 0, CLOCK_FB_W * CLOCK_FB_H * 2); + + char status[64]; + snprintf(status, sizeof(status), "%s %d%% %d sats", + radio_online ? "RADIO" : "idle", + (int)battery_percent, + gps_sats); + + co5300_draw_string(clock_fb, CLOCK_FB_W, CLOCK_FB_H, + 10, 15, status, CO5300_GREY, &Org_01); + + co5300_push_pixels(0, 150, CLOCK_FB_W, CLOCK_FB_H, clock_fb); + + display_updating = false; + } + +#else +// Original display path for all other boards #include "Graphics.h" #include @@ -1282,3 +1385,5 @@ void ext_fb_enable() { void ext_fb_disable() { disp_ext_fb = false; } + +#endif // BOARD_MODEL == BOARD_TWATCH_ULT (display path selector) diff --git a/GPS.h b/GPS.h index 53b6bba..caca005 100644 --- a/GPS.h +++ b/GPS.h @@ -79,28 +79,34 @@ void gps_power_off() { void gps_setup() { gps_power_on(); - delay(1000); // Allow L76K time to boot after reset + delay(1000); // Allow GPS module time to boot // PIN_GPS_TX/RX named from ESP32 perspective: // PIN_GPS_RX = ESP32 receives FROM GPS module // PIN_GPS_TX = ESP32 transmits TO GPS module gps_serial.begin(GPS_BAUD_RATE, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX); delay(250); - // L76K init: force internal antenna (ceramic patch) - gps_serial.print("$PCAS15,0*19\r\n"); - delay(250); - // Hot start — use cached ephemeris/almanac if available in L76K backup RAM - gps_serial.print("$PCAS10,0*1C\r\n"); - delay(500); - // Enable GPS+GLONASS+BeiDou - gps_serial.print("$PCAS04,7*1E\r\n"); - delay(250); - // Output GGA, GSA, GSV, and RMC - gps_serial.print("$PCAS03,1,0,1,1,1,0,0,0,0,0,,,0,0*02\r\n"); - delay(250); - // Set navigation mode to Portable (general purpose, works stationary and moving) - gps_serial.print("$PCAS11,0*1D\r\n"); - delay(250); + #if BOARD_MODEL == BOARD_TWATCH_ULT + // MIA-M10Q (u-blox): outputs NMEA at 38400 baud by default. + // GGA, GSA, GSV, RMC are enabled out of the box. + // No vendor-specific init commands needed. + #else + // L76K init: force internal antenna (ceramic patch) + gps_serial.print("$PCAS15,0*19\r\n"); + delay(250); + // Hot start — use cached ephemeris/almanac if available in L76K backup RAM + gps_serial.print("$PCAS10,0*1C\r\n"); + delay(500); + // Enable GPS+GLONASS+BeiDou + gps_serial.print("$PCAS04,7*1E\r\n"); + delay(250); + // Output GGA, GSA, GSV, and RMC + gps_serial.print("$PCAS03,1,0,1,1,1,0,0,0,0,0,,,0,0*02\r\n"); + delay(250); + // Set navigation mode to Portable (general purpose, works stationary and moving) + gps_serial.print("$PCAS11,0*1D\r\n"); + delay(250); + #endif gps_ready = true; } diff --git a/Makefile b/Makefile index 32dd20d..9938f99 100644 --- a/Makefile +++ b/Makefile @@ -117,6 +117,16 @@ deploy-tbeam_supreme: firmware-tbeam_supreme free-port @sleep 3 rnodeconf $(PORT) --firmware-hash $$(./partition_hashes ./build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin) +firmware-twatch_ultra: + arduino-cli compile --log --fqbn "esp32:esp32:esp32s3:CDCOnBoot=cdc" -e --build-property "build.partitions=no_ota" --build-property "upload.maximum_size=2097152" --build-property "compiler.cpp.extra_flags=-DBOARD_MODEL=0x45" + +upload-twatch_ultra: firmware-twatch_ultra + @echo "Flashing T-Watch Ultra via OpenOCD JTAG..." + $(HOME)/.arduino15/packages/esp32/tools/openocd-esp32/v0.12.0-esp32-20230921/bin/openocd \ + -s $(HOME)/.arduino15/packages/esp32/tools/openocd-esp32/v0.12.0-esp32-20230921/share/openocd/scripts \ + -f board/esp32s3-builtin.cfg \ + -c "program_esp build/esp32.esp32.esp32s3/RNode_Firmware.ino.bin 0x10000 verify reset exit" + firmware-lora32_v10: check_bt_buffers arduino-cli compile --log --fqbn esp32:esp32:ttgo-lora32 -e --build-property "build.partitions=no_ota" --build-property "upload.maximum_size=2097152" --build-property "compiler.cpp.extra_flags=\"-DBOARD_MODEL=0x39\"" diff --git a/Power.h b/Power.h index 8077520..48029f7 100644 --- a/Power.h +++ b/Power.h @@ -19,7 +19,7 @@ bool pmu_temp_sensor_ready = false; float pmu_temperature = PMU_TEMP_MIN-1; -#if BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 +#if BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 || BOARD_MODEL == BOARD_TWATCH_ULT #include XPowersLibInterface* PMU = NULL; @@ -36,17 +36,54 @@ float pmu_temperature = PMU_TEMP_MIN-1; void disablePeripherals() { if (PMU) { - // GNSS RTC PowerVDD - PMU->enablePowerOutput(XPOWERS_VBACKUP); - - // LoRa VDD - PMU->disablePowerOutput(XPOWERS_ALDO2); - - // GNSS VDD - PMU->disablePowerOutput(XPOWERS_ALDO3); + #if BOARD_MODEL == BOARD_TWATCH_ULT + PMU->disablePowerOutput(XPOWERS_ALDO3); // LoRa VDD + PMU->disablePowerOutput(XPOWERS_BLDO1); // GPS VDD + #else + // GNSS RTC PowerVDD + PMU->enablePowerOutput(XPOWERS_VBACKUP); + // LoRa VDD + PMU->disablePowerOutput(XPOWERS_ALDO2); + // GNSS VDD + PMU->disablePowerOutput(XPOWERS_ALDO3); + #endif } } + #if BOARD_MODEL == BOARD_TWATCH_ULT + void power_gps(bool on) { if (PMU) { if (on) PMU->enablePowerOutput(XPOWERS_BLDO1); else PMU->disablePowerOutput(XPOWERS_BLDO1); } } + void power_radio(bool on) { if (PMU) { if (on) PMU->enablePowerOutput(XPOWERS_ALDO3); else PMU->disablePowerOutput(XPOWERS_ALDO3); } } + void power_nfc(bool on) { if (PMU) { if (on) PMU->enablePowerOutput(XPOWERS_DLDO1); else PMU->disablePowerOutput(XPOWERS_DLDO1); } } + void power_speaker(bool on) { if (PMU) { if (on) PMU->enablePowerOutput(XPOWERS_BLDO2); else PMU->disablePowerOutput(XPOWERS_BLDO2); } } + void power_sensor(bool on) { if (PMU) { if (on) PMU->enablePowerOutput(XPOWERS_ALDO4); else PMU->disablePowerOutput(XPOWERS_ALDO4); } } + void power_sd(bool on) { if (PMU) { if (on) PMU->enablePowerOutput(XPOWERS_ALDO1); else PMU->disablePowerOutput(XPOWERS_ALDO1); } } + + void pmu_prepare_sleep() { + if (!PMU) return; + PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); + PMU->enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ); + + // Disable measurement ADCs + PMU->disableBattDetection(); + PMU->disableVbusVoltageMeasure(); + PMU->disableBattVoltageMeasure(); + PMU->disableSystemVoltageMeasure(); + PMU->disableTSPinMeasure(); + + PMU->enableSleep(); + + // Disable peripheral rails (NOT ALDO2 — causes 600µA anomaly!) + power_sd(false); + power_radio(false); + power_sensor(false); + power_gps(false); + power_speaker(false); + power_nfc(false); + + PMU->clearIrqStatus(); + } + #endif + bool pmuInterrupt; void setPmuFlag() { @@ -308,7 +345,7 @@ void measure_battery() { // } } - #elif BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 + #elif BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 || BOARD_MODEL == BOARD_TWATCH_ULT if (PMU) { float discharge_current = 0; float charge_current = 0; @@ -644,7 +681,80 @@ bool init_pmu() { PMU->enableBattVoltageMeasure(); - return true; + return true; + #elif BOARD_MODEL == BOARD_TWATCH_ULT + Wire.begin(I2C_SDA, I2C_SCL); + + if (!PMU) { + PMU = new XPowersAXP2101(PMU_WIRE_PORT); + if (!PMU->init()) { + delete PMU; + PMU = NULL; + } + } + + if (!PMU) { + return false; + } + + // Set voltages for all power rails + PMU->setPowerChannelVoltage(XPOWERS_ALDO1, 3300); // SD card + PMU->setPowerChannelVoltage(XPOWERS_ALDO2, 3300); // Display + PMU->setPowerChannelVoltage(XPOWERS_ALDO3, 3300); // LoRa radio + PMU->setPowerChannelVoltage(XPOWERS_ALDO4, 1800); // BHI260AP sensor (1.8V!) + PMU->setPowerChannelVoltage(XPOWERS_BLDO1, 3300); // GPS + PMU->setPowerChannelVoltage(XPOWERS_BLDO2, 3300); // Speaker + PMU->setPowerChannelVoltage(XPOWERS_VBACKUP, 3300); // RTC button battery + + // Enable needed rails + PMU->enablePowerOutput(XPOWERS_ALDO1); // SD card + PMU->enablePowerOutput(XPOWERS_ALDO2); // Display + PMU->enablePowerOutput(XPOWERS_ALDO3); // LoRa radio + PMU->enablePowerOutput(XPOWERS_ALDO4); // IMU sensor + PMU->enablePowerOutput(XPOWERS_BLDO1); // GPS + PMU->enablePowerOutput(XPOWERS_BLDO2); // Speaker + PMU->enablePowerOutput(XPOWERS_DLDO1); // NFC + PMU->enablePowerOutput(XPOWERS_VBACKUP); // RTC battery + + // Disable unused rails + PMU->disablePowerOutput(XPOWERS_DCDC2); + PMU->disablePowerOutput(XPOWERS_DCDC3); + PMU->disablePowerOutput(XPOWERS_DCDC4); + PMU->disablePowerOutput(XPOWERS_DCDC5); + + // Protect ESP32-S3 main power + PMU->setProtectedChannel(XPOWERS_DCDC1); + + // Configure charging: 4.2V target, 500mA current + PMU->setChargeTargetVoltage(XPOWERS_AXP2101_CHG_VOL_4V2); + PMU->setChargerConstantCurr(XPOWERS_AXP2101_CHG_CUR_500MA); + PMU->setChargingLedMode(XPOWERS_CHG_LED_OFF); + + // Set power button timing + PMU->setPowerKeyPressOffTime(XPOWERS_POWEROFF_4S); + PMU->setPowerKeyPressOnTime(XPOWERS_POWERON_128MS); + + // Enable battery and temperature monitoring + PMU->enableBattDetection(); + PMU->enableVbusVoltageMeasure(); + PMU->enableBattVoltageMeasure(); + PMU->enableSystemVoltageMeasure(); + PMU->enableTSPinMeasure(); + + // Configure interrupts + PMU->disableIRQ(XPOWERS_AXP2101_ALL_IRQ); + PMU->enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ | + XPOWERS_AXP2101_PKEY_LONG_IRQ | + XPOWERS_AXP2101_VBUS_INSERT_IRQ | + XPOWERS_AXP2101_VBUS_REMOVE_IRQ | + XPOWERS_AXP2101_BAT_CHG_START_IRQ | + XPOWERS_AXP2101_BAT_CHG_DONE_IRQ); + PMU->clearIrqStatus(); + + pinMode(PMU_IRQ, INPUT_PULLUP); + attachInterrupt(PMU_IRQ, setPmuFlag, FALLING); + + return true; #else return false; #endif diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index c79bda5..f1d3aec 100644 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -17,6 +17,11 @@ #include #include "Utilities.h" +#if BOARD_MODEL == BOARD_TWATCH_ULT + #include "XL9555.h" + #include "CO5300.h" +#endif + #define CHANNEL_FIFO_SIZE (CONFIG_UART_BUFFER_SIZE / NUM_CHANNELS) FIFOBuffer channelFIFO[NUM_CHANNELS]; uint8_t channelBuffer[NUM_CHANNELS][CHANNEL_FIFO_SIZE + 1]; @@ -135,7 +140,7 @@ void setup() { boot_seq(); #endif - #if BOARD_MODEL != BOARD_RAK4631 && BOARD_MODEL != BOARD_HELTEC_T114 && BOARD_MODEL != BOARD_TECHO && BOARD_MODEL != BOARD_T3S3 && BOARD_MODEL != BOARD_TBEAM_S_V1 && BOARD_MODEL != BOARD_HELTEC32_V4 + #if BOARD_MODEL != BOARD_RAK4631 && BOARD_MODEL != BOARD_HELTEC_T114 && BOARD_MODEL != BOARD_TECHO && BOARD_MODEL != BOARD_T3S3 && BOARD_MODEL != BOARD_TBEAM_S_V1 && BOARD_MODEL != BOARD_HELTEC32_V4 && BOARD_MODEL != BOARD_TWATCH_ULT // Some boards need to wait until the hardware UART is set up before booting // the full firmware. In the case of the RAK4631 and Heltec T114, the line below will wait // until a serial connection is actually established with a master. Thus, it @@ -151,8 +156,8 @@ void setup() { #endif #if HAS_NP == false - pinMode(pin_led_rx, OUTPUT); - pinMode(pin_led_tx, OUTPUT); + if (pin_led_rx >= 0) pinMode(pin_led_rx, OUTPUT); + if (pin_led_tx >= 0) pinMode(pin_led_tx, OUTPUT); #endif #if HAS_TCXO == true @@ -265,6 +270,21 @@ void setup() { pmu_ready = init_pmu(); #endif + #if BOARD_MODEL == BOARD_TWATCH_ULT + xl9555_init(); + xl9555_enable_lora_antenna(); + + // Check if this is a beacon timer wakeup — take fast path if so + if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TIMER) { + beacon_wake_cycle(); // Does not return — transmits and sleeps again + } + + // Normal boot: enable display and haptics + xl9555_set(EXPANDS_DRV_EN, true); + xl9555_set(EXPANDS_DISP_EN, true); + xl9555_set(EXPANDS_TOUCH_RST, true); + #endif + #if HAS_BLUETOOTH || HAS_BLE == true bt_init(); bt_init_ran = true; @@ -1927,6 +1947,15 @@ void loop() { #if HAS_RTC == true if (gps_has_fix) rtc_sync_from_gps(gps_parser); #endif + + // Enter beacon sleep cycle when in standalone mode after beacon TX + #if BOARD_MODEL == BOARD_TWATCH_ULT + if (beacon_mode_active && beacon_gate == 6 && + (last_host_activity == 0 || (millis() - last_host_activity >= BEACON_NO_HOST_TIMEOUT_MS))) { + // Beacon was just sent and no host is connected — sleep until next interval + sleep_now(); + } + #endif } #endif @@ -1964,6 +1993,72 @@ void loop() { } +#if BOARD_MODEL == BOARD_TWATCH_ULT && HAS_GPS == true +// Minimal boot path for beacon timer wakeup. +// Inits only GPS + LoRa, waits for fix, transmits beacon, sleeps again. +// Called from setup() before BLE/display init. Does not return. +void beacon_wake_cycle() { + // GPS is not yet initialized at this point — init it now + gps_setup(); + + // Load beacon crypto config from EEPROM + if (EEPROM.read(config_addr(ADDR_BCN_OK)) == CONF_OK_BYTE) { + for (int i = 0; i < 32; i++) + collector_pub_key[i] = EEPROM.read(config_addr(ADDR_BCN_KEY + i)); + for (int i = 0; i < 16; i++) + collector_identity_hash[i] = EEPROM.read(config_addr(ADDR_BCN_IHASH + i)); + for (int i = 0; i < 16; i++) + collector_dest_hash[i] = EEPROM.read(config_addr(ADDR_BCN_DHASH + i)); + beacon_crypto_configured = true; + } + lxmf_init_identity(); + + // Wait for GPS fix (up to 60 seconds for warm start) + uint32_t fix_start = millis(); + while (!gps_has_fix && (millis() - fix_start < 60000)) { + gps_update(); + delay(100); + } + + // Attempt beacon transmission if we have a fix + if (gps_has_fix) { + last_host_activity = 0; // ensure beacon_update doesn't think host is active + last_beacon_tx = 0; // force immediate beacon + beacon_update(); + } + + // Go back to sleep with timer for next beacon cycle + gps_serial.end(); + stopRadio(); + xl9555_sleep_prepare(); + pmu_prepare_sleep(); + Serial1.end(); + SPI.end(); + Wire.end(); + + // Set GPIOs to open drain + const uint8_t sleep_pins[] = { + DISP_D0, DISP_D1, DISP_D2, DISP_D3, + DISP_SCK, DISP_CS, DISP_TE, DISP_RST, + RTC_INT, NFC_INT, SENSOR_INT, NFC_CS, + I2S_BCLK, I2S_WCLK, I2S_DOUT, SD_CS, + I2C_SDA, I2C_SCL, + pin_mosi, pin_miso, pin_sclk, pin_cs, + PIN_GPS_TX, PIN_GPS_RX, PIN_GPS_PPS, + pin_reset, pin_busy, pin_dio, + }; + for (auto p : sleep_pins) { + gpio_reset_pin((gpio_num_t)p); + pinMode(p, OPEN_DRAIN); + } + + // Timer wakeup for next beacon, also allow PMU button to wake + esp_sleep_enable_timer_wakeup((uint64_t)BEACON_INTERVAL_MS * 1000ULL); + esp_sleep_enable_ext1_wakeup(1ULL << PMU_IRQ, ESP_EXT1_WAKEUP_ANY_LOW); + esp_deep_sleep_start(); +} +#endif + void sleep_now() { #if HAS_SLEEP == true stopRadio(); // TODO: Check this on all platforms @@ -1990,8 +2085,58 @@ void sleep_now() { delay(100); } #endif - esp_sleep_enable_ext0_wakeup(PIN_WAKEUP, WAKEUP_LEVEL); - esp_deep_sleep_start(); + + #if BOARD_MODEL == BOARD_TWATCH_ULT + // T-Watch Ultra deep sleep sequence + // Following LilyGo's proven power-down order + + #if HAS_GPS + gps_serial.end(); + #endif + + // XL9555: disable display and haptics + xl9555_sleep_prepare(); + + // PMU: disable peripheral rails, enable sleep mode + pmu_prepare_sleep(); + + // Close all buses + Serial1.end(); + SPI.end(); + Wire.end(); + + // Set all unused GPIOs to OPEN_DRAIN to prevent current leaks + const uint8_t sleep_pins[] = { + DISP_D0, DISP_D1, DISP_D2, DISP_D3, + DISP_SCK, DISP_CS, DISP_TE, DISP_RST, + RTC_INT, NFC_INT, SENSOR_INT, NFC_CS, + I2S_BCLK, I2S_WCLK, I2S_DOUT, SD_CS, + I2C_SDA, I2C_SCL, + pin_mosi, pin_miso, pin_sclk, pin_cs, + PIN_GPS_TX, PIN_GPS_RX, PIN_GPS_PPS, + pin_reset, pin_busy, pin_dio, + }; + for (auto p : sleep_pins) { + gpio_reset_pin((gpio_num_t)p); + pinMode(p, OPEN_DRAIN); + } + + // Always allow PMU button wakeup + esp_sleep_enable_ext1_wakeup(1ULL << PMU_IRQ, ESP_EXT1_WAKEUP_ANY_LOW); + + // If in beacon mode, also set timer wakeup for next beacon cycle + #if HAS_GPS == true + if (beacon_mode_active) { + esp_sleep_enable_timer_wakeup((uint64_t)BEACON_INTERVAL_MS * 1000ULL); + } + #endif + + esp_deep_sleep_start(); + + #else + esp_sleep_enable_ext0_wakeup(PIN_WAKEUP, WAKEUP_LEVEL); + esp_deep_sleep_start(); + #endif #elif PLATFORM == PLATFORM_NRF52 #if BOARD_MODEL == BOARD_HELTEC_T114 npset(0,0,0); diff --git a/RTC.h b/RTC.h index 23a46cb..99bd87a 100644 --- a/RTC.h +++ b/RTC.h @@ -20,15 +20,32 @@ #include -// PCF8563 I2C address and registers -#define PCF8563_ADDR 0x51 -#define PCF8563_REG_SEC 0x02 -#define PCF8563_REG_MIN 0x03 -#define PCF8563_REG_HOUR 0x04 -#define PCF8563_REG_DAY 0x05 -#define PCF8563_REG_WDAY 0x06 -#define PCF8563_REG_MON 0x07 -#define PCF8563_REG_YEAR 0x08 +// RTC I2C address (shared by PCF8563 and PCF85063A) +#define RTC_I2C_ADDR 0x51 + +#if BOARD_MODEL == BOARD_TWATCH_ULT + // PCF85063A registers (T-Watch Ultra) + #define RTC_REG_CTRL1 0x00 + #define RTC_REG_CTRL2 0x01 + #define RTC_REG_SEC 0x04 + #define RTC_REG_MIN 0x05 + #define RTC_REG_HOUR 0x06 + #define RTC_REG_DAY 0x07 + #define RTC_REG_WDAY 0x08 + #define RTC_REG_MON 0x09 + #define RTC_REG_YEAR 0x0A +#else + // PCF8563 registers (T-Beam Supreme, etc.) + #define RTC_REG_CTRL1 0x00 + #define RTC_REG_CTRL2 0x01 + #define RTC_REG_SEC 0x02 + #define RTC_REG_MIN 0x03 + #define RTC_REG_HOUR 0x04 + #define RTC_REG_DAY 0x05 + #define RTC_REG_WDAY 0x06 + #define RTC_REG_MON 0x07 + #define RTC_REG_YEAR 0x08 +#endif bool rtc_ready = false; bool rtc_synced = false; // true once GPS time has been written to RTC @@ -47,16 +64,14 @@ static uint8_t bcd_to_dec(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); static uint8_t dec_to_bcd(uint8_t dec) { return ((dec / 10) << 4) | (dec % 10); } void rtc_setup() { - // The sensor I2C bus (Wire) is already initialised by Display.h - // on pins SDA_OLED/SCL_OLED (17/18 for T-Beam Supreme). - // Just probe for the PCF8563. - Wire.beginTransmission(PCF8563_ADDR); + // Wire is already initialised by Power.h (PMU init) or Display.h. + Wire.beginTransmission(RTC_I2C_ADDR); if (Wire.endTransmission() == 0) { rtc_ready = true; // Clear control registers (normal mode, no alarms) - Wire.beginTransmission(PCF8563_ADDR); - Wire.write(0x00); // control/status 1 + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RTC_REG_CTRL1); Wire.write(0x00); // normal mode Wire.write(0x00); // control/status 2: no alarms/timer Wire.endTransmission(); @@ -66,11 +81,11 @@ void rtc_setup() { bool rtc_read_time() { if (!rtc_ready) return false; - Wire.beginTransmission(PCF8563_ADDR); - Wire.write(PCF8563_REG_SEC); + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RTC_REG_SEC); if (Wire.endTransmission() != 0) return false; - Wire.requestFrom((uint8_t)PCF8563_ADDR, (uint8_t)7); + Wire.requestFrom((uint8_t)RTC_I2C_ADDR, (uint8_t)7); if (Wire.available() < 7) return false; uint8_t sec = Wire.read(); @@ -81,8 +96,9 @@ bool rtc_read_time() { uint8_t mon = Wire.read(); uint8_t year = Wire.read(); - // Check clock integrity bit (sec register bit 7) - if (sec & 0x80) return false; // clock integrity not guaranteed + // Check oscillator stop bit (sec register bit 7) + // Set on both PCF8563 and PCF85063A when clock integrity is lost + if (sec & 0x80) return false; rtc_second = bcd_to_dec(sec & 0x7F); rtc_minute = bcd_to_dec(min & 0x7F); @@ -98,8 +114,8 @@ bool rtc_write_time(uint16_t year, uint8_t month, uint8_t day, uint8_t hour, uint8_t minute, uint8_t second) { if (!rtc_ready) return false; - Wire.beginTransmission(PCF8563_ADDR); - Wire.write(PCF8563_REG_SEC); + Wire.beginTransmission(RTC_I2C_ADDR); + Wire.write(RTC_REG_SEC); Wire.write(dec_to_bcd(second)); Wire.write(dec_to_bcd(minute)); Wire.write(dec_to_bcd(hour)); diff --git a/Utilities.h b/Utilities.h index 0b98bab..2ac5deb 100644 --- a/Utilities.h +++ b/Utilities.h @@ -262,6 +262,13 @@ uint8_t boot_vector = 0x00; void led_tx_off() { } void led_id_on() { } void led_id_off() { } + #elif BOARD_MODEL == BOARD_TWATCH_ULT + void led_rx_on() { } + void led_rx_off() { } + void led_tx_on() { } + void led_tx_off() { } + void led_id_on() { } + void led_id_off() { } #elif BOARD_MODEL == BOARD_LORA32_V1_0 #if defined(EXTERNAL_LEDS) void led_rx_on() { digitalWrite(pin_led_rx, HIGH); } @@ -1202,7 +1209,7 @@ void kiss_indicate_fbstate() { void kiss_indicate_fb() { serial_write(FEND); serial_write(CMD_FB_READ); - #if HAS_DISPLAY + #if HAS_DISPLAY && BOARD_MODEL != BOARD_TWATCH_ULT for (int i = 0; i < 512; i++) { uint8_t byte = fb[i]; escaped_serial_write(byte); @@ -1216,7 +1223,7 @@ void kiss_indicate_fb() { void kiss_indicate_disp() { serial_write(FEND); serial_write(CMD_DISP_READ); - #if HAS_DISPLAY + #if HAS_DISPLAY && BOARD_MODEL != BOARD_TWATCH_ULT uint8_t *da = disp_area.getBuffer(); uint8_t *sa = stat_area.getBuffer(); for (int i = 0; i < 512; i++) { escaped_serial_write(da[i]); } @@ -1680,7 +1687,7 @@ bool eeprom_product_valid() { #if PLATFORM == PLATFORM_AVR if (rval == PRODUCT_RNODE || rval == PRODUCT_HMBRW) { #elif PLATFORM == PLATFORM_ESP32 - if (rval == PRODUCT_RNODE || rval == BOARD_RNODE_NG_20 || rval == BOARD_RNODE_NG_21 || rval == PRODUCT_HMBRW || rval == PRODUCT_TBEAM || rval == PRODUCT_T32_10 || rval == PRODUCT_T32_20 || rval == PRODUCT_T32_21 || rval == PRODUCT_H32_V2 || rval == PRODUCT_H32_V3 || rval == PRODUCT_H32_V4 || rval == PRODUCT_TDECK_V1 || rval == PRODUCT_TBEAM_S_V1 || rval == PRODUCT_XIAO_S3) { + if (rval == PRODUCT_RNODE || rval == BOARD_RNODE_NG_20 || rval == BOARD_RNODE_NG_21 || rval == PRODUCT_HMBRW || rval == PRODUCT_TBEAM || rval == PRODUCT_T32_10 || rval == PRODUCT_T32_20 || rval == PRODUCT_T32_21 || rval == PRODUCT_H32_V2 || rval == PRODUCT_H32_V3 || rval == PRODUCT_H32_V4 || rval == PRODUCT_TDECK_V1 || rval == PRODUCT_TBEAM_S_V1 || rval == PRODUCT_XIAO_S3 || rval == PRODUCT_TWATCH_ULT) { #elif PLATFORM == PLATFORM_NRF52 if (rval == PRODUCT_RAK4631 || rval == PRODUCT_HELTEC_T114 || rval == PRODUCT_TECHO || rval == PRODUCT_HMBRW) { #else @@ -1718,6 +1725,8 @@ bool eeprom_model_valid() { if (model == MODEL_DB || model == MODEL_DC) { #elif BOARD_MODEL == BOARD_XIAO_S3 if (model == MODEL_DD || model == MODEL_DE) { + #elif BOARD_MODEL == BOARD_TWATCH_ULT + if (model == MODEL_D5 || model == MODEL_DA || model == MODEL_FF || model == MODEL_FE) { #elif BOARD_MODEL == BOARD_LORA32_V1_0 if (model == MODEL_BA || model == MODEL_BB) { #elif BOARD_MODEL == BOARD_LORA32_V2_0 diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..a1b337c --- /dev/null +++ b/VISION.md @@ -0,0 +1,232 @@ +# R-Watch: Off-Grid Smart Watch + +## Vision + +The T-Watch Ultimate port turns the RNode firmware inside out. Where the T-Beam Supreme is a radio modem that gained GPS tracking, the R-Watch is a smart watch that gained off-grid communications. The primary interface is a watch face on your wrist. The LoRa radio, GPS, and Reticulum stack run underneath — always available, never in the way. + +The target user puts on a watch in the morning and gets the time, the date, their step count. When they walk beyond cellular range, the same watch becomes a LoRa mesh node: relaying packets for Sideband on their phone, beaconing their location, and buzzing when an LXMF message arrives over the air. + +This aligns with the RNode project's core values — sovereignty, self-replication, open hardware — while making Reticulum accessible to people who don't configure radio modems. + +## Hardware Platform + +**LilyGo T-Watch Ultra** + +| Component | Chip | RNode Support | +|-----------|------|---------------| +| MCU | ESP32-S3 (dual-core, 16MB flash, 8MB PSRAM) | Existing | +| LoRa radio | SX1262 (sub-GHz) | Existing (`sx126x.cpp/h`) | +| GPS | u-blox MIA-M10Q (GPS/GLONASS/BeiDou/Galileo) | Adapt (`GPS.h`, 38400 baud) | +| PMU | AXP2101 | Existing (`Power.h`) | +| BLE | 5.0 (ESP32-S3 native) | Existing (`BLESerial.h`) | +| WiFi | 802.11 b/g/n | Existing | +| Display | 2.06" AMOLED, 410x502, CO5300 QSPI | **New driver needed** | +| Touch | CST9217 capacitive (I2C 0x1A) | **New driver needed** | +| IMU | BHI260AP (I2C 0x28) | **New driver needed** | +| RTC | PCF85063A (I2C 0x51) | Adapt (`RTC.h`) | +| NFC | ST25R3916 (SPI) | **New driver needed** | +| Audio | MAX98357A amp + SPM1423 PDM mic | **New driver needed** | +| Haptics | DRV2605 (I2C 0x5A) | **New driver needed** | +| GPIO expander | XL9555 (I2C 0x20) | **New driver needed** | +| SD card | MicroSD via shared SPI | Existing pattern | + +## Use Cases + +In priority order: + +### 1. Watch Face and Timekeeping + +The default screen. Time, date, battery level. The RTC syncs from GPS satellites — no phone or internet needed for accurate time. This is what users see 95% of the time. + +### 2. Phone-Connected BLE RNode + +A phone running Sideband or NomadNet connects to the watch over BLE. The watch becomes a transparent LoRa modem — identical to plugging a T-Beam into USB, but wireless and on your wrist. The phone handles Reticulum routing; the watch handles the radio. The existing KISS-over-BLE protocol works without modification. + +### 3. RNode Status Dashboard + +While the radio is active, the watch shows what's happening: link quality, channel utilization, current frequency and spreading factor, BLE connection state. Glanceable, not diagnostic. A signal-strength arc on the watch face periphery, not a terminal dump. + +### 4. Standalone GPS Tracker + +When no phone has connected for 15 seconds, the watch switches to autonomous mode: GPS beacon transmission and encrypted LXMF telemetry, identical to the T-Beam Supreme's standalone operation. The watch face shows GPS fix status, satellite count, and time since last beacon. Location and battery telemetry reach the Reticulum network without any other device. + +### 5. On-Watch Message Notifications + +When LXMF messages arrive — either direct over LoRa or relayed from a connected phone — the watch displays them and vibrates. Read them on your wrist. This is new functionality: no existing RNode shows message content. + +### 6. Activity Tracking + +Step counting from the accelerometer. Basic daily activity. Table-stakes for a device on your wrist, and the hardware supports it. + +## UI/UX Philosophy + +### Watch Face Is Home + +The always-visible screen is a clock. Not a radio diagnostic panel. The 410x502 AMOLED is tall enough to show time prominently with status indicators below: a thin bar for battery, a small icon for BLE connection, a dot for GPS fix, a signal indicator for LoRa activity. Information at a glance without cluttering the time. + +### Tall Display Layout + +The 410x502 display is a tall rounded rectangle — more vertical space than a typical square watch. Time occupies the upper portion. Status indicators, complications, and secondary information use the lower area. The extra vertical space is an asset for message display and scrollable lists. + +### Gesture Navigation + +Swipe to navigate between screens. Tap to select. Long-press for context. No reliance on physical buttons for primary navigation. The screen hierarchy: + +``` + [Radio Status] + | +[GPS/Location] -- [Watch Face] -- [Messages] + | + [Settings] +``` + +Swipe left/right/up/down from the watch face to reach each screen. Each screen is designed for glanceable information — two seconds of attention, not ten. + +### Dark by Default + +AMOLED means black pixels are free. Dark themes with minimal lit pixels extend battery life. Bright elements are reserved for alerts and active indicators. + +### Wrist-Raise Wake + +The accelerometer detects wrist raise and wakes the display. No button press to check the time. When the wrist drops, the display sleeps. Simple, expected watch behaviour. + +## RNode Integration Model + +### Dual-Core Architecture + +The ESP32-S3 has two cores. Use both: + +- **Core 0 — Radio modem**: SX1262 driver, KISS framing, CSMA, packet queues, BLE serial, GPS parsing, beacon logic. This is the existing RNode firmware loop, running as a FreeRTOS task. It must never be starved by display rendering. + +- **Core 1 — Watch UI**: Display rendering, touch input, gesture recognition, animations, screen transitions. This is new code, running independently. It reads shared state (radio status, GPS coordinates, battery level, message buffers) from Core 0 via thread-safe queues. + +This separation means radio performance is identical to a headless RNode regardless of what the display is doing. + +### Operating Modes + +| Feature | Phone Connected (BLE) | Standalone | +|---------|----------------------|------------| +| Watch face / timekeeping | Yes | Yes | +| LoRa radio | Host-controlled via KISS | Autonomous beacon | +| GPS tracking | Reported to phone | Local beacon + LXMF | +| Message display | Relayed from Sideband | Direct LoRa receive | +| Message send | Via phone | Future | +| Activity tracking | Yes | Yes | +| Provisioning | Via phone or USB | Pre-provisioned | + +Mode switching is automatic. When a BLE host connects, the watch becomes a transparent modem. When the host disconnects, the watch enters standalone mode after a 15-second timeout. The watch face reflects the current mode. + +## Feature Tiers + +### Tier 1 — First Flash + +Get the watch running as an RNode with a visible clock. + +- Board definition in `Boards.h` with T-Watch Ultimate pin mapping +- AMOLED display driver (CO5300, 410x502 QSPI) +- Touch input driver (basic tap and swipe) +- Watch face: time, date, battery percentage +- GPS-synced RTC timekeeping +- BLE RNode modem mode (existing KISS/BLE stack) +- AXP2101 power management +- Radio status indicator on watch face (connected / idle / TX / RX) +- BLE connection indicator +- Provisioning via `rnodeconf` +- JTAG flash target in Makefile (OpenOCD, no serial bootloader) + +### Tier 2 — Core Watch + +Feature parity with T-Beam Supreme, plus watch-native status display. + +- Standalone GPS beacon + LXMF encrypted telemetry +- Radio status screen (frequency, SF, BW, channel utilization, RSSI) +- GPS status screen (coordinates, satellites, HDOP, fix age, minimap) +- Wrist-raise display wake via accelerometer +- Haptic feedback on radio events (message received, beacon sent) +- Display sleep/wake power management +- Battery usage optimisation (AMOLED sleep, peripheral power gating) + +### Tier 3 — Smart Watch + +Features that make it a daily-wear device. + +- On-watch LXMF message display with haptic notification +- Step counter and daily activity tracking +- Multiple watch face designs +- Alarm and timer functions +- Settings screen (LoRa parameters, BLE pairing, display brightness) +- Audio alerts via speaker for critical events (emergency beacon ACK, low battery) + +### Tier 4 — Aspirational + +Longer-term possibilities. + +- On-watch LXMF message composition (touch keyboard or canned responses) +- Mesh network visualisation (nearby nodes, link quality graph) +- Peer discovery and contact list +- Over-the-air firmware updates via BLE or WiFi +- Watch face customisation + +## Graphics and Artwork + +### Framework: LVGL + +The UI is built on [LVGL](https://lvgl.io/) (Light and Versatile Graphics Library), which is MIT-licensed and fully compatible with RNode's GPL-3.0. LVGL provides gesture-driven input, smooth animations, and efficient PSRAM-backed rendering on ESP32-S3. It is the standard choice for embedded touchscreen devices. + +### Original Artwork + +All watch face designs, icons, and graphical assets are original work created for the R-Watch project. LilyGo's factory firmware artwork is not reused — their repositories do not clearly license graphical assets separately from code, making reuse in a GPL project legally ambiguous. + +Design inspiration may be drawn from the factory UI's layout and interaction patterns (ideas are not copyrightable), but no bitmap assets, watch face graphics, or icon sets are copied. + +### Visual Identity + +The R-Watch visual language should: + +- Feel like a watch, not an electronics project. Clean typography, considered spacing, purposeful use of colour. +- Use the AMOLED's strengths: true blacks, vibrant accent colours against dark backgrounds, thin luminous arcs and indicators. +- Incorporate Reticulum/RNode identity subtly — the mesh network aesthetic should inform the design without dominating it. A watch face, not a dashboard. +- Prioritise readability at arm's length. Large time digits, high-contrast status indicators, no fine text that requires squinting. + +### Asset Licensing + +Code is GPL-3.0, consistent with the RNode project. Original graphical assets (watch faces, icons, UI elements) are licensed under Creative Commons Attribution-ShareAlike 4.0 (CC-BY-SA-4.0), allowing community contribution and remixing while preserving share-alike terms. + +## Technical Approach + +### What We Reuse + +The following existing RNode firmware modules compile for the T-Watch with a new board definition and pin mapping — no architectural changes: + +- `sx126x.cpp/h` — SX1262 LoRa driver +- `GPS.h` — GPS with TinyGPSPlus parser (adapt baud rate for MIA-M10Q: 38400 vs L76K's 9600) +- `Beacon.h` — GPS beacon transmission +- `LxmfBeacon.h` — LXMF telemetry messaging +- `BeaconCrypto.h` — ECDH + AES-256-CBC encryption +- `BLESerial.h` — BLE GATT serial interface +- `Bluetooth.h` — BLE pairing and state management +- `Power.h` — AXP2101 PMU driver (same chip as T-Beam Supreme) +- `RTC.h` — RTC driver (adapt register map for PCF85063A vs T-Beam's PCF8563) +- `Config.h` — Configuration state and KISS protocol +- `Modem.h` — Radio abstraction layer +- `Framing.h` — KISS framing + +### What We Build + +- **AMOLED display driver** — CO5300 controller over QSPI (4 data lines). The 410x502 display at 16-bit colour requires DMA-driven QSPI and a PSRAM-backed framebuffer. The UI layer is built on LVGL (MIT-licensed), which handles gesture input and efficient rendering on ESP32-S3 with PSRAM. + +- **Touch input system** — Replaces the GPIO button debounce in `Input.h`. Capacitive touch over I2C, with gesture recognition (tap, swipe direction, long-press). + +- **Watch UI layer** — The watch face, status screens, message display, and settings. This is the largest piece of new code. It runs on Core 1 and reads shared state from the modem task on Core 0. + +- **Board definition** — `BOARD_TWATCH_ULT` in `Boards.h` with pin mapping, feature flags, and display parameters. + +- **Makefile target** — `firmware-twatch_ultimate` using `esp32:esp32:esp32s3:CDCOnBoot=cdc`, flashed via OpenOCD JTAG (the T-Watch's native USB doesn't support esptool auto-reset). + +## What This Is Not + +- **Not Android or WearOS.** This is bare-metal firmware on an ESP32-S3. No app store, no Play Services, no Wear compatibility. The trade-off is sovereignty and radio capability. + +- **Not a phone replacement.** The phone running Sideband remains the primary Reticulum host. The watch is the radio and the display, not the router. + +- **Not a general-purpose LoRa development board.** It is a watch. The LoRa radio serves the watch's communication features, not the other way around. diff --git a/XL9555.h b/XL9555.h new file mode 100644 index 0000000..3c48476 --- /dev/null +++ b/XL9555.h @@ -0,0 +1,106 @@ +// XL9555 I2C GPIO Expander - Minimal driver for T-Watch Ultra +// Two 8-bit ports (0-7 = port 0, 8-15 = port 1) +// Registers: 0x00/0x01 input, 0x02/0x03 output, 0x06/0x07 direction (1=input, 0=output) + +#ifndef XL9555_H +#define XL9555_H + +#include + +#define XL9555_ADDR 0x20 +#define XL9555_REG_IN0 0x00 +#define XL9555_REG_IN1 0x01 +#define XL9555_REG_OUT0 0x02 +#define XL9555_REG_OUT1 0x03 +#define XL9555_REG_DIR0 0x06 +#define XL9555_REG_DIR1 0x07 + +// T-Watch Ultra expander pin assignments (matching LilyGoLib numbering) +#define EXPANDS_DRV_EN 6 // Port 0, bit 6 — haptic driver enable +#define EXPANDS_DISP_EN 14 // Port 1, bit 6 — display power gate +#define EXPANDS_DISP_RST 15 // Port 1, bit 7 — display reset +#define EXPANDS_TOUCH_RST 16 // Extended pin — touch panel reset +#define EXPANDS_SD_DET 12 // Port 1, bit 4 — SD card detect (input) +#define EXPANDS_LORA_RF_SW 11 // Port 1, bit 3 — LoRa RF switch + +static bool xl9555_ready = false; +static uint8_t xl9555_out[2] = {0xFF, 0xFF}; // output register cache + +static uint8_t xl9555_read_reg(uint8_t reg) { + Wire.beginTransmission(XL9555_ADDR); + Wire.write(reg); + Wire.endTransmission(false); + Wire.requestFrom((uint8_t)XL9555_ADDR, (uint8_t)1); + return Wire.available() ? Wire.read() : 0xFF; +} + +static void xl9555_write_reg(uint8_t reg, uint8_t val) { + Wire.beginTransmission(XL9555_ADDR); + Wire.write(reg); + Wire.write(val); + Wire.endTransmission(); +} + +bool xl9555_init() { + Wire.beginTransmission(XL9555_ADDR); + if (Wire.endTransmission() != 0) return false; + + // Set output pin directions (0 = output, 1 = input) + // Port 0: pin 6 (DRV_EN) as output, rest input + uint8_t dir0 = 0xFF & ~(1 << 6); + // Port 1: pins 3 (LORA_RF_SW), 6 (DISP_EN), 7 (DISP_RST) as output + uint8_t dir1 = 0xFF & ~((1 << 3) | (1 << 6) | (1 << 7)); + + xl9555_write_reg(XL9555_REG_DIR0, dir0); + xl9555_write_reg(XL9555_REG_DIR1, dir1); + + // Read current output state + xl9555_out[0] = xl9555_read_reg(XL9555_REG_OUT0); + xl9555_out[1] = xl9555_read_reg(XL9555_REG_OUT1); + + xl9555_ready = true; + return true; +} + +void xl9555_set(uint8_t pin, bool value) { + if (!xl9555_ready) return; + uint8_t port = (pin >= 8) ? 1 : 0; + uint8_t bit = pin % 8; + + if (value) { + xl9555_out[port] |= (1 << bit); + } else { + xl9555_out[port] &= ~(1 << bit); + } + xl9555_write_reg(port == 0 ? XL9555_REG_OUT0 : XL9555_REG_OUT1, xl9555_out[port]); +} + +bool xl9555_get(uint8_t pin) { + if (!xl9555_ready) return false; + uint8_t port = (pin >= 8) ? 1 : 0; + uint8_t bit = pin % 8; + uint8_t val = xl9555_read_reg(port == 0 ? XL9555_REG_IN0 : XL9555_REG_IN1); + return (val >> bit) & 1; +} + +// Convenience functions for sleep entry +void xl9555_sleep_prepare() { + if (!xl9555_ready) return; + xl9555_set(EXPANDS_DISP_RST, false); // hold display in reset + xl9555_set(EXPANDS_DRV_EN, false); // disable haptic + xl9555_set(EXPANDS_DISP_EN, false); // gate display power +} + +void xl9555_wake_display() { + if (!xl9555_ready) return; + xl9555_set(EXPANDS_DISP_EN, true); // enable display power + xl9555_set(EXPANDS_DISP_RST, false); // reset pulse + delay(50); + xl9555_set(EXPANDS_DISP_RST, true); +} + +void xl9555_enable_lora_antenna() { + xl9555_set(EXPANDS_LORA_RF_SW, true); // select built-in antenna +} + +#endif diff --git a/sx126x.cpp b/sx126x.cpp index ddfd3e1..480206f 100644 --- a/sx126x.cpp +++ b/sx126x.cpp @@ -128,7 +128,7 @@ bool sx126x::preInit() { pinMode(_ss, OUTPUT); digitalWrite(_ss, HIGH); - #if BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_TDECK || BOARD_MODEL == BOARD_XIAO_S3 || BOARD_MODEL == BOARD_TBEAM_S_V1 + #if BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_TDECK || BOARD_MODEL == BOARD_XIAO_S3 || BOARD_MODEL == BOARD_TBEAM_S_V1 || BOARD_MODEL == BOARD_TWATCH_ULT SPI.begin(pin_sclk, pin_miso, pin_mosi, pin_cs); #elif BOARD_MODEL == BOARD_TECHO SPI.setPins(pin_miso, pin_sclk, pin_mosi); @@ -337,7 +337,7 @@ int sx126x::begin(long frequency) { // Default after reset is LDO-only. Heltec V4 (and most SX1262 // boards) have the DC-DC inductor on VREGSW and need this set // before TCXO/calibration per datasheet Section 13.1. - #if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_RAK4631 || BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 || BOARD_MODEL == BOARD_TDECK + #if BOARD_MODEL == BOARD_HELTEC32_V4 || BOARD_MODEL == BOARD_HELTEC32_V3 || BOARD_MODEL == BOARD_RAK4631 || BOARD_MODEL == BOARD_T3S3 || BOARD_MODEL == BOARD_TBEAM || BOARD_MODEL == BOARD_TBEAM_S_V1 || BOARD_MODEL == BOARD_TDECK || BOARD_MODEL == BOARD_TWATCH_ULT uint8_t reg_mode = 0x01; // DC-DC + LDO executeOpcode(OP_REGULATOR_MODE_6X, ®_mode, 1); #endif @@ -684,6 +684,8 @@ void sx126x::enableTCXO() { uint8_t buf[4] = {MODE_TCXO_1_8V_6X, 0x00, 0x00, 0xFF}; #elif BOARD_MODEL == BOARD_HELTEC32_V4 uint8_t buf[4] = {MODE_TCXO_1_8V_6X, 0x00, 0xC8, 0x00}; // 800ms timeout + #elif BOARD_MODEL == BOARD_TWATCH_ULT + uint8_t buf[4] = {MODE_TCXO_1_8V_6X, 0x00, 0x00, 0xFF}; #endif executeOpcode(OP_DIO3_TCXO_CTRL_6X, buf, 4); delay(10); // Allow TCXO to stabilize