ESP32 MQTT Watch for Home Assistant

A touch AMOLED wrist controller built on the Waveshare ESP32 C6 Touch AMOLED 2.06

Contents

This is a small watch style device that sends Home Assistant commands over MQTT. It has a touch screen, a battery, and a simple UI. It does not pair to a phone and it does not run a smartwatch OS.

Hardware used

I used the Waveshare ESP32 C6 Touch AMOLED 2.06 development board. It is a watch style ESP32 C6 board with a 2.06 inch capacitive touch AMOLED display at 410x502, using a CO5300 display driver and an FT3168 touch controller. It also has onboard power management via an AXP2101.

The ESP32 C6 is a RISC V part with WiFi 6 and Bluetooth 5 support.

How it works

When the screen is on, the device connects to WiFi and connects to your MQTT broker. When you tap an icon, it publishes a short payload like kitchen_lights to watch/cmd.

Home Assistant listens to that topic and runs an automation that toggles a light, or runs any other action you map to the payload.

This is a single purpose device that uses a software sleep mode that turns the display off and shuts down WiFi. It wakes from this mode using GPIO interrupts from the touch controller interrupt pin and the power button pin.

Arduino IDE setup

Download the Waveshare demo package:
https://drive.google.com/file/d/1QDJbo-K4ev0CQAQlmGU18auxJHM_1aaf/view

Extract it and install the libraries bundled with the demo. Copy the libraries from the demo folder into your Arduino libraries folder. Avoid installing these libraries via the Arduino Library Manager, the demo bundle versions are picky.

This sketch uses LVGL, Arduino_GFX, Arduino_DriveBus, XPowersLib, and PubSubClient. PubSubClient is not included in the Waveshare examples so install that separately.

Arduino board settings

These are the settings I used:

  • Board, ESP32C6 Dev Module
  • USB CDC On Boot, Enabled
  • Partition Scheme, Huge app
  • Flash Size, 16MB

Flash a basic Hello World first, then flash the full sketch.

Home Assistant side

You need an MQTT broker configured in Home Assistant.

This automation listens on watch/cmd and toggles entities based on the payload.

alias: ESP32 watch commands
mode: single

trigger:
  - platform: mqtt
    topic: watch/cmd

action:
  - choose:
      - conditions:
          - condition: template
            value_template: "{{ trigger.payload == 'kitchen_lights' }}"
        sequence:
          - service: light.toggle
            target:
              entity_id: light.kitchen_lights

      - conditions:
          - condition: template
            value_template: "{{ trigger.payload == 'living_room_lights' }}"
        sequence:
          - service: light.toggle
            target:
              entity_id: light.living_room_lights

      - conditions:
          - condition: template
            value_template: "{{ trigger.payload == 'dining_room_lights' }}"
        sequence:
          - service: light.toggle
            target:
              entity_id: light.dining_room_lights

      - conditions:
          - condition: template
            value_template: "{{ trigger.payload == 'office_lights' }}"
        sequence:
          - service: light.toggle
            target:
              entity_id: light.office_lights

Build walkthrough

1. Includes and hardware drivers

This pulls in LVGL, the display driver stack, WiFi, MQTT, the PMU library, and ESP sleep APIs.

#include <lvgl.h>
#include "Arduino_GFX_Library.h"
#include "Arduino_DriveBus_Library.h"
#include <WiFi.h>
#include <PubSubClient.h>
#include <XPowersLib.h>
#include "esp_sleep.h"

The display is set up over QSPI, and the panel driver is Arduino_CO5300.

Arduino_DataBus *lcdBus = new Arduino_ESP32QSPI(
  LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3
);

Arduino_GFX *lcd = new Arduino_CO5300(
  lcdBus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT,
  22, 0, 0, 0
);

2. Touch input and LVGL glue

Touch uses the FT3168 controller. The code reads touch points over I2C and feeds LVGL via an input driver callback.

The important parts are:

  • tp_read() reads registers from the controller.
  • tp_point0() parses the first touch point and maps coordinates.
  • lv_touch() is the LVGL input callback.
static void lv_touch(lv_indev_t *indev, lv_indev_data_t *data) {
  ...
  (void)tp_point0(down, x, y);
  ...
}

3. Modes, navigation, and blocking

There are three modes:

  • MODE_SLEEP, screen off and WiFi off
  • MODE_CLOCK, watch face
  • MODE_GRID, Home Assistant button grid
enum ScreenMode : uint8_t {
  MODE_SLEEP = 0,
  MODE_CLOCK,
  MODE_GRID
};

Navigation is done via a request flag so swipes can trigger transitions cleanly.

static volatile uint8_t navReq = NAV_NONE;

static void nav_request(uint8_t r) {
  if (navReq != NAV_NONE) return;
  navReq = r;
  touchBlockUntil = millis() + 250;
  clickBlockUntil = millis() + 300;
}

4. MQTT payload queue

Taps enqueue a short payload string. The main loop publishes later, once WiFi and MQTT are connected.

static const uint8_t kTxQ = 8;
static char txQ[kTxQ][32];

static bool tx_push(const char *s) { ... }
static bool tx_pop(char *out, size_t outLen) { ... }

The mapping from button enum to payload is:

static const char *act_name(ActionId a) {
  switch (a) {
    case ACT_KITCHEN:  return "kitchen_lights";
    ...
  }
}

5. The UI screens

There are three LVGL screens:

  • Boot splash, the ambient_node logo
  • Clock face, time, date, battery
  • Grid screen, 8 icons and battery

I originally planned to use Material Design Icons. In LVGL that means loading a Material Icons font, which I did not want to deal with for this build. I ended up drawing simple shape based icons instead. Images would also work.

Boot splash:

static void make_boot_screen() {
  ...
  lv_label_set_text(lbl, "ambient_node");
}

Clock face tap toggles sleep:

static void face_tap(lv_event_t *e) {
  ...
  if (modeNow == MODE_CLOCK) {
    display_sleep();
  } else if (modeNow == MODE_SLEEP) {
    display_wake();
    ...
    load_clock(LV_SCR_LOAD_ANIM_FADE_IN);
  }
}

Grid click enqueues the payload:

static void grid_clicked(lv_event_t *e) {
  ...
  const char *name = act_name((ActionId)v);
  bool ok = tx_push(name);
  ...
}

6. Sleep and wake

There are two timers:

After 5 seconds idle on an active screen, it goes to MODE_SLEEP.

static const uint32_t idleToSleepMs = 5000;

When it sleeps, it turns the display off and shuts down networking.

static void display_sleep() {
  lcd->displayOff();
  modeNow = MODE_SLEEP;
  sleepAt = millis();
  wantsNet = false;
  net_down();
  wakeByTouch = false;
  wakeByPwr = false;
}

Wake is handled by GPIO interrupts that set flags.

static volatile bool wakeByTouch = false;
static volatile bool wakeByPwr = false;

static void IRAM_ATTR isr_tp()  { wakeByTouch = true; }
static void IRAM_ATTR isr_pwr() { wakeByPwr = true; }

attachInterrupt(digitalPinToInterrupt(TP_INT), isr_tp, FALLING);
attachInterrupt(digitalPinToInterrupt((int)POWER_BUTTON_GPIO), isr_pwr, FALLING);

While in MODE_SLEEP, ui_tick() checks those flags and restores the clock screen.

if (modeNow == MODE_SLEEP) {
  if ((millis() - sleepAt) < 150) return;

  if (wakeByTouch || wakeByPwr) {
    wakeByTouch = false;
    wakeByPwr = false;

    display_wake();
    touchBlockUntil = millis() + 250;
    clickBlockUntil = millis() + 300;
    load_clock(LV_SCR_LOAD_ANIM_FADE_IN);
  }
  return;
}

7. The main UI loop

ui_tick() runs every 50ms. It handles:

  • Wake checks while sleeping
  • Navigation requests
  • WiFi reconnect
  • MQTT connect
  • Publishing queued payloads
  • NTP once
  • Updating time labels
  • Updating battery labels
  • Idle timeout to sleep
static void ui_tick(lv_timer_t *t) {
  ...
}

8. setup() and loop()

setup() initializes display, touch, LVGL, MQTT, creates screens, and shows the boot splash on cold boot only.

On a cold boot, it briefly brings up WiFi to get time via NTP, then shuts WiFi down and starts in MODE_SLEEP. It wakes on touch or the power button.

Networking and MQTT are only used when the UI is awake.

Full code

#include <Arduino.h>
#include <Wire.h>
#include <memory>
#include <math.h>

#include "pin_config.h"
#include "HWCDC.h"

#include <lvgl.h>

#include "Arduino_GFX_Library.h"
#include "Arduino_DriveBus_Library.h"

#include <WiFi.h>
#include <time.h>
#include <PubSubClient.h>

#include <XPowersLib.h>
#include "esp_sleep.h"

enum ActionId : uint8_t {
  ACT_KITCHEN = 0,
  ACT_LIVING,
  ACT_DINING,
  ACT_BED1,
  ACT_BED2,
  ACT_GARDEN,
  ACT_SHED,
  ACT_OFFICE
};

HWCDC Diag;

Arduino_DataBus *lcdBus = new Arduino_ESP32QSPI(
  LCD_CS, LCD_SCLK, LCD_SDIO0, LCD_SDIO1, LCD_SDIO2, LCD_SDIO3
);

Arduino_GFX *lcd = new Arduino_CO5300(
  lcdBus, LCD_RESET, 0, LCD_WIDTH, LCD_HEIGHT,
  22, 0, 0, 0
);

std::shared_ptr<Arduino_IIC_DriveBus> i2cBus =
  std::make_shared<Arduino_HWIIC>(IIC_SDA, IIC_SCL, &Wire);

static void tp_irq(void);
std::unique_ptr<Arduino_IIC> tpDev(new Arduino_FT3x68(
  i2cBus, FT3168_DEVICE_ADDRESS, DRIVEBUS_DEFAULT_VALUE, TP_INT, tp_irq
));

static void tp_irq(void) {
  tpDev->IIC_Interrupt_Flag = true;
}

static lv_display_t *lvDisp = nullptr;
static lv_color_t *lvBuf = nullptr;
static uint32_t lcdW = 0;
static uint32_t lcdH = 0;
static uint32_t lvBufPx = 0;

static uint32_t lv_millis_cb() { return millis(); }

static void lv_flush(lv_display_t *d, const lv_area_t *area, uint8_t *px_map) {
  uint32_t w = lv_area_get_width(area);
  uint32_t h = lv_area_get_height(area);
  lcd->draw16bitRGBBitmap(area->x1, area->y1, (uint16_t *)px_map, w, h);
  lv_disp_flush_ready(d);
}

static void lv_round_area(lv_event_t *e) {
  lv_area_t *a = (lv_area_t *)lv_event_get_param(e);
  a->x1 = (a->x1 >> 1) << 1;
  a->y1 = (a->y1 >> 1) << 1;
  a->x2 = ((a->x2 >> 1) << 1) + 1;
  a->y2 = ((a->y2 >> 1) << 1) + 1;
}

enum ScreenMode : uint8_t {
  MODE_SLEEP = 0,
  MODE_CLOCK,
  MODE_GRID
};

static ScreenMode modeNow = MODE_SLEEP;
static uint32_t lastInputAt = 0;
static uint32_t touchBlockUntil = 0;
static uint32_t clickBlockUntil = 0;
static uint32_t sleepAt = 0;
static uint32_t navHoldUntil = 0;

#ifndef POWER_BUTTON_GPIO
#define POWER_BUTTON_GPIO 18
#endif

static const uint32_t idleToSleepMs = 5000;

// Secrets removed for publication
static const char *wifiSsid = "YOUR_WIFI_SSID";
static const char *wifiPass = "YOUR_WIFI_PASSWORD";

// Secrets removed for publication
static const char *mqttHost = "YOUR_MQTT_BROKER_HOST_OR_IP";
static const uint16_t mqttPort = 1883;

// Secrets removed for publication
static const char *mqttUser = "YOUR_MQTT_USERNAME";
static const char *mqttPass = "YOUR_MQTT_PASSWORD";

static const char *mqttClientId = "esp32watch";
static const char *mqttTopic = "watch/cmd";

static bool clockOk = false;
static bool hadWifi = false;
static bool wantsNet = false;

static WiFiClient netSock;
static PubSubClient mq(netSock);

static const uint8_t kTxQ = 8;
static char txQ[kTxQ][32];
static uint8_t txHead = 0;
static uint8_t txTail = 0;
static uint8_t txCount = 0;
static uint32_t keepAwakeUntil = 0;

