Преглед изворни кода

Add initial firmware for the ESP32-S3-RLCD-4.2 display

Implements the application described in README.md: a 400x300 landscape
layout with the time and date across the top 60% and indoor/outdoor
temperatures across the bottom 40%.

Pin assignments and the ST7305 panel driver come from the vendor examples
in waveshareteam/ESP32-S3-RLCD-4.2 (02_Example/Arduino), since the
Waveshare wiki does not publish a pinout. lib/ST7305_U8g2 is vendored
verbatim from that repo's 10_U8G2_Test example (Apache-2.0).

Notable decisions, recorded in CLAUDE.md:

- The PCF85063 stores UTC, never local time, so daylight-saving changes
  never require a write to the chip.
- Boot order is panel, RTC, SHTC3, first draw, then Wi-Fi, so a useful
  screen appears even if the network never associates.
- The panel is refreshed only when a rendered string changes rather than
  when a value changes, which is what avoids needless full-frame flushes.
- Missing readings draw as --.- / --:--; a stale outdoor value keeps its
  last number with a dashed rule beneath it.

Not yet addressed: light sleep between refreshes, so the low-power design
goal is currently unmet, and partial-region panel updates.

Builds clean for esp32-s3-rlcd-42 (46 KB RAM, 928 KB flash). Untested on
hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Benjamin Harris пре 2 месеци
родитељ
комит
685e977e23
21 измењених фајлова са 1553 додато и 2 уклоњено
  1. 16 0
      .gitignore
  2. 91 0
      CLAUDE.md
  3. 2 2
      README.md
  4. 62 0
      include/config.h
  5. 24 0
      include/i2c_bus.h
  6. 15 0
      include/secrets.h.example
  7. 275 0
      lib/ST7305_U8g2/ST7305_U8g2.cpp
  8. 39 0
      lib/ST7305_U8g2/ST7305_U8g2.h
  9. 11 0
      lib/ST7305_U8g2/library.json
  10. 33 0
      platformio.ini
  11. 172 0
      src/display.cpp
  12. 39 0
      src/display.h
  13. 133 0
      src/main.cpp
  14. 128 0
      src/network.cpp
  15. 25 0
      src/network.h
  16. 129 0
      src/outdoor.cpp
  17. 25 0
      src/outdoor.h
  18. 179 0
      src/rtc.cpp
  19. 31 0
      src/rtc.h
  20. 109 0
      src/temperature.cpp
  21. 15 0
      src/temperature.h

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+# Local credentials — never commit
+include/secrets.h
+
+# PlatformIO
+.pio/
+.pioenvs/
+.piolibdeps/
+.clang_complete
+.gcc-flags.json
+compile_commands.json
+
+# Editors / OS
+.vscode/
+!.vscode/extensions.json
+.DS_Store
+Thumbs.db

+ 91 - 0
CLAUDE.md

@@ -0,0 +1,91 @@
+# CLAUDE.md
+
+Firmware for a desk clock / thermometer on the **WaveShare ESP32-S3-RLCD-4.2**.
+`README.md` is the product spec — layout, update rates and error behaviour are
+defined there and should be treated as requirements, not suggestions.
+
+## Build
+
+```sh
+pio run              # build
+pio run -t upload    # flash over USB-C
+pio device monitor   # 115200 baud
+```
+
+`include/secrets.h` is gitignored. Copy `include/secrets.h.example` to it before
+the first build; the build fails without it.
+
+## Hardware facts
+
+Confirmed from the vendor examples in
+[waveshareteam/ESP32-S3-RLCD-4.2](https://github.com/waveshareteam/ESP32-S3-RLCD-4.2)
+(`02_Example/Arduino/`), not from the wiki — the wiki does not publish a pinout.
+
+| Function | Pin / value |
+| --- | --- |
+| LCD controller | **ST7305**, 300 × 400 mono reflective, SPI |
+| LCD SCK / MOSI / DC / CS / RST / TE | 11 / 12 / 5 / 40 / 41 / 6 |
+| I²C SDA / SCL | 13 / 14 |
+| PCF85063 RTC | 0x51 |
+| SHTC3 temp/humidity | 0x70 |
+| Module | ESP32-S3-WROOM-1-N16R8 — 16 MB flash, 8 MB **octal** PSRAM |
+
+The panel is native **300 wide × 400 high**. The app drives it rotated
+(`U8G2_R1`), so all drawing code works in a **400 × 300** landscape frame. Any
+new layout constant belongs in `include/config.h`, not inline in `display.cpp`.
+
+`board_build.arduino.memory_type = qio_opi` in `platformio.ini` is what selects
+octal PSRAM. Dropping it silently disables PSRAM at boot.
+
+## Layout
+
+`lib/ST7305_U8g2/` is vendored verbatim from the vendor's `10_U8G2_Test`
+example (Apache-2.0). Treat it as third party: fix bugs upstream-style, keep
+local changes minimal and commented.
+
+```
+src/main.cpp          startup order + the loop; owns the indoor reading
+src/display.{h,cpp}   the only code that touches the panel
+src/rtc.{h,cpp}       PCF85063 driver, stores UTC
+src/temperature.{h,cpp}  SHTC3 driver
+src/network.{h,cpp}   Wi-Fi association + NTP, writes the RTC
+src/outdoor.{h,cpp}   HTTP poll of the remote sensor
+include/config.h      pins, layout, intervals, timezone
+```
+
+## Design decisions worth preserving
+
+**The RTC holds UTC, never local time.** Presentation goes through
+`localtime()` with `TIMEZONE_POSIX`, so a daylight-saving change never requires
+a write to the chip. Anything that writes the RTC must convert to UTC first.
+
+**Boot order is deliberate.** Panel → RTC → SHTC3 → first draw → *then* Wi-Fi.
+A useful screen appears in about a second even if the network never associates.
+Do not move `network::begin()` earlier.
+
+**Nothing in `loop()` blocks on the network.** `network::loop()` owns its own
+retry timing (`WIFI_RETRY_MS`); the clock and indoor reading must keep updating
+through an outage.
+
+**The panel is refreshed only when a *rendered string* changes**, not when a
+value changes. `display::update()` formats everything to text and compares
+against what is on screen, which is why a 21.54 → 21.51 drift causes no
+refresh. This is the mechanism behind the README's "avoid unnecessary
+full-screen refreshes"; keep it if you rework the renderer.
+
+**Missing data draws as `--.-` / `--:--`, never as a zero or a blank.** A stale
+outdoor reading keeps its last value on screen with a dashed rule under it —
+the README is explicit that the old number is preferable to an error value.
+
+## Fonts
+
+All from U8g2's built-in set; no font files are shipped. `FONT_TIME` is
+`logisoso92_tn`, which is **digits, colon, period and minus only** — it cannot
+render letters. `FONT_TEMP` must stay a `_tf` variant so `°` (U+00B0)
+resolves, and temperatures must be drawn with `drawUTF8`, not `drawStr`.
+
+## Not implemented
+
+Everything under README "Future Features", plus: light sleep between refreshes
+(the "low power consumption" goal is currently unaddressed — the loop spins),
+partial-region panel updates, and the TE pin.

+ 2 - 2
README.md

@@ -12,7 +12,7 @@ The display uses the full 300 × 400 pixel resolution of the 4.2-inch reflective
 
 ### Display Sections
 
-**Top 50%**
+**Top 60%**
 
 * Large digital time
 * Current date underneath
@@ -24,7 +24,7 @@ The display uses the full 300 × 400 pixel resolution of the 4.2-inch reflective
   * Day
   * Month
 
-**Bottom 50%**
+**Bottom 40%**
 
 * Two equal-width temperature sections
 * Indoor temperature on the left

+ 62 - 0
include/config.h

@@ -0,0 +1,62 @@
+#pragma once
+
+// Board- and application-wide configuration.
+//
+// Wi-Fi credentials and the outdoor sensor URL live in secrets.h, which is not
+// committed. Copy secrets.h.example to secrets.h before the first build.
+
+// ---------------------------------------------------------------- display ---
+// The panel is a 300 x 400 ST7305 reflective LCD. The application runs it
+// rotated (U8G2_R1), so the drawing surface is 400 wide by 300 high.
+#define LCD_NATIVE_WIDTH   300
+#define LCD_NATIVE_HEIGHT  400
+#define DISPLAY_WIDTH      400
+#define DISPLAY_HEIGHT     300
+
+#define RLCD_SCK_PIN       11
+#define RLCD_MOSI_PIN      12
+#define RLCD_DC_PIN        5
+#define RLCD_CS_PIN        40
+#define RLCD_RST_PIN       41
+#define RLCD_TE_PIN        6   // tearing effect output, unused
+
+// Layout: top 60% is time + date, bottom 40% is indoor | outdoor.
+#define LAYOUT_SPLIT_Y     180
+#define LAYOUT_MID_X       200
+
+// ------------------------------------------------------------------- i2c ---
+#define I2C_SDA_PIN        13
+#define I2C_SCL_PIN        14
+#define I2C_FREQ_HZ        400000
+
+#define PCF85063_ADDR      0x51
+#define SHTC3_ADDR         0x70
+
+// ------------------------------------------------------------------ time ---
+#define NTP_SERVER_1       "pool.ntp.org"
+#define NTP_SERVER_2       "time.google.com"
+
+// POSIX TZ string for Australia/Hobart (AEST-10, AEDT from Oct to Apr).
+#define TIMEZONE_POSIX     "AEST-10AEDT,M10.1.0,M4.1.0/3"
+
+// Date line format, strftime(3). "MONDAY 24 AUGUST".
+#define DATE_FORMAT        "%A %e %B"
+
+// -------------------------------------------------------------- intervals ---
+#define WIFI_CONNECT_TIMEOUT_MS   20000UL   // give up on a connect attempt
+#define WIFI_RETRY_MS             60000UL   // then retry this often
+#define NTP_RESYNC_MS             (6UL * 60UL * 60UL * 1000UL)
+#define INDOOR_READ_MS            30000UL
+#define OUTDOOR_POLL_MS           30000UL
+#define OUTDOOR_STALE_MS          (10UL * 60UL * 1000UL)  // mark reading stale
+#define DISPLAY_TICK_MS           1000UL    // check-for-change rate
+
+// ---------------------------------------------------------------- outdoor ---
+// HTTP timeout for one outdoor poll.
+#define OUTDOOR_HTTP_TIMEOUT_MS   5000
+
+// Plausible temperature range; readings outside it are rejected.
+#define TEMP_MIN_VALID     -50.0f
+#define TEMP_MAX_VALID     80.0f
+
+#include "secrets.h"

+ 24 - 0
include/i2c_bus.h

@@ -0,0 +1,24 @@
+#pragma once
+
+#include <Arduino.h>
+#include <Wire.h>
+
+#include "config.h"
+
+// The PCF85063 RTC and the SHTC3 sensor share one bus. Both drivers call this;
+// only the first call does any work.
+inline bool i2cBusBegin()
+{
+  static bool started = false;
+  if (!started) {
+    started = Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN, I2C_FREQ_HZ);
+  }
+  return started;
+}
+
+// True if a device acknowledges its address.
+inline bool i2cDevicePresent(uint8_t addr)
+{
+  Wire.beginTransmission(addr);
+  return Wire.endTransmission() == 0;
+}