static volatile bool wakeByTouch = false;
static volatile bool wakeByPwr = false;

static void IRAM_ATTR isr_tp() {
  wakeByTouch = true;
}

static void IRAM_ATTR isr_pwr() {
  wakeByPwr = true;
}

static bool tx_push(const char *s) {
  if (!s) return false;
  if (txCount >= kTxQ) return false;

  size_t n = strnlen(s, sizeof(txQ[0]) - 1);
  memset(txQ[txTail], 0, sizeof(txQ[0]));
  memcpy(txQ[txTail], s, n);
  txQ[txTail][n] = 0;

  txTail = (uint8_t)((txTail + 1) % kTxQ);
  txCount++;
  return true;
}

static bool tx_pop(char *out, size_t outLen) {
  if (!out || outLen == 0) return false;
  if (txCount == 0) return false;

  strncpy(out, txQ[txHead], outLen - 1);
  out[outLen - 1] = 0;

  txHead = (uint8_t)((txHead + 1) % kTxQ);
  txCount--;
  return true;
}

static void mq_try_connect() {
  if (WiFi.status() != WL_CONNECTED) return;
  if (mq.connected()) return;
  (void)mq.connect(mqttClientId, mqttUser, mqttPass);
}

static XPowersPMU pmu;
static bool pmuOk = false;

static void bump_activity() { lastInputAt = millis(); }

static void no_scroll(lv_obj_t *o) {
  lv_obj_clear_flag(o, LV_OBJ_FLAG_SCROLLABLE);
  lv_obj_set_scrollbar_mode(o, LV_SCROLLBAR_MODE_OFF);
}

static void net_up() {
  if (WiFi.getMode() != WIFI_STA) WiFi.mode(WIFI_STA);
  WiFi.setAutoReconnect(true);
  WiFi.persistent(false);
}

static void net_down() {
  mq.disconnect();
  WiFi.disconnect(true, true);
  delay(10);
  WiFi.mode(WIFI_OFF);
}

static void net_begin() {
  net_up();
  WiFi.begin(wifiSsid, wifiPass);
}

static bool net_wait(uint32_t timeoutMs) {
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && (millis() - t0) < timeoutMs) {
    delay(150);
  }
  if (WiFi.status() == WL_CONNECTED) {
    hadWifi = true;
    return true;
  }
  return false;
}

static const char *wake_name(esp_sleep_wakeup_cause_t c) {
  switch (c) {
    case ESP_SLEEP_WAKEUP_EXT0: return "EXT0";
    case ESP_SLEEP_WAKEUP_EXT1: return "EXT1";
    case ESP_SLEEP_WAKEUP_TIMER: return "TIMER";
    case ESP_SLEEP_WAKEUP_TOUCHPAD: return "TOUCHPAD";
    case ESP_SLEEP_WAKEUP_ULP: return "ULP";
    case ESP_SLEEP_WAKEUP_GPIO: return "GPIO";
    case ESP_SLEEP_WAKEUP_UART: return "UART";
    default: return "UNDEFINED";
  }
}

static void clock_from_build() {
  struct tm t {};
  char mstr[4] = {0};
  int d = 0, y = 0, hh = 0, mm = 0, ss = 0;

  sscanf(__DATE__, "%3s %d %d", mstr, &d, &y);
  sscanf(__TIME__, "%d:%d:%d", &hh, &mm, &ss);

  static const char *months = "JanFebMarAprMayJunJulAugSepOctNovDec";
  const char *p = strstr(months, mstr);
  int mo = p ? (int)((p - months) / 3) : 0;

  t.tm_year = y - 1900;
  t.tm_mon = mo;
  t.tm_mday = d;
  t.tm_hour = hh;
  t.tm_min = mm;
  t.tm_sec = ss;

  time_t tt = mktime(&t);
  struct timeval now = { .tv_sec = (long)tt, .tv_usec = 0 };
  settimeofday(&now, nullptr);

  clockOk = true;
}

static void ntp_once() {
  setenv("TZ", "Europe/London", 1);
  tzset();

  if (WiFi.status() != WL_CONNECTED) return;

  configTime(0, 0, "pool.ntp.org", "time.nist.gov", "time.google.com");
  struct tm t;
  uint32_t t0 = millis();
  while (!getLocalTime(&t) && (millis() - t0) < 8000) delay(200);
  clockOk = getLocalTime(&t);
}

static bool clock_tm(struct tm &out) { return getLocalTime(&out); }

static const lv_font_t *font_time() {
#if LV_FONT_MONTSERRAT_64
  return &lv_font_montserrat_64;
#elif LV_FONT_MONTSERRAT_48
  return &lv_font_montserrat_48;
#elif LV_FONT_MONTSERRAT_36
  return &lv_font_montserrat_36;
#else
  return LV_FONT_DEFAULT;
#endif
}

static const lv_font_t *font_small() {
#if LV_FONT_MONTSERRAT_18
  return &lv_font_montserrat_18;
#elif LV_FONT_MONTSERRAT_16
  return &lv_font_montserrat_16;
#else
  return LV_FONT_DEFAULT;
#endif
}

static const lv_font_t *font_boot() {
#if LV_FONT_MONTSERRAT_40
  return &lv_font_montserrat_40;
#elif LV_FONT_MONTSERRAT_36
  return &lv_font_montserrat_36;
#elif LV_FONT_MONTSERRAT_32
  return &lv_font_montserrat_32;
#elif LV_FONT_MONTSERRAT_28
  return &lv_font_montserrat_28;
#elif LV_FONT_MONTSERRAT_24
  return &lv_font_montserrat_24;
#else
  return LV_FONT_DEFAULT;
#endif
}

static lv_obj_t *screenBoot = nullptr;
static lv_obj_t *screenClock = nullptr;
static lv_obj_t *screenGrid = nullptr;

static lv_obj_t *timeLbl = nullptr;
static lv_obj_t *dateLbl = nullptr;
static lv_obj_t *orbDot = nullptr;
static lv_obj_t *orbArc = nullptr;

static lv_obj_t *battLblClock = nullptr;
static lv_obj_t *battLblGrid = nullptr;

static lv_timer_t *uiTimer = nullptr;

static const uint8_t NAV_NONE = 0;
static const uint8_t NAV_TO_GRID = 1;
static const uint8_t NAV_TO_CLOCK = 2;