+ 15 - 0
include/secrets.h.example

@@ -0,0 +1,15 @@
+#pragma once
+
+// Copy this file to secrets.h and fill in your own values.
+// secrets.h is listed in .gitignore and must not be committed.
+
+#define WIFI_SSID       "YOUR_WIFI"
+#define WIFI_PASSWORD   "YOUR_PASSWORD"
+
+// Hostname the display registers on the network.
+#define WIFI_HOSTNAME   "temp-display"
+
+// Outdoor temperature source. An HTTP GET that returns either a bare number
+// ("14.8") or a JSON object containing OUTDOOR_JSON_KEY.
+#define OUTDOOR_URL       "http://192.168.1.50/temperature"
+#define OUTDOOR_JSON_KEY  "temperature"

+ 275 - 0
lib/ST7305_U8g2/ST7305_U8g2.cpp

@@ -0,0 +1,275 @@
+#include "ST7305_U8g2.h"
+
+#define SPI_CLK 24000000
+#define ST7305_TILE_WIDTH 38
+#define ST7305_TILE_HEIGHT 50
+#define ST7305_BUFFER_ROW_BYTES (ST7305_TILE_WIDTH * 8)
+
+static ST7305_U8g2 *g_lcd_instance = nullptr;
+
+static u8x8_display_info_t st7305_display_info = {
+  /* chip_enable_level = */ 0,
+  /* chip_disable_level = */ 1,
+  /* post_chip_enable_wait_ns = */ 0,
+  /* pre_chip_disable_wait_ns = */ 0,
+  /* reset_pulse_width_ms = */ 20,
+  /* post_reset_wait_ms = */ 50,
+  /* sda_setup_time_ns = */ 0,
+  /* sck_pulse_width_ns = */ 0,
+  /* sck_clock_hz = */ SPI_CLK,
+  /* spi_mode = */ 0,
+  /* i2c_bus_clock_100kHz = */ 4,
+  /* data_setup_time_ns = */ 0,
+  /* write_pulse_width_ns = */ 0,
+  /* tile_width = */ ST7305_TILE_WIDTH,
+  /* tile_height = */ ST7305_TILE_HEIGHT,
+  /* default_x_offset = */ 0,
+  /* flip_mode_x_offset = */ 0,
+  /* pixel_width = */ 300,
+  /* pixel_height = */ 400
+};
+
+ST7305_U8g2::ST7305_U8g2(int sck, int mosi, int dc, int cs, int rst)
+  : _sck(sck), _mosi(mosi), _dc(dc), _cs(cs), _rst(rst)
+{
+  _spi = new SPIClass(HSPI);
+  g_lcd_instance = this;
+}
+
+ST7305_U8g2::~ST7305_U8g2()
+{
+  if (_spi) {
+    _spi->end();
+    delete _spi;
+  }
+  if (_my_buf) {
+    free(_my_buf);
+  }
+  if (g_lcd_instance == this) {
+    g_lcd_instance = nullptr;
+  }
+}
+
+void ST7305_U8g2::_cmd(uint8_t cmd)
+{
+  digitalWrite(_dc, LOW);
+  digitalWrite(_cs, LOW);
+  _spi->transfer(cmd);
+  digitalWrite(_cs, HIGH);
+}
+
+void ST7305_U8g2::_data(const uint8_t *data, size_t len)
+{
+  digitalWrite(_dc, HIGH);
+  digitalWrite(_cs, LOW);
+  _spi->transferBytes((uint8_t *)data, nullptr, len);
+  digitalWrite(_cs, HIGH);
+}
+
+void ST7305_U8g2::_cmd_data(uint8_t cmd, const uint8_t *data, size_t len)
+{
+  digitalWrite(_dc, LOW);
+  digitalWrite(_cs, LOW);
+  _spi->transfer(cmd);
+  if (len > 0) {
+    digitalWrite(_dc, HIGH);
+    _spi->transferBytes((uint8_t *)data, nullptr, len);
+  }
+  digitalWrite(_cs, HIGH);
+}
+
+void ST7305_U8g2::reset()
+{
+  digitalWrite(_rst, HIGH);
+  delay(50);
+  digitalWrite(_rst, LOW);
+  delay(20);
+  digitalWrite(_rst, HIGH);
+  delay(50);
+}
+
+uint8_t ST7305_U8g2::u8x8_byte_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
+{
+  (void)u8x8;
+  (void)arg_int;
+  (void)arg_ptr;
+
+  switch (msg) {
+    case U8X8_MSG_BYTE_INIT:
+    case U8X8_MSG_BYTE_START_TRANSFER:
+    case U8X8_MSG_BYTE_END_TRANSFER:
+    case U8X8_MSG_BYTE_SET_DC:
+    case U8X8_MSG_BYTE_SEND:
+    default:
+      return 1;
+  }
+}
+
+uint8_t ST7305_U8g2::u8x8_d_st7305_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr)
+{
+  (void)arg_int;
+
+  if (!g_lcd_instance) {
+    return 0;
+  }
+
+  switch (msg) {
+    case U8X8_MSG_DISPLAY_SETUP_MEMORY:
+      u8x8_d_helper_display_setup_memory(u8x8, &st7305_display_info);
+      break;
+
+    case U8X8_MSG_DISPLAY_INIT:
+      g_lcd_instance->fullInit();
+      break;
+
+    case U8X8_MSG_DISPLAY_DRAW_TILE: {
+      u8x8_tile_t *tile = (u8x8_tile_t *)arg_ptr;
+      uint8_t cnt = tile->cnt;
+      uint8_t y_pos = tile->y_pos;
+      uint8_t x_pos = tile->x_pos;
+
+      int first_col = x_pos * 8;
+      int last_col = (x_pos + cnt) * 8 - 1;
+      if (last_col >= 300) {
+        last_col = 299;
+      }
+
+      int addr_start = 0x12 + first_col / 12;
+      int addr_end = 0x12 + last_col / 12;
+      int send_start = (addr_start - 0x12) * 3;
+      int send_cnt = (addr_end - addr_start + 1) * 3;
+
+      int addr_first_col = (addr_start - 0x12) * 12;
+      int addr_last_col = (addr_end - 0x12) * 12 + 11;
+      if (addr_last_col >= 300) {
+        addr_last_col = 299;
+      }
+
+      uint8_t *row_base = tile->tile_ptr - ((uint16_t)x_pos * 8U);
+
+      uint8_t col_bounds[] = {(uint8_t)(0x3C - addr_end), (uint8_t)(0x3C - addr_start)};
+      g_lcd_instance->_cmd_data(0x2A, col_bounds, sizeof(col_bounds));
+
+      uint8_t row_bounds[] = {(uint8_t)(y_pos * 4), (uint8_t)(y_pos * 4 + 3)};
+      g_lcd_instance->_cmd_data(0x2B, row_bounds, sizeof(row_bounds));
+
+      static const uint8_t st_lut[4][4] = {
+        {0x00, 0x80, 0x40, 0xC0},
+        {0x00, 0x20, 0x10, 0x30},
+        {0x00, 0x08, 0x04, 0x0C},
+        {0x00, 0x02, 0x01, 0x03},
+      };
+
+      uint8_t all_rows[300] = {0};
+      for (int sr = 0; sr < 4; sr++) {
+        int shift = sr * 2;
+        int base_off = sr * send_cnt;
+        int idx = base_off + (addr_first_col >> 2) - send_start;
+
+        for (int col = addr_first_col; col <= addr_last_col; col += 4, idx++) {
+          all_rows[idx] = st_lut[0][(row_base[col] >> shift) & 3]
+                        | st_lut[1][(row_base[col + 1] >> shift) & 3]
+                        | st_lut[2][(row_base[col + 2] >> shift) & 3]
+                        | st_lut[3][(row_base[col + 3] >> shift) & 3];
+        }
+      }
+
+      g_lcd_instance->_cmd_data(0x2C, all_rows, (size_t)send_cnt * 4U);
+      break;
+    }
+
+    default:
+      return 0;
+  }
+
+  return 1;
+}
+
+void ST7305_U8g2::begin(uint8_t tile_buf_height, const u8g2_cb_t *rotation)
+{
+  pinMode(_dc, OUTPUT);
+  pinMode(_cs, OUTPUT);
+  pinMode(_rst, OUTPUT);
+  digitalWrite(_cs, HIGH);
+  digitalWrite(_dc, HIGH);
+  digitalWrite(_rst, HIGH);
+
+  _spi->begin(_sck, -1, _mosi, -1);
+  _spi->beginTransaction(SPISettings(SPI_CLK, MSBFIRST, SPI_MODE0));
+
+  u8g2_t *u = u8g2_wrapper.getU8g2();
+  u8x8_Setup(u8g2_GetU8x8(u), u8x8_d_st7305_custom, u8x8_dummy_cb, u8x8_byte_custom, u8x8_dummy_cb);
+
+  uint8_t tbh = tile_buf_height == 0 ? ST7305_TILE_HEIGHT : tile_buf_height;
+  if (tbh > ST7305_TILE_HEIGHT) {
+    tbh = ST7305_TILE_HEIGHT;
+  }
+
+  size_t buf_sz = ST7305_BUFFER_ROW_BYTES * tbh;
+  _my_buf = (uint8_t *)malloc(buf_sz);
+  if (!_my_buf) {
+    return;
+  }
+  memset(_my_buf, 0, buf_sz);
+
+  u8g2_SetupBuffer(u, _my_buf, tbh, u8g2_ll_hvline_vertical_top_lsb, rotation);
+  u8g2_InitDisplay(u);
+  u8g2_SetPowerSave(u, 0);
+}
+
+void ST7305_U8g2::fullInit()
+{
+  reset();
+
+  const uint8_t d6[] = {0x17, 0x02};
+  const uint8_t d1[] = {0x01};
+  const uint8_t c0[] = {0x11, 0x04};
+  const uint8_t c1[] = {0x69, 0x69, 0x69, 0x69};
+  const uint8_t c2[] = {0x19, 0x19, 0x19, 0x19};
+  const uint8_t c4[] = {0x4B, 0x4B, 0x4B, 0x4B};
+  const uint8_t d8[] = {0x80, 0xE9};
+  const uint8_t b2[] = {0x02};
+  const uint8_t b3[] = {0xE5, 0xF6, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45};
+  const uint8_t b4[] = {0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45};
+  const uint8_t g_timing[] = {0x32, 0x03, 0x1F};
+  const uint8_t b7[] = {0x13};
+  const uint8_t b0[] = {0x64};
+  const uint8_t c9[] = {0x00};
+  const uint8_t m36[] = {0x48};
+  const uint8_t m3a[] = {0x11};
+  const uint8_t b9[] = {0x20};
+  const uint8_t b8[] = {0x29};
+  const uint8_t win_a[] = {0x12, 0x2A};
+  const uint8_t win_b[] = {0x00, 0xC7};
+  const uint8_t m35[] = {0x00};
+  const uint8_t d0[] = {0xFF};
+
+  _cmd_data(0xD6, d6, sizeof(d6));
+  _cmd_data(0xD1, d1, sizeof(d1));
+  _cmd_data(0xC0, c0, sizeof(c0));
+  _cmd_data(0xC1, c1, sizeof(c1));
+  _cmd_data(0xC2, c2, sizeof(c2));
+  _cmd_data(0xC4, c4, sizeof(c4));
+  _cmd_data(0xC5, c2, sizeof(c2));
+  _cmd_data(0xD8, d8, sizeof(d8));
+  _cmd_data(0xB2, b2, sizeof(b2));
+  _cmd_data(0xB3, b3, sizeof(b3));
+  _cmd_data(0xB4, b4, sizeof(b4));
+  _cmd_data(0x62, g_timing, sizeof(g_timing));
+  _cmd_data(0xB7, b7, sizeof(b7));
+  _cmd_data(0xB0, b0, sizeof(b0));
+  _cmd(0x11);
+  delay(120);
+  _cmd_data(0xC9, c9, sizeof(c9));
+  _cmd_data(0x36, m36, sizeof(m36));
+  _cmd_data(0x3A, m3a, sizeof(m3a));
+  _cmd_data(0xB9, b9, sizeof(b9));
+  _cmd_data(0xB8, b8, sizeof(b8));
+  _cmd(0x21);
+  _cmd_data(0x2A, win_a, sizeof(win_a));
+  _cmd_data(0x2B, win_b, sizeof(win_b));
+  _cmd_data(0x35, m35, sizeof(m35));
+  _cmd_data(0xD0, d0, sizeof(d0));
+  _cmd(0x38);
+  _cmd(0x29);
+}

+ 39 - 0
lib/ST7305_U8g2/ST7305_U8g2.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <Arduino.h>
+#include <SPI.h>
+#include <U8g2lib.h>
+
+class ST7305_U8g2 {
+private:
+  int _sck;
+  int _mosi;
+  int _dc;
+  int _cs;
+  int _rst;
+  SPIClass *_spi = nullptr;
+  U8G2 u8g2_wrapper;
+  uint8_t *_my_buf = nullptr;
+
+  void _cmd(uint8_t cmd);
+  void _data(const uint8_t *data, size_t len);
+  void _cmd_data(uint8_t cmd, const uint8_t *data, size_t len);
+
+  static uint8_t u8x8_d_st7305_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
+  static uint8_t u8x8_byte_custom(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr);
+
+public:
+  ST7305_U8g2(int sck = 11, int mosi = 12, int dc = 5, int cs = 40, int rst = 41);
+  ~ST7305_U8g2();
+
+  // tile_buf_height 0 means full-buffer mode, about 15 KB RAM for 300x400.
+  // A smaller value can be used with firstPage()/nextPage(), but this demo uses full-buffer mode.
+  void begin(uint8_t tile_buf_height = 0, const u8g2_cb_t *rotation = U8G2_R0);
+  void reset();
+  void fullInit();
+
+  U8G2 *getU8g2()
+  {
+    return &u8g2_wrapper;
+  }
+};