static volatile uint8_t navReq = NAV_NONE;

static void nav_request(uint8_t r) {
  if (navReq != NAV_NONE) return;
  navReq = r;
  touchBlockUntil = millis() + 250;
  clickBlockUntil = millis() + 300;
}

static bool orbActive = false;

static void orb_exec(void *var, int32_t v) {
  lv_obj_t *dot = (lv_obj_t *)var;
  if (!dot) return;

  float rad = (float)v * 3.14159265f / 180.0f;
  float r = 130.0f;
  float cx = (float)lcdW / 2.0f;
  float cy = (float)lcdH / 2.0f - 6.0f;
  int x = (int)(cx + cosf(rad) * r) - 4;
  int y = (int)(cy + sinf(rad) * r) - 4;
  lv_obj_set_pos(dot, x, y);
}

static void orb_start() {
  if (orbActive) return;
  if (!orbDot) return;

  lv_anim_t a;
  lv_anim_init(&a);
  lv_anim_set_var(&a, orbDot);
  lv_anim_set_exec_cb(&a, orb_exec);
  lv_anim_set_values(&a, 0, 360);
  lv_anim_set_time(&a, 4500);
  lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE);
  lv_anim_start(&a);

  orbActive = true;
}

static void orb_stop() {
  if (!orbDot) return;
  lv_anim_del(orbDot, orb_exec);
  orbActive = false;
}

static void display_sleep() {
  orb_stop();
  lcd->displayOff();
  modeNow = MODE_SLEEP;
  sleepAt = millis();
  wantsNet = false;
  net_down();

  wakeByTouch = false;
  wakeByPwr = false;
}

static void display_wake() {
  lcd->displayOn();
  wantsNet = true;
  net_up();
}

static void load_clock(lv_scr_load_anim_t anim) {
  modeNow = MODE_CLOCK;
  orb_start();
  lv_scr_load_anim(screenClock, anim, 180, 0, false);
  bump_activity();
  navHoldUntil = millis() + 220;
}

static void load_grid(lv_scr_load_anim_t anim) {
  modeNow = MODE_GRID;
  orb_stop();
  lv_scr_load_anim(screenGrid, anim, 180, 0, false);
  bump_activity();
  navHoldUntil = millis() + 220;
}

static bool tp_read(uint8_t reg, uint8_t *buf, size_t len) {
  Wire.beginTransmission(FT3168_DEVICE_ADDRESS);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom((int)FT3168_DEVICE_ADDRESS, (int)len) != (int)len) return false;
  for (size_t i = 0; i < len; i++) buf[i] = Wire.read();
  return true;
}

static int tpMapMode = 0;

static bool tp_point0(bool &down, int32_t &x, int32_t &y) {
  uint8_t td = 0;
  if (!tp_read(0x02, &td, 1)) {
    down = false;
    return false;
  }

  uint8_t points = td & 0x0F;
  if (points == 0) {
    down = false;
    return true;
  }

  uint8_t p[4] = {0};
  if (!tp_read(0x03, p, 4)) {
    down = false;
    return false;
  }

  int32_t rawX = ((int32_t)(p[0] & 0x0F) << 8) | (int32_t)p[1];
  int32_t rawY = ((int32_t)(p[2] & 0x0F) << 8) | (int32_t)p[3];

  if (tpMapMode == 0) {
    x = rawX;
    y = rawY;
  } else if (tpMapMode == 1) {
    x = rawY;
    y = (int32_t)lcdH - 1 - rawX;
  } else {
    x = (int32_t)lcdW - 1 - rawX;
    y = (int32_t)lcdH - 1 - rawY;
  }

  if (x < 0) x = 0;
  if (y < 0) y = 0;
  if (x >= (int32_t)lcdW) x = (int32_t)lcdW - 1;
  if (y >= (int32_t)lcdH) y = (int32_t)lcdH - 1;

  down = true;
  return true;
}

static bool fingerDown = false;
static int32_t p0x = 0;
static int32_t p0y = 0;
static int32_t pnx = 0;
static int32_t pny = 0;
static bool swipeSeen = false;

static const int32_t swipeMinX = 70;
static const int32_t swipeMaxY = 90;

static void swipe_on_release() {
  int32_t dx = pnx - p0x;
  int32_t dy = pny - p0y;

  if (abs(dy) <= swipeMaxY) {
    if (modeNow == MODE_CLOCK && dx <= -swipeMinX) {
      swipeSeen = true;
      clickBlockUntil = millis() + 300;
      nav_request(NAV_TO_GRID);
      return;
    }
    if (modeNow == MODE_GRID && dx >= swipeMinX) {
      swipeSeen = true;
      clickBlockUntil = millis() + 300;
      nav_request(NAV_TO_CLOCK);
      return;
    }
  }
}

static int32_t lastX = 0;
static int32_t lastY = 0;

static void lv_touch(lv_indev_t *indev, lv_indev_data_t *data) {
  LV_UNUSED(indev);

  data->continue_reading = false;

  if (millis() < touchBlockUntil) {
    data->state = LV_INDEV_STATE_REL;
    data->point.x = lastX;
    data->point.y = lastY;
    return;
  }

  bool down = false;
  int32_t x = lastX;
  int32_t y = lastY;
  (void)tp_point0(down, x, y);

  if (down) {
    lastX = x;
    lastY = y;

    data->state = LV_INDEV_STATE_PR;
    data->point.x = x;
    data->point.y = y;

    bump_activity();

    if (!fingerDown) {
      p0x = x;
      p0y = y;
      pnx = x;
      pny = y;
      swipeSeen = false;
      fingerDown = true;
    } else {
      pnx = x;
      pny = y;
    }
  } else {
    data->state = LV_INDEV_STATE_REL;
    data->point.x = lastX;
    data->point.y = lastY;

    if (fingerDown) {
      fingerDown = false;
      swipe_on_release();
    }
  }
}