+ 11 - 0
lib/ST7305_U8g2/library.json

@@ -0,0 +1,11 @@
+{
+  "name": "ST7305_U8g2",
+  "version": "1.0.0",
+  "description": "U8g2 display driver for the ST7305 300x400 reflective LCD on the WaveShare ESP32-S3-RLCD-4.2. Vendored from waveshareteam/ESP32-S3-RLCD-4.2 (Apache-2.0), example 10_U8G2_Test.",
+  "license": "Apache-2.0",
+  "frameworks": "arduino",
+  "platforms": "espressif32",
+  "dependencies": {
+    "olikraus/U8g2": "^2.35.30"
+  }
+}

+ 33 - 0
platformio.ini

@@ -0,0 +1,33 @@
+; PlatformIO project for the WaveShare ESP32-S3-RLCD-4.2 indoor/outdoor
+; temperature and clock display.
+;
+;   pio run              build
+;   pio run -t upload    build + flash
+;   pio device monitor   serial log at 115200
+
+[env:esp32-s3-rlcd-42]
+platform = espressif32
+board = esp32-s3-devkitc-1
+framework = arduino
+
+; 16 MB flash and 8 MB octal PSRAM, as fitted on the WaveShare board
+; (ESP32-S3-WROOM-1-N16R8). qio_opi is what selects octal PSRAM.
+board_build.flash_mode = qio
+board_build.arduino.memory_type = qio_opi
+board_build.partitions = default_16MB.csv
+board_upload.flash_size = 16MB
+board_upload.maximum_size = 16777216
+
+monitor_speed = 115200
+monitor_filters = esp32_exception_decoder
+upload_speed = 921600
+
+lib_deps =
+	olikraus/U8g2 @ ^2.35.30
+	bblanchon/ArduinoJson @ ^7.0.4
+
+build_flags =
+	-DARDUINO_USB_CDC_ON_BOOT=1
+	-DARDUINO_USB_MODE=1
+	-DBOARD_HAS_PSRAM
+	-DCORE_DEBUG_LEVEL=3

+ 172 - 0
src/display.cpp

@@ -0,0 +1,172 @@
+#include "display.h"
+
+#include <ST7305_U8g2.h>
+
+#include "config.h"
+
+// Layout, in the rotated 400 x 300 frame. The top 60% carries the time and
+// date; the bottom 40% is split into equal indoor and outdoor halves.
+namespace {
+
+constexpr int FRAME_X = 2;
+constexpr int FRAME_Y = 2;
+constexpr int FRAME_W = DISPLAY_WIDTH - 4;
+constexpr int FRAME_H = DISPLAY_HEIGHT - 4;
+
+constexpr int TIME_BASELINE  = 126;
+constexpr int DATE_BASELINE  = 172;
+constexpr int LABEL_BASELINE = 210;
+constexpr int TEMP_BASELINE  = 272;
+
+// Digits, colon and minus only — the largest font U8g2 ships.
+#define FONT_TIME   u8g2_font_logisoso92_tn
+#define FONT_DATE   u8g2_font_fub20_tr
+#define FONT_LABEL  u8g2_font_helvB14_tr
+#define FONT_TEMP   u8g2_font_fub35_tf   // "_tf" carries the ° glyph
+
+ST7305_U8g2 g_lcd(RLCD_SCK_PIN, RLCD_MOSI_PIN, RLCD_DC_PIN, RLCD_CS_PIN, RLCD_RST_PIN);
+U8G2 *g_u8g2 = nullptr;
+
+// What is currently on the panel, as rendered strings. Comparing the strings
+// rather than the raw floats is what keeps the panel from refreshing when a
+// reading moves by less than the displayed precision.
+struct Rendered {
+  bool valid = false;
+  char time[8] = "";
+  char date[40] = "";
+  char indoor[16] = "";
+  char outdoor[16] = "";
+  bool outdoorStale = false;
+};
+
+Rendered g_onScreen;
+
+void drawCentred(int centreX, int baseline, const char *text)
+{
+  const int w = g_u8g2->getUTF8Width(text);
+  g_u8g2->drawUTF8(centreX - w / 2, baseline, text);
+}
+
+void formatTime(const display::Model &m, char *out, size_t len)
+{
+  if (m.timeValid) {
+    snprintf(out, len, "%02u:%02u", (unsigned)m.hour, (unsigned)m.minute);
+  } else {
+    snprintf(out, len, "--:--");
+  }
+}
+
+void formatTemp(bool valid, float c, char *out, size_t len)
+{
+  if (valid) {
+    snprintf(out, len, "%.1f°C", c);
+  } else {
+    snprintf(out, len, "--.-°C");
+  }
+}
+
+void render(const Rendered &r)
+{
+  g_u8g2->clearBuffer();
+  g_u8g2->setDrawColor(1);
+
+  g_u8g2->drawFrame(FRAME_X, FRAME_Y, FRAME_W, FRAME_H);
+  g_u8g2->drawHLine(FRAME_X, LAYOUT_SPLIT_Y, FRAME_W);
+  g_u8g2->drawVLine(LAYOUT_MID_X, LAYOUT_SPLIT_Y + 1,
+                    DISPLAY_HEIGHT - LAYOUT_SPLIT_Y - FRAME_Y - 1);
+
+  g_u8g2->setFont(FONT_TIME);
+  drawCentred(DISPLAY_WIDTH / 2, TIME_BASELINE, r.time);
+
+  g_u8g2->setFont(FONT_DATE);
+  drawCentred(DISPLAY_WIDTH / 2, DATE_BASELINE, r.date);
+
+  const int leftCentre = LAYOUT_MID_X / 2;
+  const int rightCentre = LAYOUT_MID_X + LAYOUT_MID_X / 2;
+
+  g_u8g2->setFont(FONT_LABEL);
+  drawCentred(leftCentre, LABEL_BASELINE, "INDOOR");
+  drawCentred(rightCentre, LABEL_BASELINE, "OUTDOOR");
+
+  g_u8g2->setFont(FONT_TEMP);
+  drawCentred(leftCentre, TEMP_BASELINE, r.indoor);
+  drawCentred(rightCentre, TEMP_BASELINE, r.outdoor);
+
+  // A stale outdoor value stays on screen; a dashed rule under it marks that
+  // the source is no longer answering.
+  if (r.outdoorStale) {
+    const int w = g_u8g2->getUTF8Width(r.outdoor);
+    const int x0 = rightCentre - w / 2;
+    for (int x = x0; x < x0 + w; x += 8) {
+      g_u8g2->drawHLine(x, TEMP_BASELINE + 8, 4);
+    }
+  }
+
+  g_u8g2->sendBuffer();
+}
+
+}  // namespace
+
+namespace display {
+
+bool begin()
+{
+  // Rotated so the 300 x 400 panel presents as 400 x 300 landscape.
+  g_lcd.begin(0, U8G2_R1);
+  g_u8g2 = g_lcd.getU8g2();
+  if (!g_u8g2) {
+    return false;
+  }
+  g_u8g2->clearBuffer();
+  g_u8g2->sendBuffer();
+  return true;
+}
+
+void showBootMessage(const char *message)
+{
+  if (!g_u8g2) {
+    return;
+  }
+  g_u8g2->clearBuffer();
+  g_u8g2->setDrawColor(1);
+  g_u8g2->setFont(FONT_DATE);
+  drawCentred(DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2, message);
+  g_u8g2->sendBuffer();
+  g_onScreen.valid = false;
+}
+
+bool update(const Model &m)
+{
+  if (!g_u8g2) {
+    return false;
+  }
+
+  Rendered next;
+  next.valid = true;
+  formatTime(m, next.time, sizeof(next.time));
+  snprintf(next.date, sizeof(next.date), "%s", m.timeValid ? m.dateLine : "");
+  formatTemp(m.indoorValid, m.indoorC, next.indoor, sizeof(next.indoor));
+  formatTemp(m.outdoorValid, m.outdoorC, next.outdoor, sizeof(next.outdoor));
+  next.outdoorStale = m.outdoorValid && m.outdoorStale;
+
+  const bool unchanged = g_onScreen.valid
+      && strcmp(next.time, g_onScreen.time) == 0
+      && strcmp(next.date, g_onScreen.date) == 0
+      && strcmp(next.indoor, g_onScreen.indoor) == 0
+      && strcmp(next.outdoor, g_onScreen.outdoor) == 0
+      && next.outdoorStale == g_onScreen.outdoorStale;
+  if (unchanged) {
+    return false;
+  }
+
+  render(next);
+  g_onScreen = next;
+  return true;
+}
+
+void invalidate()
+{
+  g_onScreen.valid = false;
+}
+
+}  // namespace display

+ 39 - 0
src/display.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <Arduino.h>
+
+// Display engine for the 400 x 300 (rotated) ST7305 reflective LCD.
+//
+// The engine is fed a snapshot of everything shown on screen and decides
+// whether a panel refresh is needed. Nothing else touches the panel.
+namespace display {
+
+// One frame's worth of state. Invalid readings are signalled by the *Valid
+// flags rather than sentinel values, so the renderer can draw "--.-".
+struct Model {
+  bool timeValid = false;
+  uint8_t hour = 0;          // 24-hour
+  uint8_t minute = 0;
+  char dateLine[40] = "";    // pre-formatted, e.g. "MONDAY 24 AUGUST"
+
+  bool indoorValid = false;
+  float indoorC = 0.0f;
+
+  bool outdoorValid = false;
+  float outdoorC = 0.0f;
+  bool outdoorStale = false;  // last known value, source not reachable
+};
+
+bool begin();
+
+// Shows a one-line boot message before real data is available.
+void showBootMessage(const char *message);
+
+// Redraws and pushes to the panel only if m differs from what is on screen.
+// Returns true if the panel was refreshed.
+bool update(const Model &m);
+
+// Forces the next update() to refresh regardless of change.
+void invalidate();
+
+}  // namespace display

+ 133 - 0
src/main.cpp