static void pulse_exec(void *var, int32_t dim) {
  lv_obj_t *p = (lv_obj_t *)var;
  if (!p) return;

  const int32_t startDim = 12;
  int32_t maxDim = (int32_t)(uintptr_t)lv_obj_get_user_data(p);
  if (maxDim < startDim) maxDim = startDim;

  int32_t opa = LV_OPA_60;
  if (maxDim != startDim) {
    int32_t num = (dim - startDim);
    if (num < 0) num = 0;
    if (num > (maxDim - startDim)) num = (maxDim - startDim);
    opa = (int32_t)LV_OPA_60 - ((int32_t)LV_OPA_60 * num) / (maxDim - startDim);
  }
  if (opa < 0) opa = 0;

  lv_obj_set_size(p, dim, dim);
  lv_obj_center(p);
  lv_obj_set_style_opa(p, (lv_opa_t)opa, 0);
}

static void pulse_done(lv_anim_t *a) {
  if (!a) return;
  lv_obj_t *p = (lv_obj_t *)a->var;
  if (p) lv_obj_del(p);
}

static void pulse_spawn(lv_obj_t *btn) {
  if (!btn) return;

  lv_obj_t *p = lv_obj_create(btn);
  no_scroll(p);
  lv_obj_set_style_radius(p, LV_RADIUS_CIRCLE, 0);
  lv_obj_set_style_bg_color(p, lv_color_white(), 0);
  lv_obj_set_style_bg_opa(p, LV_OPA_20, 0);
  lv_obj_set_style_border_width(p, 0, 0);
  lv_obj_set_style_opa(p, LV_OPA_0, 0);
  lv_obj_clear_flag(p, LV_OBJ_FLAG_CLICKABLE);

  int32_t maxDim = (int32_t)LV_MIN(lv_obj_get_width(btn), lv_obj_get_height(btn));
  const int32_t startDim = 12;
  if (maxDim < startDim) maxDim = startDim;

  lv_obj_set_user_data(p, (void *)(uintptr_t)maxDim);

  lv_obj_set_size(p, startDim, startDim);
  lv_obj_center(p);
  lv_obj_set_style_opa(p, LV_OPA_60, 0);

  lv_anim_t a;
  lv_anim_init(&a);
  lv_anim_set_var(&a, p);
  lv_anim_set_exec_cb(&a, pulse_exec);
  lv_anim_set_values(&a, startDim, maxDim);
  lv_anim_set_time(&a, 200);
  lv_anim_set_path_cb(&a, lv_anim_path_ease_out);
  lv_anim_set_ready_cb(&a, pulse_done);
  lv_anim_start(&a);
}

static const char *act_name(ActionId a) {
  switch (a) {
    case ACT_KITCHEN:  return "kitchen_lights";
    case ACT_LIVING:   return "living_room_lights";
    case ACT_DINING:   return "dining_room_lights";
    case ACT_BED1:     return "bedroom1_lights";
    case ACT_BED2:     return "bedroom2_lights";
    case ACT_GARDEN:   return "garden_lights";
    case ACT_SHED:     return "shed_lights";
    case ACT_OFFICE:   return "office_lights";
    default:           return "unknown";
  }
}

static void grid_pressed(lv_event_t *e) {
  lv_obj_t *btn = (lv_obj_t *)lv_event_get_target(e);
  if (!btn) return;
  if (millis() < touchBlockUntil) return;

  bump_activity();
  pulse_spawn(btn);
}

static void grid_clicked(lv_event_t *e) {
  if (millis() < touchBlockUntil) return;
  bump_activity();

  lv_obj_t *btn = (lv_obj_t *)lv_event_get_target(e);
  uintptr_t v = (uintptr_t)lv_obj_get_user_data(btn);
  const char *name = act_name((ActionId)v);

  bool ok = tx_push(name);
  if (ok) {
    keepAwakeUntil = millis() + 3500;
  } else {
    Diag.println("tx queue full");
  }
}

static void face_tap(lv_event_t *e) {
  LV_UNUSED(e);
  if (millis() < touchBlockUntil) return;
  if (millis() < clickBlockUntil) return;
  if (swipeSeen) return;

  if (modeNow == MODE_CLOCK) {
    display_sleep();
  } else if (modeNow == MODE_SLEEP) {
    display_wake();
    touchBlockUntil = millis() + 250;
    load_clock(LV_SCR_LOAD_ANIM_FADE_IN);
  }
}

static void icon_base(lv_obj_t *o) {
  no_scroll(o);
  lv_obj_set_style_bg_opa(o, LV_OPA_TRANSP, 0);
  lv_obj_set_style_border_width(o, 0, 0);
  lv_obj_set_style_outline_width(o, 0, 0);
  lv_obj_set_style_shadow_width(o, 0, 0);
  lv_obj_set_style_pad_all(o, 0, 0);
  lv_obj_clear_flag(o, LV_OBJ_FLAG_CLICKABLE);
  lv_obj_add_flag(o, LV_OBJ_FLAG_EVENT_BUBBLE);
}

static lv_obj_t *icon_root(lv_obj_t *parent, int size) {
  lv_obj_t *c = lv_obj_create(parent);
  icon_base(c);
  lv_obj_set_size(c, size, size);
  lv_obj_center(c);
  return c;
}

static lv_obj_t *icon_box(lv_obj_t *p, int w, int h, int x, int y, int radius, int bw) {
  lv_obj_t *o = lv_obj_create(p);
  icon_base(o);
  lv_obj_set_size(o, w, h);
  lv_obj_align(o, LV_ALIGN_CENTER, x, y);
  lv_obj_set_style_radius(o, radius, 0);
  lv_obj_set_style_border_width(o, bw, 0);
  lv_obj_set_style_border_color(o, lv_color_white(), 0);
  return o;
}

static lv_obj_t *icon_fill(lv_obj_t *p, int w, int h, int x, int y, int radius) {
  lv_obj_t *o = lv_obj_create(p);
  icon_base(o);
  lv_obj_set_size(o, w, h);
  lv_obj_align(o, LV_ALIGN_CENTER, x, y);
  lv_obj_set_style_radius(o, radius, 0);
  lv_obj_set_style_bg_color(o, lv_color_white(), 0);
  lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0);
  return o;
}

static void icon_fridge(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_box(c, 40, 54, 0, 2, 8, 3);
  icon_fill(c, 30, 2, 0, -4, 1);
  icon_fill(c, 3, 10, 12, -14, 2);
  icon_fill(c, 3, 12, 12, 10, 2);
}

static void icon_sofa(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_box(c, 48, 18, 0, -10, 8, 3);
  icon_box(c, 52, 18, 0, 6, 8, 3);
  icon_fill(c, 6, 6, -18, 22, 2);
  icon_fill(c, 6, 6, 18, 22, 2);
}