@@ -0,0 +1,133 @@
+// Indoor/outdoor temperature and clock display for the
+// WaveShare ESP32-S3-RLCD-4.2.
+//
+// Startup order matters: the panel, RTC and onboard sensor all come up before
+// Wi-Fi is touched, so a working clock and indoor reading are on screen within
+// a second or two even if the network never associates.
+
+#include <Arduino.h>
+#include <time.h>
+
+#include "config.h"
+#include "display.h"
+#include "network.h"
+#include "outdoor.h"
+#include "rtc.h"
+#include "temperature.h"
+
+namespace {
+
+bool g_indoorValid = false;
+float g_indoorC = 0.0f;
+uint32_t g_lastIndoorMs = 0;
+uint32_t g_lastDisplayTickMs = 0;
+
+void readIndoor()
+{
+  float c = 0.0f;
+  if (temperature::read(c)) {
+    g_indoorC = c;
+    g_indoorValid = true;
+  } else {
+    // One bad read is usually noise on the bus; only give up after a while.
+    static uint8_t failures = 0;
+    if (++failures >= 3) {
+      g_indoorValid = false;
+      failures = 0;
+    }
+  }
+  g_lastIndoorMs = millis();
+}
+
+// "MONDAY 24 AUGUST" — strftime output, uppercased.
+void formatDateLine(const struct tm &local, char *out, size_t len)
+{
+  char buf[40];
+  strftime(buf, sizeof(buf), DATE_FORMAT, &local);
+
+  // %e pads single-digit days with a space; collapse the double space.
+  size_t w = 0;
+  for (size_t i = 0; buf[i] && w + 1 < len; i++) {
+    if (buf[i] == ' ' && w > 0 && out[w - 1] == ' ') {
+      continue;
+    }
+    out[w++] = (char)toupper((unsigned char)buf[i]);
+  }
+  out[w] = '\0';
+}
+
+display::Model buildModel()
+{
+  display::Model m;
+
+  // localtime() reads the system clock, which is seeded from the RTC at boot
+  // and corrected by NTP when available.
+  const time_t t = time(nullptr);
+  struct tm local;
+  localtime_r(&t, &local);
+
+  m.timeValid = rtc::hasValidTime() || network::hasSyncedTime();
+  if (m.timeValid) {
+    m.hour = (uint8_t)local.tm_hour;
+    m.minute = (uint8_t)local.tm_min;
+    formatDateLine(local, m.dateLine, sizeof(m.dateLine));
+  }
+
+  m.indoorValid = g_indoorValid;
+  m.indoorC = g_indoorC;
+
+  m.outdoorValid = outdoor::hasReading();
+  m.outdoorC = outdoor::lastCelsius();
+  m.outdoorStale = outdoor::isStale();
+
+  return m;
+}
+
+}  // namespace
+
+void setup()
+{
+  Serial.begin(115200);
+  delay(200);
+  Serial.println("\n[boot] ESP32-S3-RLCD-4.2 temperature display");
+
+  if (!display::begin()) {
+    Serial.println("[boot] display init failed");
+  }
+  display::showBootMessage("Starting...");
+
+  rtc::begin();
+  rtc::syncSystemClockFromRtc();
+
+  temperature::begin();
+  readIndoor();
+
+  outdoor::begin();
+
+  // Draw everything we already know before the network attempt, so the screen
+  // is useful immediately.
+  display::update(buildModel());
+
+  network::begin();
+}
+
+void loop()
+{
+  network::loop();
+  outdoor::loop();
+
+  const uint32_t nowMs = millis();
+
+  if (nowMs - g_lastIndoorMs >= INDOOR_READ_MS) {
+    readIndoor();
+  }
+
+  // The panel itself is only refreshed when a displayed value actually
+  // changes; this tick just decides how promptly that is noticed.
+  if (nowMs - g_lastDisplayTickMs >= DISPLAY_TICK_MS) {
+    g_lastDisplayTickMs = nowMs;
+    display::update(buildModel());
+  }
+
+  delay(10);
+}

+ 128 - 0
src/network.cpp

@@ -0,0 +1,128 @@
+#include "network.h"
+
+#include <WiFi.h>
+#include <time.h>
+
+#include "config.h"
+#include "rtc.h"
+
+namespace {
+
+bool g_synced = false;
+uint32_t g_lastAttemptMs = 0;
+uint32_t g_lastSyncMs = 0;
+bool g_connecting = false;
+
+void startConnect()
+{
+  Serial.printf("[net] connecting to %s\n", WIFI_SSID);
+  WiFi.disconnect();
+  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
+  g_lastAttemptMs = millis();
+  g_connecting = true;
+}
+
+}  // namespace
+
+namespace network {
+
+void begin()
+{
+  WiFi.mode(WIFI_STA);
+  WiFi.setHostname(WIFI_HOSTNAME);
+  WiFi.setAutoReconnect(true);
+  WiFi.setSleep(true);  // the display is idle most of the time
+
+  // Timezone is applied to the system clock regardless of connectivity, so the
+  // RTC-derived time renders correctly offline.
+  setenv("TZ", TIMEZONE_POSIX, 1);
+  tzset();
+
+  startConnect();
+}
+
+void loop()
+{
+  const uint32_t nowMs = millis();
+
+  if (WiFi.status() == WL_CONNECTED) {
+    if (g_connecting) {
+      g_connecting = false;
+      Serial.printf("[net] connected, ip=%s\n", WiFi.localIP().toString().c_str());
+      syncTimeNow();
+    }
+    if (g_synced && nowMs - g_lastSyncMs >= NTP_RESYNC_MS) {
+      syncTimeNow();
+    }
+    return;
+  }
+
+  // Not connected: retry, but never more often than WIFI_RETRY_MS.
+  const uint32_t since = nowMs - g_lastAttemptMs;
+  if (g_connecting && since < WIFI_CONNECT_TIMEOUT_MS) {
+    return;
+  }
+  if (since >= WIFI_RETRY_MS || (g_connecting && since >= WIFI_CONNECT_TIMEOUT_MS)) {
+    if (g_connecting) {
+      Serial.println("[net] connect attempt timed out");
+      g_connecting = false;
+      g_lastAttemptMs = nowMs;  // hold off for WIFI_RETRY_MS before retrying
+      return;
+    }
+    startConnect();
+  }
+}
+
+bool isConnected()
+{
+  return WiFi.status() == WL_CONNECTED;
+}
+
+bool syncTimeNow()
+{
+  if (!isConnected()) {
+    return false;
+  }
+
+  configTzTime(TIMEZONE_POSIX, NTP_SERVER_1, NTP_SERVER_2);
+
+  // Wait briefly for the first SNTP response. A plausible year means the
+  // system clock has been set from the network rather than left at the epoch.
+  struct tm local = {};
+  const uint32_t deadline = millis() + 10000UL;
+  bool got = false;
+  while (millis() < deadline) {
+    if (getLocalTime(&local, 200) && local.tm_year + 1900 >= 2024) {
+      got = true;
+      break;
+    }
+    delay(100);
+  }
+
+  if (!got) {
+    Serial.println("[net] NTP sync failed");
+    return false;
+  }
+
+  // Persist UTC into the RTC so timekeeping survives the next outage.
+  const time_t utcNow = time(nullptr);
+  struct tm utc;
+  gmtime_r(&utcNow, &utc);
+  if (!rtc::setTime(utc)) {
+    Serial.println("[net] NTP ok but RTC write failed");
+  }
+
+  g_synced = true;
+  g_lastSyncMs = millis();
+  Serial.printf("[net] time synced: %04d-%02d-%02d %02d:%02d local\n",
+                local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
+                local.tm_hour, local.tm_min);
+  return true;
+}
+
+bool hasSyncedTime()
+{
+  return g_synced;
+}
+
+}  // namespace network

+ 25 - 0
src/network.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <Arduino.h>
+
+// Wi-Fi association and NTP time synchronisation.
+//
+// Nothing here blocks for longer than one connect attempt at startup: loop()
+// must keep running so the clock and indoor reading stay live without Wi-Fi.
+namespace network {
+
+void begin();
+
+// Drives reconnects and periodic NTP resyncs. Call from loop().
+void loop();
+
+bool isConnected();
+
+// Attempts one NTP sync, writing the result into the RTC on success. Safe to
+// call when offline; returns false immediately.
+bool syncTimeNow();
+
+// True once NTP has succeeded at least once since boot.
+bool hasSyncedTime();
+
+}  // namespace network

+ 129 - 0
src/outdoor.cpp

@@ -0,0 +1,129 @@
+#include "outdoor.h"
+
+#include <ArduinoJson.h>
+#include <HTTPClient.h>
+
+#include "config.h"
+#include "network.h"
+
+namespace {
+
+bool g_have = false;
+float g_celsius = 0.0f;
+uint32_t g_lastGoodMs = 0;
+uint32_t g_lastPollMs = 0;
+
+bool inRange(float t)
+{
+  return !isnan(t) && t >= TEMP_MIN_VALID && t <= TEMP_MAX_VALID;
+}
+
+// The source may answer with a bare number or with JSON containing
+// OUTDOOR_JSON_KEY. Accept either so a plain sensor sketch needs no wrapper.
+bool parseBody(const String &body, float &out)
+{
+  JsonDocument doc;
+  if (deserializeJson(doc, body) == DeserializationError::Ok) {
+    JsonVariant v = doc[OUTDOOR_JSON_KEY];
+    if (!v.isNull()) {
+      const float t = v.as<float>();
+      if (inRange(t)) {
+        out = t;
+        return true;
+      }
+      return false;
+    }
+    if (doc.is<float>()) {  // a bare JSON number
+      const float t = doc.as<float>();
+      if (inRange(t)) {
+        out = t;
+        return true;
+      }
+      return false;
+    }
+    return false;
+  }
+
+  // Not JSON: try the whole body as a decimal number.
+  char *end = nullptr;
+  const float t = strtof(body.c_str(), &end);
+  if (end == body.c_str() || !inRange(t)) {
+    return false;
+  }
+  out = t;
+  return true;
+}
+
+bool poll()
+{
+  HTTPClient http;
+  http.setTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
+  http.setConnectTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
+
+  if (!http.begin(OUTDOOR_URL)) {
+    Serial.println("[outdoor] bad URL");
+    return false;
+  }
+
+  const int code = http.GET();
+  if (code != HTTP_CODE_OK) {
+    Serial.printf("[outdoor] HTTP %d\n", code);
+    http.end();
+    return false;
+  }
+
+  const String body = http.getString();
+  http.end();
+
+  float t = 0.0f;
+  if (!parseBody(body, t)) {
+    Serial.printf("[outdoor] unparseable response: %s\n", body.c_str());
+    return false;
+  }
+
+  g_celsius = t;
+  g_have = true;
+  g_lastGoodMs = millis();
+  Serial.printf("[outdoor] %.1f C\n", t);
+  return true;
+}
+
+}  // namespace
+
+namespace outdoor {
+
+void begin()
+{
+  // Force the first poll as soon as Wi-Fi comes up.
+  g_lastPollMs = (uint32_t)(0 - OUTDOOR_POLL_MS);
+}
+
+void loop()
+{
+  if (!network::isConnected()) {
+    return;
+  }
+  const uint32_t nowMs = millis();
+  if (nowMs - g_lastPollMs < OUTDOOR_POLL_MS) {
+    return;
+  }
+  g_lastPollMs = nowMs;
+  poll();
+}
+
+bool hasReading()
+{
+  return g_have;
+}
+
+float lastCelsius()
+{
+  return g_celsius;
+}
+
+bool isStale()
+{
+  return !g_have || (millis() - g_lastGoodMs) > OUTDOOR_STALE_MS;
+}
+
+}  // namespace outdoor

+ 25 - 0
src/outdoor.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <Arduino.h>
+
+// Outdoor temperature, fetched over the network from a separate sensor.
+//
+// The last good reading is retained across network outages: the display shows
+// it (flagged stale) rather than blanking out.
+namespace outdoor {
+
+void begin();
+
+// Polls the source when due. Call from loop(); does nothing when offline.
+void loop();
+
+// True if a reading has ever been received.
+bool hasReading();
+
+// Last received value in °C. Only meaningful when hasReading().
+float lastCelsius();
+
+// True when the last reading is older than OUTDOOR_STALE_MS.
+bool isStale();
+
+}  // namespace outdoor

+ 179 - 0
src/rtc.cpp

@@ -0,0 +1,179 @@
+#include "rtc.h"
+
+#include <sys/time.h>
+
+#include "config.h"
+#include "i2c_bus.h"
+
+// The RTC holds UTC. Presentation is done by localtime()/strftime() using
+// TIMEZONE_POSIX, so daylight saving changes never need a write to the chip.
+
+namespace {
+
+// PCF85063 register map.
+constexpr uint8_t REG_CONTROL_1 = 0x00;
+constexpr uint8_t REG_CONTROL_2 = 0x01;
+constexpr uint8_t REG_SECONDS   = 0x04;  // bit 7 is the oscillator-stop flag
+constexpr uint8_t OS_FLAG       = 0x80;
+
+bool g_present = false;
+
+uint8_t bcdToBin(uint8_t v)
+{
+  return (uint8_t)((v >> 4) * 10 + (v & 0x0F));
+}
+
+uint8_t binToBcd(uint8_t v)
+{
+  return (uint8_t)(((v / 10) << 4) | (v % 10));
+}
+
+bool writeReg(uint8_t reg, uint8_t value)
+{
+  Wire.beginTransmission(PCF85063_ADDR);
+  Wire.write(reg);
+  Wire.write(value);
+  return Wire.endTransmission() == 0;
+}
+
+bool readRegs(uint8_t reg, uint8_t *buf, size_t len)
+{
+  Wire.beginTransmission(PCF85063_ADDR);
+  Wire.write(reg);
+  if (Wire.endTransmission(false) != 0) {
+    return false;
+  }
+  if (Wire.requestFrom((int)PCF85063_ADDR, (int)len) != (int)len) {
+    return false;
+  }
+  for (size_t i = 0; i < len; i++) {
+    buf[i] = Wire.read();
+  }
+  return true;
+}
+
+// Days since 1970-01-01 for a proleptic Gregorian date. Howard Hinnant's
+// days_from_civil; used instead of timegm() to keep the conversion explicit.
+long daysFromCivil(int y, unsigned m, unsigned d)
+{
+  y -= m <= 2;
+  const int era = (y >= 0 ? y : y - 399) / 400;
+  const unsigned yoe = (unsigned)(y - era * 400);
+  const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
+  const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+  return (long)era * 146097 + (long)doe - 719468;
+}
+
+time_t tmToUtc(const struct tm &t)
+{
+  const long days = daysFromCivil(t.tm_year + 1900, (unsigned)(t.tm_mon + 1),
+                                  (unsigned)t.tm_mday);
+  return (time_t)days * 86400 + t.tm_hour * 3600 + t.tm_min * 60 + t.tm_sec;
+}
+
+}  // namespace
+
+namespace rtc {
+
+bool begin()
+{
+  if (!i2cBusBegin()) {
+    Serial.println("[rtc] I2C bus failed to start");
+    return false;
+  }
+
+  g_present = i2cDevicePresent(PCF85063_ADDR);
+  if (!g_present) {
+    Serial.println("[rtc] PCF85063 not responding");
+    return false;
+  }
+
+  // Normal mode, RTC running, no clock output on INT.
+  writeReg(REG_CONTROL_1, 0x00);
+  writeReg(REG_CONTROL_2, 0x00);
+  return true;
+}
+
+bool hasValidTime()
+{
+  if (!g_present) {
+    return false;
+  }
+  uint8_t seconds = 0;
+  if (!readRegs(REG_SECONDS, &seconds, 1)) {
+    return false;
+  }
+  return (seconds & OS_FLAG) == 0;
+}
+
+bool now(struct tm &out)
+{
+  if (!g_present) {
+    return false;
+  }
+
+  uint8_t r[7];  // seconds, minutes, hours, days, weekdays, months, years
+  if (!readRegs(REG_SECONDS, r, sizeof(r))) {
+    return false;
+  }
+  if (r[0] & OS_FLAG) {
+    return false;  // oscillator stopped: the stored time is not trustworthy
+  }
+
+  struct tm t = {};
+  t.tm_sec  = bcdToBin(r[0] & 0x7F);
+  t.tm_min  = bcdToBin(r[1] & 0x7F);
+  t.tm_hour = bcdToBin(r[2] & 0x3F);
+  t.tm_mday = bcdToBin(r[3] & 0x3F);
+  t.tm_wday = r[4] & 0x07;
+  t.tm_mon  = bcdToBin(r[5] & 0x1F) - 1;
+  t.tm_year = bcdToBin(r[6]) + 100;  // register counts years from 2000
+  t.tm_isdst = 0;
+
+  // A blank or corrupt register set can pass the OS-flag check.
+  if (t.tm_mon < 0 || t.tm_mon > 11 || t.tm_mday < 1 || t.tm_mday > 31) {
+    return false;
+  }
+
+  out = t;
+  return true;
+}
+
+bool setTime(const struct tm &t)
+{
+  if (!g_present) {
+    return false;
+  }
+
+  Wire.beginTransmission(PCF85063_ADDR);
+  Wire.write(REG_SECONDS);
+  Wire.write(binToBcd((uint8_t)t.tm_sec));  // writing clears the OS flag
+  Wire.write(binToBcd((uint8_t)t.tm_min));
+  Wire.write(binToBcd((uint8_t)t.tm_hour));
+  Wire.write(binToBcd((uint8_t)t.tm_mday));
+  Wire.write((uint8_t)(t.tm_wday & 0x07));
+  Wire.write(binToBcd((uint8_t)(t.tm_mon + 1)));
+  Wire.write(binToBcd((uint8_t)(t.tm_year % 100)));
+  return Wire.endTransmission() == 0;
+}
+
+bool syncSystemClockFromRtc()
+{
+  struct tm t;
+  if (!now(t)) {
+    return false;
+  }
+
+  struct timeval tv = {};
+  tv.tv_sec = tmToUtc(t);
+  if (settimeofday(&tv, nullptr) != 0) {
+    return false;
+  }
+
+  Serial.printf("[rtc] system clock set from RTC: %04d-%02d-%02d %02d:%02d:%02dZ\n",
+                t.tm_year + 1900, t.tm_mon + 1, t.tm_mday,
+                t.tm_hour, t.tm_min, t.tm_sec);
+  return true;
+}
+
+}  // namespace rtc