static void icon_computer(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_box(c, 54, 34, 0, -6, 8, 3);
  icon_fill(c, 18, 4, 0, 16, 2);
  icon_fill(c, 10, 8, 0, 22, 2);
}

static void icon_table(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_box(c, 54, 12, 0, -10, 8, 3);
  icon_fill(c, 6, 26, -18, 10, 2);
  icon_fill(c, 6, 26, 18, 10, 2);
  icon_fill(c, 46, 4, 0, 24, 2);
}

static void icon_bed(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_box(c, 56, 22, 0, 10, 8, 3);
  icon_box(c, 14, 22, -21, 10, 6, 3);
  icon_box(c, 18, 10, 10, 0, 6, 3);
  icon_fill(c, 6, 6, -22, 24, 2);
  icon_fill(c, 6, 6, 22, 24, 2);
}

static void icon_crib(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_box(c, 56, 28, 0, 6, 8, 3);
  for (int i = -18; i <= 18; i += 9) icon_fill(c, 3, 18, i, 6, 1);
  icon_fill(c, 52, 4, 0, 20, 2);
  icon_fill(c, 6, 8, -22, 24, 2);
  icon_fill(c, 6, 8, 22, 24, 2);
}

static void icon_gate(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);
  icon_fill(c, 6, 40, -22, 6, 2);
  icon_fill(c, 6, 40, 22, 6, 2);
  icon_fill(c, 44, 4, 0, -12, 2);
  icon_fill(c, 44, 4, 0, 2, 2);
  for (int i = -16; i <= 16; i += 8) icon_fill(c, 3, 24, i, 8, 1);
}

static void icon_tree(lv_obj_t *parent) {
  lv_obj_t *c = icon_root(parent, 70);

  lv_obj_t *crown = lv_obj_create(c);
  icon_base(crown);
  lv_obj_set_size(crown, 44, 44);
  lv_obj_align(crown, LV_ALIGN_CENTER, 0, -6);
  lv_obj_set_style_radius(crown, LV_RADIUS_CIRCLE, 0);
  lv_obj_set_style_border_width(crown, 3, 0);
  lv_obj_set_style_border_color(crown, lv_color_white(), 0);

  icon_fill(c, 12, 22, 0, 18, 3);
}

static void make_boot_screen() {
  screenBoot = lv_obj_create(nullptr);
  no_scroll(screenBoot);
  lv_obj_set_style_bg_color(screenBoot, lv_color_black(), 0);
  lv_obj_set_style_bg_opa(screenBoot, LV_OPA_COVER, 0);

  lv_obj_t *lbl = lv_label_create(screenBoot);
  no_scroll(lbl);
  lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
  lv_obj_set_style_text_font(lbl, font_boot(), 0);
  lv_obj_set_style_text_letter_space(lbl, 1, 0);
  lv_label_set_text(lbl, "ambient_node");
  lv_obj_align(lbl, LV_ALIGN_CENTER, 0, 0);
}

static void make_clock_screen() {
  screenClock = lv_obj_create(nullptr);
  no_scroll(screenClock);
  lv_obj_set_style_bg_color(screenClock, lv_color_black(), 0);
  lv_obj_set_style_bg_opa(screenClock, LV_OPA_COVER, 0);

  battLblClock = lv_label_create(screenClock);
  no_scroll(battLblClock);
  lv_obj_set_style_text_color(battLblClock, lv_color_hex(0xB0B0B0), 0);
  lv_obj_set_style_text_font(battLblClock, font_small(), 0);
  lv_label_set_text(battLblClock, "--%");
  lv_obj_align(battLblClock, LV_ALIGN_TOP_MID, 0, 10);

  timeLbl = lv_label_create(screenClock);
  no_scroll(timeLbl);
  lv_obj_set_style_text_color(timeLbl, lv_color_white(), 0);
  lv_obj_set_style_text_font(timeLbl, font_time(), 0);
  lv_label_set_text(timeLbl, "--:--");
  lv_obj_align(timeLbl, LV_ALIGN_CENTER, 0, -18);

  dateLbl = lv_label_create(screenClock);
  no_scroll(dateLbl);
  lv_obj_set_style_text_color(dateLbl, lv_color_hex(0xB0B0B0), 0);
  lv_obj_set_style_text_font(dateLbl, font_small(), 0);
  lv_label_set_text(dateLbl, "---- -- ---");
  lv_obj_align(dateLbl, LV_ALIGN_CENTER, 0, 46);

  orbArc = lv_arc_create(screenClock);
  no_scroll(orbArc);
  lv_obj_set_size(orbArc, 260, 260);
  lv_obj_align(orbArc, LV_ALIGN_CENTER, 0, -6);
  lv_arc_set_bg_angles(orbArc, 0, 360);
  lv_arc_set_angles(orbArc, 0, 360);
  lv_obj_remove_style(orbArc, nullptr, LV_PART_KNOB);
  lv_obj_set_style_bg_opa(orbArc, LV_OPA_TRANSP, LV_PART_MAIN);
  lv_obj_set_style_arc_width(orbArc, 2, LV_PART_MAIN);
  lv_obj_set_style_arc_color(orbArc, lv_color_hex(0x1E1E1E), LV_PART_MAIN);
  lv_obj_set_style_arc_width(orbArc, 2, LV_PART_INDICATOR);
  lv_obj_set_style_arc_color(orbArc, lv_color_hex(0x1E1E1E), LV_PART_INDICATOR);

  orbDot = lv_obj_create(screenClock);
  no_scroll(orbDot);
  lv_obj_set_size(orbDot, 8, 8);
  lv_obj_set_style_radius(orbDot, LV_RADIUS_CIRCLE, 0);
  lv_obj_set_style_bg_color(orbDot, lv_color_hex(0xD0D0D0), 0);
  lv_obj_set_style_bg_opa(orbDot, LV_OPA_COVER, 0);
  lv_obj_set_style_border_width(orbDot, 0, 0);
  lv_obj_clear_flag(orbDot, LV_OBJ_FLAG_CLICKABLE);

  lv_obj_add_event_cb(screenClock, face_tap, LV_EVENT_CLICKED, nullptr);
}