+ 31 - 0
src/rtc.h

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <Arduino.h>
+#include <time.h>
+
+// PCF85063 hardware real-time clock.
+//
+// The RTC is the authoritative time source: it survives Wi-Fi outages and,
+// with the backup cell fitted, power loss. NTP only ever writes into it.
+namespace rtc {
+
+// Starts the shared I2C bus and probes the RTC. Returns false if the chip does
+// not answer, in which case now() always reports an invalid time.
+bool begin();
+
+// True once the RTC has been probed and reports a time it has not lost
+// (oscillator-stop flag clear).
+bool hasValidTime();
+
+// Reads the RTC into out. Returns false if the read failed or the RTC has lost
+// its time, in which case out is left untouched.
+bool now(struct tm &out);
+
+// Writes t to the RTC and clears the oscillator-stop flag.
+bool setTime(const struct tm &t);
+
+// Copies the RTC into the ESP32 system clock so localtime()/strftime() work
+// without a network. Call after begin() and after every setTime().
+bool syncSystemClockFromRtc();
+
+}  // namespace rtc

+ 109 - 0
src/temperature.cpp

@@ -0,0 +1,109 @@
+#include "temperature.h"
+
+#include "config.h"
+#include "i2c_bus.h"
+
+namespace {
+
+// SHTC3 commands, MSB first.
+constexpr uint16_t CMD_WAKEUP          = 0x3517;
+constexpr uint16_t CMD_SLEEP           = 0xB098;
+constexpr uint16_t CMD_SOFT_RESET      = 0x805D;
+constexpr uint16_t CMD_MEAS_T_RH_POLL  = 0x7866;  // T first, no clock stretch
+
+constexpr uint16_t CRC_POLYNOMIAL = 0x131;
+
+bool g_present = false;
+
+bool sendCommand(uint16_t cmd)
+{
+  Wire.beginTransmission(SHTC3_ADDR);
+  Wire.write((uint8_t)(cmd >> 8));
+  Wire.write((uint8_t)(cmd & 0xFF));
+  return Wire.endTransmission() == 0;
+}
+
+// SHTC3 CRC-8, polynomial 0x31, init 0xFF.
+bool crcOk(const uint8_t *data, uint8_t len, uint8_t checksum)
+{
+  uint8_t crc = 0xFF;
+  for (uint8_t i = 0; i < len; i++) {
+    crc ^= data[i];
+    for (uint8_t bit = 8; bit > 0; bit--) {
+      crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ CRC_POLYNOMIAL) : (uint8_t)(crc << 1);
+    }
+  }
+  return crc == checksum;
+}
+
+}  // namespace
+
+namespace temperature {
+
+bool begin()
+{
+  if (!i2cBusBegin()) {
+    return false;
+  }
+
+  sendCommand(CMD_WAKEUP);
+  delayMicroseconds(300);
+
+  g_present = i2cDevicePresent(SHTC3_ADDR);
+  if (!g_present) {
+    Serial.println("[temp] SHTC3 not responding");
+    return false;
+  }
+
+  sendCommand(CMD_SOFT_RESET);
+  delay(1);
+  sendCommand(CMD_SLEEP);
+  return true;
+}
+
+bool read(float &celsius, float *relHumidity)
+{
+  if (!g_present) {
+    return false;
+  }
+
+  if (!sendCommand(CMD_WAKEUP)) {
+    return false;
+  }
+  delayMicroseconds(300);
+
+  if (!sendCommand(CMD_MEAS_T_RH_POLL)) {
+    sendCommand(CMD_SLEEP);
+    return false;
+  }
+  delay(15);  // worst-case conversion time in normal mode
+
+  uint8_t raw[6];
+  bool ok = Wire.requestFrom((int)SHTC3_ADDR, (int)sizeof(raw)) == (int)sizeof(raw);
+  if (ok) {
+    for (uint8_t i = 0; i < sizeof(raw); i++) {
+      raw[i] = Wire.read();
+    }
+  }
+  sendCommand(CMD_SLEEP);
+
+  if (!ok || !crcOk(&raw[0], 2, raw[2]) || !crcOk(&raw[3], 2, raw[5])) {
+    return false;
+  }
+
+  const uint16_t rawT = (uint16_t)(raw[0] << 8 | raw[1]);
+  const uint16_t rawH = (uint16_t)(raw[3] << 8 | raw[4]);
+
+  const float t = -45.0f + 175.0f * (float)rawT / 65535.0f;
+  if (t < TEMP_MIN_VALID || t > TEMP_MAX_VALID) {
+    return false;
+  }
+
+  celsius = t;
+  if (relHumidity) {
+    *relHumidity = 100.0f * (float)rawH / 65535.0f;
+  }
+  return true;
+}
+
+}  // namespace temperature

+ 15 - 0
src/temperature.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include <Arduino.h>
+
+// Onboard SHTC3 temperature/humidity sensor: the indoor reading.
+namespace temperature {
+
+bool begin();
+
+// Takes a single-shot measurement. Returns false on a bus error, a CRC
+// mismatch, or an implausible value, leaving the outputs untouched.
+// humidity may be null.
+bool read(float &celsius, float *relHumidity = nullptr);
+
+}  // namespace temperature