static void btn_icon_only(lv_obj_t *btn) {
  no_scroll(btn);
  lv_obj_set_style_bg_opa(btn, LV_OPA_TRANSP, LV_PART_MAIN);
  lv_obj_set_style_border_width(btn, 0, LV_PART_MAIN);
  lv_obj_set_style_outline_width(btn, 0, LV_PART_MAIN);
  lv_obj_set_style_shadow_width(btn, 0, LV_PART_MAIN);
  lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
}

static lv_obj_t *make_tile(lv_obj_t *parent, ActionId act, void (*iconFn)(lv_obj_t *)) {
  lv_obj_t *btn = lv_btn_create(parent);
  btn_icon_only(btn);

  lv_obj_set_user_data(btn, (void *)(uintptr_t)act);
  lv_obj_add_event_cb(btn, grid_pressed, LV_EVENT_PRESSED, nullptr);
  lv_obj_add_event_cb(btn, grid_clicked, LV_EVENT_CLICKED, nullptr);

  if (iconFn) iconFn(btn);
  return btn;
}

static void make_grid_screen() {
  screenGrid = lv_obj_create(nullptr);
  no_scroll(screenGrid);
  lv_obj_set_style_bg_color(screenGrid, lv_color_black(), 0);
  lv_obj_set_style_bg_opa(screenGrid, LV_OPA_COVER, 0);

  battLblGrid = lv_label_create(screenGrid);
  no_scroll(battLblGrid);
  lv_obj_set_style_text_color(battLblGrid, lv_color_hex(0xB0B0B0), 0);
  lv_obj_set_style_text_font(battLblGrid, font_small(), 0);
  lv_label_set_text(battLblGrid, "--%");
  lv_obj_align(battLblGrid, LV_ALIGN_TOP_MID, 0, 10);

  const int pad = 18;
  const int gap = 14;
  const int topReserve = 30;

  lv_obj_t *grid = lv_obj_create(screenGrid);
  no_scroll(grid);
  lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0);
  lv_obj_set_style_border_width(grid, 0, 0);
  lv_obj_set_style_pad_all(grid, 0, 0);

  lv_obj_set_size(grid, (int)lcdW - (pad * 2), (int)lcdH - (pad * 2) - topReserve);
  lv_obj_align(grid, LV_ALIGN_CENTER, 0, topReserve / 2);

  lv_obj_set_layout(grid, LV_LAYOUT_FLEX);
  lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
  lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER);

  const int tileW = ((int)lcdW - (pad * 2) - gap) / 2;
  const int tileH = ((int)lcdH - (pad * 2) - (gap * 3) - topReserve) / 4;

  lv_obj_t *b;
  b = make_tile(grid, ACT_KITCHEN,  icon_fridge);    lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_LIVING,   icon_sofa);      lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_DINING,   icon_table);     lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_BED1,     icon_bed);       lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_BED2,     icon_crib);      lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_GARDEN,   icon_tree);      lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_SHED, icon_gate);      lv_obj_set_size(b, tileW, tileH);
  b = make_tile(grid, ACT_OFFICE,   icon_computer);  lv_obj_set_size(b, tileW, tileH);
}

static void batt_labels() {
  if (!pmuOk) {
    if (battLblClock) lv_label_set_text(battLblClock, "--%");
    if (battLblGrid)  lv_label_set_text(battLblGrid, "--%");
    return;
  }

  int pct = (int)pmu.getBatteryPercent();
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;

  char buf[8];
  snprintf(buf, sizeof(buf), "%d%%", pct);

  if (battLblClock) lv_label_set_text(battLblClock, buf);
  if (battLblGrid)  lv_label_set_text(battLblGrid, buf);
}

static void pmu_adc_minimal() {
  pmu.disableTSPinMeasure();
  pmu.disableTemperatureMeasure();
  pmu.enableBattDetection();
  pmu.enableBattVoltageMeasure();
  pmu.disableVbusVoltageMeasure();
  pmu.disableSystemVoltageMeasure();
}

static void pmu_setup() {
#ifdef DEV_DEVICE_INIT
  DEV_DEVICE_INIT();
#endif

  pmu.disableIRQ(XPOWERS_AXP2101_ALL_IRQ);
  pmu.setChargeTargetVoltage(3);
  pmu.clearIrqStatus();
  pmu.enableIRQ(XPOWERS_AXP2101_PKEY_SHORT_IRQ);

  pmu_adc_minimal();

  int pct = (int)pmu.getBatteryPercent();
  pmuOk = (pct >= 0 && pct <= 100);
}

static void show_boot(uint32_t ms) {
  lv_scr_load(screenBoot);
  uint32_t t0 = millis();
  while (millis() - t0 < ms) {
    lv_timer_handler();
    delay(5);
  }
}

static void render_for(uint32_t ms) {
  uint32_t t0 = millis();
  while (millis() - t0 < ms) {
    lv_timer_handler();
    delay(5);
  }
}

static void ui_tick(lv_timer_t *t) {
  LV_UNUSED(t);

  if (modeNow == MODE_SLEEP) {
    if ((millis() - sleepAt) < 150) return;

    if (wakeByTouch || wakeByPwr) {
      wakeByTouch = false;
      wakeByPwr = false;

      display_wake();
      touchBlockUntil = millis() + 250;
      clickBlockUntil = millis() + 300;
      load_clock(LV_SCR_LOAD_ANIM_FADE_IN);
    }
    return;
  }

  uint8_t r = navReq;
  if (r != NAV_NONE) {
    navReq = NAV_NONE;
    if (r == NAV_TO_GRID && modeNow == MODE_CLOCK) {
      load_grid(LV_SCR_LOAD_ANIM_MOVE_LEFT);
    } else if (r == NAV_TO_CLOCK && modeNow == MODE_GRID) {
      load_clock(LV_SCR_LOAD_ANIM_MOVE_RIGHT);
    }
  }

  static uint32_t lastBeginAt = 0;
  static uint32_t lastMqTryAt = 0;

  if (wantsNet) {
    if (WiFi.status() != WL_CONNECTED) {
      if (millis() - lastBeginAt > 7000) {
        lastBeginAt = millis();
        net_begin();
      }
    } else {
      hadWifi = true;

      if (!mq.connected()) {
        if (millis() - lastMqTryAt > 3000) {
          lastMqTryAt = millis();
          mq_try_connect();
        }
      }
    }

    if (WiFi.status() == WL_CONNECTED && mq.connected() && txCount > 0) {
      char payload[32] = {0};
      if (tx_pop(payload, sizeof(payload))) {
        bool ok = mq.publish(mqttTopic, payload, false);
        Diag.print("MQTT ");
        Diag.print(payload);
        Diag.print(" ");
        Diag.println(ok ? "ok" : "fail");
      }
    }
  }

  static bool ntpDone = false;
  if (!ntpDone && WiFi.status() == WL_CONNECTED) {
    ntp_once();
    ntpDone = true;
  }

  if (!clockOk && !hadWifi) {
    static uint32_t lastTry = 0;
    if (millis() - lastTry > 15000) {
      lastTry = millis();
      clock_from_build();
    }
  }

  struct tm now;
  if (clock_tm(now)) {
    char tbuf[6];
    snprintf(tbuf, sizeof(tbuf), "%02d:%02d", now.tm_hour, now.tm_min);
    if (timeLbl) lv_label_set_text(timeLbl, tbuf);

    char dbuf[32];
    strftime(dbuf, sizeof(dbuf), "%a %d %b", &now);
    if (dateLbl) lv_label_set_text(dateLbl, dbuf);

    clockOk = true;
  }

  static uint32_t lastBattAt = 0;
  if (millis() - lastBattAt > 1500) {
    lastBattAt = millis();
    batt_labels();
  }

  if (millis() < navHoldUntil) return;

  if (keepAwakeUntil != 0 && millis() < keepAwakeUntil) {
    bump_activity();
  } else {
    keepAwakeUntil = 0;
  }

  uint32_t idle = millis() - lastInputAt;
  if (idle >= idleToSleepMs) {
    display_sleep();
  }
}

void setup() {
  Diag.begin(115200);

  esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
  bool fromDeep = (cause == ESP_SLEEP_WAKEUP_EXT1) || (cause == ESP_SLEEP_WAKEUP_EXT0) || (cause == ESP_SLEEP_WAKEUP_GPIO);

  pinMode((int)POWER_BUTTON_GPIO, INPUT_PULLUP);
  Diag.println("start");
  Diag.print("wake ");
  Diag.println(wake_name(cause));
  Diag.print("gpio ");
  Diag.print((int)POWER_BUTTON_GPIO);
  Diag.print(" ");
  Diag.println(digitalRead((int)POWER_BUTTON_GPIO));

  if (!lcd->begin()) Diag.println("lcd begin fail");
  lcd->fillScreen(RGB565_BLACK);

  Wire.begin(IIC_SDA, IIC_SCL);
  Wire.setClock(400000);

  pinMode(TP_INT, INPUT_PULLUP);

  attachInterrupt(digitalPinToInterrupt(TP_INT), isr_tp, FALLING);
  attachInterrupt(digitalPinToInterrupt((int)POWER_BUTTON_GPIO), isr_pwr, FALLING);

  while (tpDev->begin() == false) {
    Diag.println("tp init fail");
    delay(1000);
  }

  tpDev->IIC_Write_Device_State(
    tpDev->Arduino_IIC_Touch::Device::TOUCH_POWER_MODE,
    tpDev->Arduino_IIC_Touch::Device_Mode::TOUCH_POWER_MONITOR
  );

  setenv("TZ", "Europe/London", 1);
  tzset();

  pmu_setup();

  lv_init();
  lv_tick_set_cb(lv_millis_cb);

  lcdW = lcd->width();
  lcdH = lcd->height();

  lvBufPx = lcdW * 40;
  lvBuf = (lv_color_t *)heap_caps_malloc(lvBufPx * 2, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
  if (!lvBuf) lvBuf = (lv_color_t *)malloc(lvBufPx * 2);

  lvDisp = lv_display_create(lcdW, lcdH);
  lv_display_set_flush_cb(lvDisp, lv_flush);
  lv_display_set_buffers(lvDisp, lvBuf, nullptr, lvBufPx * 2, LV_DISPLAY_RENDER_MODE_PARTIAL);
  lv_display_add_event_cb(lvDisp, lv_round_area, LV_EVENT_INVALIDATE_AREA, nullptr);

  lv_indev_t *indev = lv_indev_create();
  lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
  lv_indev_set_read_cb(indev, lv_touch);

  mq.setServer(mqttHost, mqttPort);

  make_boot_screen();
  make_clock_screen();
  make_grid_screen();

  if (!fromDeep) {
    show_boot(1100);
  }

  lv_scr_load(screenClock);
  render_for(120);

  if (!fromDeep) {
    wantsNet = true;
    net_begin();
    (void)net_wait(5000);
    if (WiFi.status() == WL_CONNECTED) ntp_once();
    if (!clockOk) clock_from_build();
    hadWifi = (WiFi.status() == WL_CONNECTED);
    net_down();
    wantsNet = false;
  } else {
    struct tm tmp;
    if (!clock_tm(tmp)) {
      clock_from_build();
    }
    display_wake();
    net_begin();
  }

  batt_labels();
  orb_start();

  uiTimer = lv_timer_create(ui_tick, 50, nullptr);

  lastInputAt = millis();

  if (!fromDeep) {
    display_sleep();
  } else {
    modeNow = MODE_CLOCK;
    touchBlockUntil = millis() + 250;
    clickBlockUntil = millis() + 300;
    navHoldUntil = millis() + 220;
  }

  Diag.println("ready");
}

void loop() {
  lv_timer_handler();

  if (modeNow != MODE_SLEEP) {
    if (WiFi.status() == WL_CONNECTED) {
      mq.loop();
    }
  }

  if (modeNow == MODE_SLEEP) {
    delay(35);
  } else {
    delay(5);
  }
}

Notes

So this is a usable baseline, but there are a few things I want to change next if I revisit this project:

Deep sleep

Right now "sleep" means display off and WiFi off, but the MCU keeps running. The next step is moving MODE_SLEEP to real deep sleep, with wake on the touch interrupt pin and the power button pin, however, every time I tried this, the buttons did not wake the watcht required a hard reset.

MQTT hardening

Lock the MQTT user down so it can only publish to watch/cmd. If your broker supports it, use TLS and move off port 1883.

Reconnects

At the moment WiFi and MQTT reconnect logic runs in ui_tick(). If you add more screens or animations you may want to move networking to its own state so the UI stays responsive during reconnects.

Icons

The drawn icons work and keep the build simple, but using static images would be the next step.