Parcourir la source

Add setup portal, clock/date formats, battery and power saving

Three related features, kept in one commit because they share settings.cpp,
portal.cpp, display.cpp and config.h too heavily to split cleanly.

Runtime configuration and setup portal
--------------------------------------
Wi-Fi, the outdoor source, timezone and NTP server move out of secrets.h and
into NVS, entered through a web page served by the device. With nothing
stored it raises a captive-portal AP and shows joining instructions on the
panel, which is the only interface it has before it is on a network. Once
associated the same page stays reachable on the LAN and over mDNS so the
outdoor URL can be changed without re-provisioning.

secrets.h is now optional and only seeds a freshly erased device; the build
works with it empty or absent. Holding KEY at power-up returns to setup.

Losing Wi-Fi deliberately does not raise the setup AP: replacing a working
clock with a setup screen on a router reboot is the opposite of the README's
"continued operation without Wi-Fi" goal.

Clock and date formats
----------------------
12- or 24-hour time, and the date line as a preset or a raw strftime pattern
with an optional capitals toggle. The portal previews both lines by calling
back into the same two formatters the panel uses, so the preview cannot drift
from the screen. The time font carries no letters, so AM/PM is drawn
separately; a long date steps down a font ladder rather than overflowing.

Battery indicator and power saving
----------------------------------
A four-bar battery icon top right, from a lithium-ion discharge curve rather
than a linear voltage map, quantised to quarters so ADC noise cannot trigger
a panel refresh. Charging is not shown because the divider sits downstream of
the charger and cannot distinguish it from a full cell.

While the room is lit: CPU at 80 MHz, Wi-Fi modem sleep, panel low-power
driving mode. While dark: Wi-Fi off and light sleep, waking every 20 s only to
sample an optional BH1750, since a reflective panel holds its image unpowered
and cannot be read unlit anyway. Falls back to quiet hours from the clock when
no sensor is fitted. Hysteresis on the threshold prevents chatter, and
sleeping is suppressed around use of the settings page.

Verified: builds clean (15.4% RAM, 16.2% flash); portal JS syntax-checked with
every element id, endpoint and form field cross-checked against the registered
routes and the save handler; battery curve, quiet-hours midnight wrap, 12/24h
conversion and darkness hysteresis checked as runnable logic.

Unverified on hardware and noted in CLAUDE.md: ST7305 0x39 as low-power mode
(inferred from the vendor init sending 0x38, and switchable from the portal),
light sleep with USB-CDC serial, and BH1750 one-shot timing. No current
measurement taken, so no runtime figures are claimed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Benjamin Harris il y a 1 mois
Parent
commit
5f79b4f0f3
23 fichiers modifiés avec 2416 ajouts et 124 suppressions
  1. 92 9
      CLAUDE.md
  2. 206 23
      README.md
  3. 69 4
      include/config.h
  4. 14 11
      include/secrets.h.example
  5. 6 0
      lib/ST7305_U8g2/ST7305_U8g2.cpp
  6. 6 0
      lib/ST7305_U8g2/ST7305_U8g2.h
  7. 149 0
      src/ambient.cpp
  8. 34 0
      src/ambient.h
  9. 107 0
      src/battery.cpp
  10. 30 0
      src/battery.h
  11. 210 16
      src/display.cpp
  12. 32 2
      src/display.h
  13. 75 13
      src/main.cpp
  14. 62 10
      src/network.cpp
  15. 7 0
      src/network.h
  16. 60 35
      src/outdoor.cpp
  17. 12 1
      src/outdoor.h
  18. 772 0
      src/portal.cpp
  19. 39 0
      src/portal.h
  20. 114 0
      src/power.cpp
  21. 36 0
      src/power.h
  22. 230 0
      src/settings.cpp
  23. 54 0
      src/settings.h

+ 92 - 9
CLAUDE.md

@@ -12,8 +12,11 @@ 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.
+No `secrets.h` is required — Wi-Fi, the outdoor URL, the timezone and the NTP
+server are runtime settings in NVS, entered through the setup portal.
+`include/secrets.h` is optional and gitignored; when present it only seeds a
+freshly erased device. Stored settings always win, so editing it changes
+nothing until NVS is erased (`pio run -t erase`).
 
 ## Hardware facts
 
@@ -28,6 +31,18 @@ Confirmed from the vendor examples in
 | I²C SDA / SCL | 13 / 14 |
 | PCF85063 RTC | 0x51 |
 | SHTC3 temp/humidity | 0x70 |
+| KEY / BOOT buttons | 18 / 0, both active low |
+| Battery divider | ADC1_CH3 = GPIO4, ×3, downstream of the charger |
+| BH1750 (optional, user-fitted) | 0x23 or 0x5C on the same I²C bus |
+
+**The panel has no backlight or frontlight and there is no onboard light
+sensor.** Both were checked against the vendor docs and examples. This is why
+"dim the screen" is not implementable and why darkness instead triggers sleep —
+a reflective panel holds its image with no power and is unreadable unlit.
+
+Because the divider is downstream of the charger, **charging cannot be detected**
+— on USB the node reads the same ~4.2 V as a full cell. Don't add a charging
+glyph without a hardware way to tell.
 | 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
@@ -43,14 +58,19 @@ octal PSRAM. Dropping it silently disables PSRAM at boot.
 example (Apache-2.0). Treat it as third party: fix bugs upstream-style, keep
 local changes minimal and commented.
 
-```
+```text
 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
+src/settings.{h,cpp}  NVS-backed runtime config
+src/portal.{h,cpp}    setup web UI; the HTML/CSS/JS page lives here
+src/battery.{h,cpp}   18650 voltage and percentage
+src/ambient.{h,cpp}   BH1750, with quiet-hours fallback
+src/power.{h,cpp}     CPU clock, panel mode, dark-room light sleep
+include/config.h      pins, layout, intervals, compiled-in defaults
 ```
 
 ## Design decisions worth preserving
@@ -77,15 +97,78 @@ full-screen refreshes"; keep it if you rework the renderer.
 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.
 
+**Losing Wi-Fi never raises the setup AP.** The portal appears only when NVS
+holds no SSID, or when KEY is held at power-up. Falling back to an AP on a
+router reboot would replace a working clock with a setup screen, which is the
+opposite of the "continued operation without Wi-Fi" goal.
+
+**Saving settings reboots the device** rather than reconfiguring in place, so
+what the user sees after a save is the same code path as a power-on.
+
+**`display::formatTimeText` and `display::formatDateLine` are the single
+source of truth for presentation.** `main.cpp` uses them to fill the model and
+the portal's `/api/preview` uses them to answer the browser, so the preview
+cannot drift from the panel. Do not reimplement either formatter in the page's
+JavaScript — the ESP32 has a real `strftime` and the browser does not.
+
+**`power::sleepWhileDark()` blocks until the room is lit again.** It is not a
+state machine — it owns the loop while resting, with Wi-Fi off, waking on a
+timer or on KEY. Callers treat a `true` return as "you were away for a while":
+refresh everything, re-poll the outdoor source, `display::invalidate()`.
+
+**Everything on screen is quantised before it is compared.** The battery is
+four bars, not a percentage, specifically so voltage noise cannot trigger a
+refresh. Anything new on the panel should be reduced the same way — put the
+quantisation in `update()` next to the existing dirty check, not in the caller.
+
+**80 MHz is a floor, not a preference.** It is the lowest the ESP32-S3 runs
+with Wi-Fi active, and at or above 80 MHz the APB clock stays 80 MHz so the
+display SPI and UART dividers are unaffected. `power::begin()` must run before
+`Serial.begin()` or the log comes out garbled.
+
+**The portal page is one self-contained document** in `portal.cpp` — no CDN,
+no external fonts. In setup mode the phone loading it has no route to the
+internet, so any external reference would simply hang. There is a build-time
+check on this: `python` extraction plus `node --check` on the script block is
+how the page's JS was verified, since nothing here has a browser test.
+
 ## 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`.
+render letters, which is why the AM/PM suffix has its own `FONT_TIME_SUFFIX`
+and is drawn separately. `FONT_TEMP` must stay a `_tf` variant so `°`
+(U+00B0) resolves, and temperatures must be drawn with `drawUTF8`, not
+`drawStr`.
+
+The date line picks the largest font from the `DATE_FONTS` ladder that fits
+across the panel, because the format string is user-supplied and an arbitrary
+`strftime` pattern can produce a very long line.
+
+## Unverified on hardware
+
+Three things were written from datasheets and cannot be confirmed without the
+board. Check these first if something misbehaves:
+
+- **ST7305 command 0x39 as low-power mode.** Inferred from the vendor's init
+  sending 0x38 (high power). If the panel looks wrong, the "Panel low-power
+  mode" checkbox in the portal turns it off without a reflash.
+- **Light sleep with USB-CDC.** `ARDUINO_USB_CDC_ON_BOOT=1` means serial
+  logging may not survive the first rest. Harmless on battery; annoying on the
+  bench, where unchecking "Sleep when the room is dark" avoids it.
+- **BH1750 timing.** A flat 180 ms one-shot delay, the datasheet maximum, with
+  no busy polling.
 
 ## 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.
+Everything under README "Future Features", plus partial-region panel updates
+and the TE pin. Deep sleep is not used anywhere — light sleep retains RAM,
+which is what keeps the last outdoor reading across a dark night.
+
+No runtime current measurement has been taken, so avoid quoting runtime
+figures in the docs.
+
+The portal is plain HTTP with no authentication: anyone on the LAN can read the
+outdoor URL and change settings. The Wi-Fi password is never sent to the
+browser, but it is stored in NVS in the clear. Acceptable for a home device on
+a trusted network; revisit before this goes anywhere else.

+ 206 - 23
README.md

@@ -24,6 +24,9 @@ The display uses the full 300 × 400 pixel resolution of the 4.2-inch reflective
   * Day
   * Month
 
+A small battery indicator sits in the top right corner. It can be turned off in
+the setup page.
+
 **Bottom 40%**
 
 * Two equal-width temperature sections
@@ -58,6 +61,33 @@ The board provides:
 
 The onboard SHTC3 provides the indoor temperature measurement. The PCF85063 provides hardware real-time clock functionality.
 
+The 4.2-inch panel is fully reflective and has **no backlight or frontlight**.
+It is read by room light alone, which means it needs no power to hold an image
+but also cannot be read in the dark. Both facts shape the power saving below.
+
+Battery voltage is measured through the onboard divider on ADC1 channel 3
+(GPIO4). The divider sits downstream of the charger, so the firmware reports
+cell voltage but cannot distinguish "charging" from "fully charged".
+
+### Optional: ambient light sensor
+
+A **BH1750** module on the expansion header lets the display detect an unlit
+room and sleep. It is optional — without it the same decision is made from the
+clock using configurable quiet hours.
+
+| BH1750 | Board |
+| --- | --- |
+| VCC | 3V3 |
+| GND | GND |
+| SDA | GPIO 13 |
+| SCL | GPIO 14 |
+| ADDR | leave unconnected |
+
+It shares the existing I²C bus with the RTC and the SHTC3, so no GPIO is
+consumed. Both possible addresses (0x23 and 0x5C) are probed at boot, and the
+setup page reports whether the sensor was found along with the current lux
+reading.
+
 See the official hardware documentation:
 
 [WaveShare ESP32-S3-RLCD-4.2 Documentation](https://docs.waveshare.com/ESP32-S3-RLCD-4.2?utm_source=chatgpt.com)
@@ -155,6 +185,9 @@ The seconds should not normally be displayed.
 
 The display should update once per minute.
 
+24-hour and 12-hour formats are both selectable in the setup page; 24 hour is
+the default.
+
 ### Date
 
 The date is positioned directly underneath the time.
@@ -165,6 +198,9 @@ Example:
      MONDAY 24 AUGUST
 ```
 
+The arrangement is configurable — see
+[Time and date format](#time-and-date-format).
+
 ### Indoor Temperature
 
 The indoor temperature comes from the onboard SHTC3 sensor.
@@ -203,7 +239,11 @@ The display should avoid unnecessary full-screen refreshes.
 
 ## Network Behaviour
 
-On startup:
+On startup, with no Wi-Fi configured, the display raises its own access point
+and shows the setup screen instead of the clock. See
+[Configuration](#configuration).
+
+Otherwise, on startup:
 
 1. Initialise the display.
 2. Initialise the RTC.
@@ -226,6 +266,11 @@ If Wi-Fi is unavailable:
 * Continue displaying the indoor temperature.
 * Display the last known outdoor temperature.
 * Indicate that the outdoor reading is stale if required.
+* Retry the connection in the background.
+
+Losing Wi-Fi does not return the display to setup mode. A working clock on
+screen matters more than an opportunity to reconfigure, so the setup access
+point appears only when no network is stored or when KEY is held at power-up.
 
 ## Time Synchronisation
 
@@ -244,7 +289,11 @@ Format:
 24 hour
 ```
 
-The RTC then maintains the time locally when Wi-Fi is unavailable.
+The NTP server and timezone are both set in the setup page; the values above
+are the defaults.
+
+The RTC then maintains the time locally when Wi-Fi is unavailable. It stores
+UTC, so a daylight saving change needs no intervention.
 
 ## Temperature Display
 
@@ -327,38 +376,173 @@ A suggested project structure:
 │   ├── network.cpp
 │   ├── network.h
 │   ├── outdoor.cpp
-│   └── outdoor.h
+│   ├── outdoor.h
+│   ├── settings.cpp
+│   ├── settings.h
+│   ├── portal.cpp
+│   ├── portal.h
+│   ├── battery.cpp
+│   ├── battery.h
+│   ├── ambient.cpp
+│   ├── ambient.h
+│   ├── power.cpp
+│   └── power.h
 │
 ├── include/
 │   ├── config.h
-│   └── secrets.h
+│   └── i2c_bus.h
 │
-├── lib/
-│
-└── data/
-    └── fonts/
+└── lib/
+    └── ST7305_U8g2/
 ```
 
+`settings` holds the runtime configuration in flash; `portal` serves the setup
+web page. Fonts are compiled in from the U8g2 library, so no font files are
+shipped.
+
 ## Configuration
 
-User-specific settings should be kept separate from the main application code.
+All user-specific settings are entered on the display itself through a setup
+web page. Nothing needs to be edited or recompiled, and no credentials are
+stored in the repository.
 
-Example:
+### First-time setup
+
+1. Power on the display. With no Wi-Fi stored it shows the setup screen and
+   raises its own Wi-Fi network.
+2. On a phone or laptop, join:
+
+   ```text
+   Network:   TempDisplay-Setup
+   Password:  temp1234
+   ```
+
+3. The setup page should open by itself. If it does not, browse to
+   `http://192.168.4.1`.
+4. Choose your Wi-Fi network from the list and enter its password.
+5. Enter the outdoor temperature source URL and, for a JSON response, the
+   field holding the value.
+6. Check the timezone, then press **Save and restart**.
+
+The display reboots, joins your network, sets its clock from NTP and begins
+showing data.
+
+### Changing settings later
+
+Once the display is on your network the same page is available at
+`http://temp-display.local` or at the display's IP address. This is the place
+to change the outdoor source without touching the Wi-Fi settings.
+
+To return to the setup access point — after moving house, or changing the
+Wi-Fi password — hold the **KEY** button while powering the display on.
+**Erase all settings** on the page does the same thing.
+
+### Settings
+
+| Setting | Notes |
+| --- | --- |
+| Wi-Fi network and password | Chosen from a scan, or typed in for a hidden network |
+| Outdoor source URL | An HTTP address returning the outdoor temperature |
+| JSON field | The field holding the value, e.g. `temperature` or `main.temp`. Blank if the response is a bare number |
+| Time format | 24 hour (`14:05`) or 12 hour (`2:05 PM`) |
+| Battery indicator | Whether to show the corner icon |
+| Panel low-power mode | Lowers the screen's internal refresh rate |
+| Sleep when dark | Whether to rest in an unlit room |
+| Darkness threshold | In lux, with the live reading shown beside it |
+| Quiet hours | Used only when no light sensor is fitted |
+| Date format | Picked from a list, or given as a `strftime` pattern |
+| Capitals | Whether the date line is upper case |
+| Timezone | Picked from a list, or given as a POSIX TZ string |
+| NTP server | Defaults to `pool.ntp.org` |
 
-```cpp
-#define WIFI_SSID       "YOUR_WIFI"
-#define WIFI_PASSWORD   "YOUR_PASSWORD"
+### Time and date format
 
-#define NTP_SERVER      "pool.ntp.org"
-#define TIMEZONE        "Australia/Hobart"
+The time is shown either as 24 hour or as 12 hour with an AM/PM suffix. In
+12-hour mode the leading zero is dropped, midnight is `12:00 AM` and noon is
+`12:00 PM`.
 
-#define TEMP_UPDATE_MS  60000
-#define DISPLAY_UPDATE_MS 60000
+The date line is chosen from a list of common arrangements, or given directly
+as a `strftime(3)` pattern:
+
+| Pattern | Result |
+| --- | --- |
+| `%A %e %B` | MONDAY 24 AUGUST |
+| `%A %e %B %Y` | MONDAY 24 AUGUST 2026 |
+| `%B %e, %Y` | AUGUST 24, 2026 |
+| `%a %e %b %Y` | MON 24 AUG 2026 |
+| `%d/%m/%Y` | 24/08/2026 |
+| `%Y-%m-%d` | 2026-08-24 |
+
+The useful fields are `%A` day, `%a` short day, `%B` month, `%b` short month,
+`%e` date, `%d` date padded to two digits, `%Y` year and `%y` short year.
+
+The setup page shows a live preview of both lines, rendered by the display
+itself using the same code that draws the panel, so what the preview shows is
+what appears on screen. A long date is automatically set in a smaller size
+rather than being allowed to run off the edge.
+
+Settings are held in the ESP32's own flash and survive both power loss and a
+firmware update.
+
+### Outdoor source format
+
+Either form is accepted:
+
+```text
+14.8
+```
+
+```json
+{ "temperature": 14.8 }
 ```
 
-Credentials should not be committed to the repository.
+The setup page has a **Test this source** button that fetches the URL and
+reports the temperature it read, so a wrong address or field name can be
+corrected before saving.
+
+## Battery and Power Saving
+
+The display is designed to run from the 18650 cell for as long as possible.
 
-A local configuration file should be added to `.gitignore`.
+### While the room is lit
+
+* The CPU runs at 80 MHz rather than 240 MHz.
+* Wi-Fi uses modem sleep between beacons.
+* The panel runs in its own low-power driving mode, which lowers its internal
+  refresh rate — appropriate for content that changes once a minute.
+* The screen is redrawn only when a displayed value actually changes.
+
+### While the room is dark
+
+Because the panel is reflective, an unlit room is the cheapest possible time to
+stop working — the image already on screen stays there with no power at all.
+
+* Wi-Fi is switched off entirely.
+* The ESP32 drops into light sleep, waking every 20 seconds only to sample the
+  light level. The duty cycle is well under 1%.
+* Nothing is redrawn; the last image remains on the panel.
+* On waking, the network reconnects and the outdoor temperature is refetched
+  immediately rather than waiting out the normal interval.
+
+Pressing **KEY** wakes the display at once.
+
+The threshold is set in lux and has hysteresis, so a room hovering near the
+boundary cannot chatter the display in and out of sleep. With no light sensor
+fitted, the quiet hours are used instead — 23:00 to 06:00 by default.
+
+Sleeping is suppressed for a minute after anyone uses the settings page, so the
+Wi-Fi is never dropped mid-configuration, and never happens at all while the
+setup access point is up.
+
+### Battery indicator
+
+The top right corner shows a four-bar battery icon, derived from a lithium-ion
+discharge curve rather than a straight voltage scale. It is deliberately coarse
+— it only has to answer "should I be thinking about charging" — and quantising
+it to quarters also means normal voltage noise causes no screen refresh. Below
+25% the icon shows as an empty outline.
+
+The setup page shows the exact voltage and percentage.
 
 ## Future Features
 
@@ -371,17 +555,16 @@ Potential future additions:
 * Weather icons
 * Indoor/outdoor humidity
 * Wi-Fi signal indicator
-* Battery percentage
-* Automatic screen brightness control
-* Configuration through a web interface
 * OTA firmware updates
 * Custom temperature units
 * Minimum/maximum temperature
 * Historical temperature logging
 * TF card data logging
-* Automatic night mode
 * Multiple display layouts
 
+Screen brightness control is not possible on this board: the panel is
+reflective with no backlight to adjust.
+
 ## Design Goals
 
 The project should follow these principles:

+ 69 - 4
include/config.h

@@ -2,8 +2,10 @@
 
 // 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.
+// Wi-Fi credentials, the outdoor sensor URL, the timezone and the NTP server
+// are runtime settings held in NVS and entered through the setup portal — see
+// src/settings.h and src/portal.h. The values below are compile-time
+// constants and safe to commit.
 
 // ---------------------------------------------------------------- display ---
 // The panel is a 300 x 400 ST7305 reflective LCD. The application runs it
@@ -32,15 +34,73 @@
 #define PCF85063_ADDR      0x51
 #define SHTC3_ADDR         0x70
 
+// BH1750 ambient light sensor, wired to the same bus on the expansion header.
+// Optional: without it the display falls back to quiet hours from the clock.
+// The part answers on 0x23 with ADDR low and 0x5C with ADDR high; both are
+// probed at boot.
+#define BH1750_ADDR_LOW    0x23
+#define BH1750_ADDR_HIGH   0x5C
+
+// --------------------------------------------------------------- battery ---
+// 18650 voltage divider, per the vendor's 03_ADC_Test: ADC1 channel 3 with a
+// 1:3 divider, so the measured millivolts are multiplied by three.
+#define BATTERY_ADC_PIN    4
+#define BATTERY_DIVIDER    3.0f
+#define BATTERY_SAMPLES    16      // oversampling per reading
+#define BATTERY_READ_MS    60000UL
+
+// --------------------------------------------------------------- buttons ---
+// Both onboard buttons are active low. KEY is the application button; BOOT is
+// avoided because holding it at reset also enters the ROM download mode.
+#define KEY_BUTTON_PIN     18
+#define BOOT_BUTTON_PIN    0
+
+// Hold KEY for this long during power-up to force the setup portal.
+#define KEY_FORCE_SETUP_MS 1500
+
 // ------------------------------------------------------------------ time ---
+// Defaults for a freshly erased device; both are editable in the setup portal.
 #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"
+// Clock and date presentation defaults; all three are editable in the setup
+// portal. DATE_FORMAT is a strftime(3) string — "%A %e %B" is "MONDAY 24
+// AUGUST".
+#define CLOCK_24H_DEFAULT      true
+#define DATE_FORMAT            "%A %e %B"
+#define DATE_UPPERCASE_DEFAULT true
+
+// ----------------------------------------------------------------- setup ---
+// Soft-AP the device raises when it has nothing to connect to. The password
+// must be at least 8 characters or the AP comes up open.
+#define SETUP_AP_SSID      "TempDisplay-Setup"
+#define SETUP_AP_PASSWORD  "temp1234"
+#define DEVICE_HOSTNAME    "temp-display"
+
+// ------------------------------------------------------------ power save ---
+// 80 MHz is the lowest the ESP32-S3 can run with Wi-Fi active and leaves the
+// 80 MHz APB clock — and so the display SPI — untouched.
+#define CPU_FREQ_MHZ       80
+
+// Defaults for the power-saving settings, all editable in the setup portal.
+#define SHOW_BATTERY_DEFAULT      true
+#define SLEEP_WHEN_DARK_DEFAULT   true
+#define PANEL_LOW_POWER_DEFAULT   true
+#define DARK_LUX_DEFAULT          5     // below this the room is unreadable
+#define QUIET_START_HOUR_DEFAULT  23    // used only when no sensor is fitted
+#define QUIET_END_HOUR_DEFAULT    6
+
+// Hysteresis, so a flickering or marginal reading cannot chatter the display
+// in and out of sleep. Waking needs a clearly brighter room than sleeping.
+#define DARK_LUX_HYSTERESIS       3.0f
+
+// While asleep in a dark room, wake this often to re-check the light level.
+// Short enough that the clock is at most one minute stale when the lights come
+// on, long enough that the duty cycle is well under 1%.
+#define DARK_RECHECK_MS    20000UL
 
 // -------------------------------------------------------------- intervals ---
 #define WIFI_CONNECT_TIMEOUT_MS   20000UL   // give up on a connect attempt
@@ -59,4 +119,9 @@
 #define TEMP_MIN_VALID     -50.0f
 #define TEMP_MAX_VALID     80.0f
 
+// Optional development conveniences. include/secrets.h is gitignored; when it
+// exists its values seed a freshly erased device so a bench unit does not need
+// to be re-provisioned through the portal after every flash erase.
+#if __has_include("secrets.h")
 #include "secrets.h"
+#endif

+ 14 - 11
include/secrets.h.example

@@ -1,15 +1,18 @@
 #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.
+// OPTIONAL development file. The display is normally configured through its
+// setup portal — see the Setup section of README.md — and does not need this
+// file to build or run.
+//
+// Anything defined here seeds a freshly erased device, which saves
+// re-provisioning a bench unit through the portal after every `pio run -t
+// erase`. Stored settings always win over these values, so changing one has no
+// effect until the device's NVS is erased.
+//
+// Copy to secrets.h and uncomment what you want. secrets.h is gitignored.
 
-#define WIFI_SSID       "YOUR_WIFI"
-#define WIFI_PASSWORD   "YOUR_PASSWORD"
+// #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"
+// #define OUTDOOR_URL       "http://192.168.1.50/temperature"
+// #define OUTDOOR_JSON_KEY  "temperature"

+ 6 - 0
lib/ST7305_U8g2/ST7305_U8g2.cpp

@@ -217,6 +217,12 @@ void ST7305_U8g2::begin(uint8_t tile_buf_height, const u8g2_cb_t *rotation)
   u8g2_SetPowerSave(u, 0);
 }
 
+// LOCAL ADDITION (not upstream) — see the header.
+void ST7305_U8g2::setLowPowerMode(bool low)
+{
+  _cmd(low ? 0x39 : 0x38);
+}
+
 void ST7305_U8g2::fullInit()
 {
   reset();

+ 6 - 0
lib/ST7305_U8g2/ST7305_U8g2.h

@@ -32,6 +32,12 @@ public:
   void reset();
   void fullInit();
 
+  // LOCAL ADDITION (not upstream): selects the ST7305 low-power driving mode,
+  // command 0x39, instead of the high-power mode 0x38 that fullInit() sets.
+  // Low power drops the panel's internal refresh rate, which suits a clock
+  // that changes once a minute. Safe to call at any time.
+  void setLowPowerMode(bool low);
+
   U8G2 *getU8g2()
   {
     return &u8g2_wrapper;

+ 149 - 0
src/ambient.cpp

@@ -0,0 +1,149 @@
+#include "ambient.h"
+
+#include <time.h>
+
+#include "config.h"
+#include "i2c_bus.h"
+#include "settings.h"
+
+namespace {
+
+// BH1750 opcodes. One-shot is used rather than continuous mode so the sensor
+// powers itself down between readings.
+constexpr uint8_t CMD_POWER_DOWN      = 0x00;
+constexpr uint8_t CMD_POWER_ON        = 0x01;
+constexpr uint8_t CMD_RESET           = 0x07;
+constexpr uint8_t CMD_ONE_SHOT_HIRES  = 0x20;  // 1 lx resolution, ~120 ms
+
+// Datasheet conversion: counts / 1.2 gives lux at the default sensitivity.
+constexpr float COUNTS_PER_LUX = 1.2f;
+
+uint8_t g_addr = 0;        // 0 means no sensor found
+float g_lastLux = -1.0f;
+bool g_darkLatched = false;  // remembers which side of the hysteresis we are on
+
+bool writeCommand(uint8_t addr, uint8_t cmd)
+{
+  Wire.beginTransmission(addr);
+  Wire.write(cmd);
+  return Wire.endTransmission() == 0;
+}
+
+// Quiet-hours fallback. Handles a window that crosses midnight, which the
+// default 23:00-06:00 does.
+bool inQuietHours()
+{
+  const settings::Config &cfg = settings::get();
+
+  const time_t now = time(nullptr);
+  struct tm local;
+  localtime_r(&now, &local);
+  if (local.tm_year + 1900 < 2024) {
+    return false;  // clock not set yet: do not sleep on a guess
+  }
+
+  const int hour = local.tm_hour;
+  const int start = cfg.quietStartHour;
+  const int end = cfg.quietEndHour;
+
+  if (start == end) {
+    return false;  // an empty window, not a 24-hour one
+  }
+  if (start < end) {
+    return hour >= start && hour < end;
+  }
+  return hour >= start || hour < end;  // wraps past midnight
+}
+
+}  // namespace
+
+namespace ambient {
+
+void begin()
+{
+  if (!i2cBusBegin()) {
+    return;
+  }
+
+  const uint8_t candidates[] = {BH1750_ADDR_LOW, BH1750_ADDR_HIGH};
+  for (uint8_t addr : candidates) {
+    if (!i2cDevicePresent(addr)) {
+      continue;
+    }
+    // An address ACK alone is weak evidence on a shared bus; require the part
+    // to accept a power-on and return a plausible first reading.
+    if (!writeCommand(addr, CMD_POWER_ON)) {
+      continue;
+    }
+    writeCommand(addr, CMD_RESET);
+
+    g_addr = addr;
+    float lux = 0.0f;
+    if (readLux(lux)) {
+      Serial.printf("[ambient] BH1750 at 0x%02X, %.1f lux\n", addr, lux);
+      return;
+    }
+    g_addr = 0;
+  }
+
+  Serial.println("[ambient] no BH1750; using quiet hours from the clock");
+}
+
+bool hasSensor()
+{
+  return g_addr != 0;
+}
+
+bool readLux(float &lux)
+{
+  if (g_addr == 0) {
+    return false;
+  }
+
+  if (!writeCommand(g_addr, CMD_ONE_SHOT_HIRES)) {
+    return false;
+  }
+  delay(180);  // datasheet maximum for the high-resolution conversion
+
+  if (Wire.requestFrom((int)g_addr, 2) != 2) {
+    return false;
+  }
+  const uint8_t hi = Wire.read();
+  const uint8_t lo = Wire.read();
+
+  const float value = (float)((uint16_t)hi << 8 | lo) / COUNTS_PER_LUX;
+  if (value < 0.0f || value > 200000.0f) {
+    return false;
+  }
+
+  g_lastLux = value;
+  lux = value;
+  return true;
+}
+
+float lastLux()
+{
+  return g_lastLux;
+}
+
+bool isDark()
+{
+  const settings::Config &cfg = settings::get();
+
+  if (g_addr != 0) {
+    float lux = 0.0f;
+    if (!readLux(lux)) {
+      return false;  // sensor gone quiet: stay awake rather than guess
+    }
+    const float threshold = (float)cfg.darkLux;
+    // Going to sleep needs lux below the threshold; waking needs it clearly
+    // above, so a level sitting right on the boundary cannot oscillate.
+    g_darkLatched = g_darkLatched ? lux < threshold + DARK_LUX_HYSTERESIS
+                                  : lux < threshold;
+    return g_darkLatched;
+  }
+
+  return inQuietHours();
+}
+
+}  // namespace ambient

+ 34 - 0
src/ambient.h

@@ -0,0 +1,34 @@
+#pragma once
+
+#include <Arduino.h>
+
+// Ambient light, used to decide whether anyone can read the screen.
+//
+// The panel is reflective and has no backlight, so in a dark room it shows
+// nothing regardless of what the firmware draws. That makes darkness the
+// cheapest possible time to stop working: see power.h.
+//
+// A BH1750 on the I2C header is preferred. With no sensor fitted the same
+// decision is made from the clock, using the configured quiet hours.
+namespace ambient {
+
+// Probes for a BH1750. Safe to call with no sensor present.
+void begin();
+
+// True if a BH1750 answered at boot.
+bool hasSensor();
+
+// Takes a one-shot reading. Returns false if there is no sensor or the read
+// failed; lux is left untouched.
+bool readLux(float &lux);
+
+// Last successful reading, or a negative value if there has never been one.
+float lastLux();
+
+// Whether the room is currently too dark to read the display. Uses the sensor
+// when fitted — with hysteresis, so a marginal level cannot chatter — and
+// otherwise the configured quiet hours. Returns false if it cannot tell, since
+// staying awake is the safe failure.
+bool isDark();
+
+}  // namespace ambient

+ 107 - 0
src/battery.cpp

@@ -0,0 +1,107 @@
+#include "battery.h"
+
+#include "config.h"
+
+namespace {
+
+bool g_have = false;
+float g_volts = 0.0f;
+uint8_t g_percent = 0;
+uint32_t g_lastReadMs = 0;
+
+// Open-circuit discharge curve for a single lithium-ion cell. A linear
+// voltage-to-percentage map badly overstates the middle of the discharge,
+// which is where the cell spends most of its life, so interpolate a table
+// instead. Must stay ordered from full to empty.
+struct CurvePoint {
+  float volts;
+  uint8_t percent;
+};
+
+constexpr CurvePoint CURVE[] = {
+  {4.20f, 100}, {4.15f, 95}, {4.10f, 90}, {4.05f, 85}, {4.00f, 80},
+  {3.95f, 72},  {3.90f, 65}, {3.85f, 58}, {3.80f, 50}, {3.75f, 42},
+  {3.70f, 35},  {3.65f, 28}, {3.60f, 20}, {3.50f, 12}, {3.40f, 7},
+  {3.30f, 3},   {3.00f, 0},
+};
+
+uint8_t voltsToPercent(float v)
+{
+  constexpr size_t COUNT = sizeof(CURVE) / sizeof(CURVE[0]);
+
+  if (v >= CURVE[0].volts) {
+    return 100;
+  }
+  if (v <= CURVE[COUNT - 1].volts) {
+    return 0;
+  }
+
+  for (size_t i = 1; i < COUNT; i++) {
+    if (v >= CURVE[i].volts) {
+      const CurvePoint &hi = CURVE[i - 1];
+      const CurvePoint &lo = CURVE[i];
+      const float span = hi.volts - lo.volts;
+      const float frac = span > 0.0f ? (v - lo.volts) / span : 0.0f;
+      return (uint8_t)(lo.percent + frac * (hi.percent - lo.percent) + 0.5f);
+    }
+  }
+  return 0;
+}
+
+}  // namespace
+
+namespace battery {
+
+void begin()
+{
+  // 12 dB attenuation to cover the full divider output, matching the vendor's
+  // ADC example. analogReadMilliVolts applies the chip's factory calibration.
+  analogSetPinAttenuation(BATTERY_ADC_PIN, ADC_11db);
+  sample();
+}
+
+void sample()
+{
+  uint32_t total = 0;
+  for (uint8_t i = 0; i < BATTERY_SAMPLES; i++) {
+    total += analogReadMilliVolts(BATTERY_ADC_PIN);
+  }
+
+  const float v = (float)total / BATTERY_SAMPLES / 1000.0f * BATTERY_DIVIDER;
+  g_lastReadMs = millis();
+
+  // Below about 2.5 V there is no cell on the divider — the board is running
+  // from USB alone — and a percentage would be meaningless.
+  if (v < 2.5f || v > 5.0f) {
+    g_have = false;
+    return;
+  }
+
+  g_volts = v;
+  g_percent = voltsToPercent(v);
+  g_have = true;
+}
+
+void loop()
+{
+  if (millis() - g_lastReadMs >= BATTERY_READ_MS) {
+    sample();
+  }
+}
+
+bool hasReading()
+{
+  return g_have;
+}
+
+float volts()
+{
+  return g_volts;
+}
+
+uint8_t percent()
+{
+  return g_percent;
+}
+
+}  // namespace battery

+ 30 - 0
src/battery.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <Arduino.h>
+
+// 18650 battery monitoring, read through the onboard divider on ADC1_CH3.
+//
+// The divider sits on the battery node, downstream of the charger, so there is
+// no way to tell "charging" from "full" here — only the cell voltage is
+// reported. See CLAUDE.md.
+namespace battery {
+
+void begin();
+
+// Samples the divider if BATTERY_READ_MS has elapsed. Call from loop().
+void loop();
+
+// Forces a reading now.
+void sample();
+
+// True once a plausible reading has been taken. False on a board with no cell
+// fitted and no sensible voltage present.
+bool hasReading();
+
+// Cell voltage in volts.
+float volts();
+
+// Remaining charge, 0-100, from a lithium-ion discharge curve.
+uint8_t percent();
+
+}  // namespace battery

+ 210 - 16
src/display.cpp

@@ -13,16 +13,41 @@ constexpr int FRAME_Y = 2;
 constexpr int FRAME_W = DISPLAY_WIDTH - 4;
 constexpr int FRAME_H = DISPLAY_HEIGHT - 4;
 
+// Battery indicator, top right inside the frame. It sits above the ascent of
+// the time font, so it cannot collide with the digits.
+constexpr int BATT_W = 30;
+constexpr int BATT_H = 15;
+constexpr int BATT_X = DISPLAY_WIDTH - FRAME_X - 10 - BATT_W;
+constexpr int BATT_Y = 10;
+constexpr int BATT_TERMINAL_W = 3;
+constexpr int BATT_TERMINAL_H = 7;
+
 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
+#define FONT_TIME        u8g2_font_logisoso92_tn
+#define FONT_TIME_SUFFIX u8g2_font_helvB24_tr   // AM/PM: the time font has no letters
+#define FONT_LABEL       u8g2_font_helvB14_tr
+#define FONT_TEMP        u8g2_font_fub35_tf   // "_tf" carries the ° glyph
+#define FONT_MESSAGE      u8g2_font_fub20_tr
+#define FONT_SETUP_TITLE  u8g2_font_helvB18_tr
+#define FONT_SETUP_BODY   u8g2_font_helvR14_tr
+#define FONT_SETUP_VALUE  u8g2_font_helvB18_tr
+
+// Gap between the time and its AM/PM suffix.
+constexpr int SUFFIX_GAP = 10;
+
+// The date line is drawn in the largest of these that fits across the panel,
+// so a long custom format degrades instead of running off the edge.
+const uint8_t *const DATE_FONTS[] = {
+  u8g2_font_fub20_tr,
+  u8g2_font_fub17_tr,
+  u8g2_font_fub14_tr,
+  u8g2_font_helvB12_tr,
+};
 
 ST7305_U8g2 g_lcd(RLCD_SCK_PIN, RLCD_MOSI_PIN, RLCD_DC_PIN, RLCD_CS_PIN, RLCD_RST_PIN);
 U8G2 *g_u8g2 = nullptr;
@@ -32,8 +57,11 @@ U8G2 *g_u8g2 = nullptr;
 // reading moves by less than the displayed precision.
 struct Rendered {
   bool valid = false;
+  bool battery = false;
+  uint8_t batteryBars = 0;   // 0-4; quantised so small drifts cause no refresh
   char time[8] = "";
-  char date[40] = "";
+  char suffix[4] = "";   // "AM"/"PM", empty in 24-hour mode
+  char date[48] = "";
   char indoor[16] = "";
   char outdoor[16] = "";
   bool outdoorStale = false;
@@ -47,13 +75,71 @@ void drawCentred(int centreX, int baseline, const char *text)
   g_u8g2->drawUTF8(centreX - w / 2, baseline, text);
 }
 
-void formatTime(const display::Model &m, char *out, size_t len)
+// Draws the time and, in 12-hour mode, its AM/PM suffix, centring the pair as
+// one block so the digits do not shift when the suffix changes width.
+void drawTimeBlock(const Rendered &r)
 {
-  if (m.timeValid) {
-    snprintf(out, len, "%02u:%02u", (unsigned)m.hour, (unsigned)m.minute);
-  } else {
-    snprintf(out, len, "--:--");
+  g_u8g2->setFont(FONT_TIME);
+  const int timeWidth = g_u8g2->getUTF8Width(r.time);
+
+  int suffixWidth = 0;
+  if (r.suffix[0] != '\0') {
+    g_u8g2->setFont(FONT_TIME_SUFFIX);
+    suffixWidth = SUFFIX_GAP + g_u8g2->getUTF8Width(r.suffix);
+  }
+
+  const int x = (DISPLAY_WIDTH - (timeWidth + suffixWidth)) / 2;
+
+  g_u8g2->setFont(FONT_TIME);
+  g_u8g2->drawUTF8(x, TIME_BASELINE, r.time);
+
+  if (r.suffix[0] != '\0') {
+    g_u8g2->setFont(FONT_TIME_SUFFIX);
+    g_u8g2->drawUTF8(x + timeWidth + SUFFIX_GAP, TIME_BASELINE, r.suffix);
+  }
+}
+
+// Outline, terminal, and a fill of whole bars. Bars rather than a proportional
+// fill because the panel only refreshes when the drawn result changes, and a
+// smooth fill would redraw the screen on every millivolt of noise.
+void drawBattery(uint8_t bars)
+{
+  g_u8g2->drawFrame(BATT_X, BATT_Y, BATT_W, BATT_H);
+  g_u8g2->drawBox(BATT_X + BATT_W, BATT_Y + (BATT_H - BATT_TERMINAL_H) / 2,
+                  BATT_TERMINAL_W, BATT_TERMINAL_H);
+
+  if (bars == 0) {
+    return;  // empty outline: the warning is the absence of fill
+  }
+
+  // One pixel of clearance inside the outline, then four bars with gaps.
+  constexpr int innerX = BATT_X + 2;
+  constexpr int innerY = BATT_Y + 2;
+  constexpr int innerH = BATT_H - 4;
+  constexpr int barPitch = (BATT_W - 4) / 4;
+  constexpr int barW = barPitch - 1;
+
+  for (uint8_t i = 0; i < bars && i < 4; i++) {
+    g_u8g2->drawBox(innerX + i * barPitch, innerY, barW, innerH);
+  }
+}
+
+// Picks the largest date font that fits, then draws centred.
+void drawDateLine(const char *text)
+{
+  const int available = FRAME_W - 8;
+  size_t chosen = sizeof(DATE_FONTS) / sizeof(DATE_FONTS[0]) - 1;
+
+  for (size_t i = 0; i < sizeof(DATE_FONTS) / sizeof(DATE_FONTS[0]); i++) {
+    g_u8g2->setFont(DATE_FONTS[i]);
+    if (g_u8g2->getUTF8Width(text) <= available) {
+      chosen = i;
+      break;
+    }
   }
+
+  g_u8g2->setFont(DATE_FONTS[chosen]);
+  drawCentred(DISPLAY_WIDTH / 2, DATE_BASELINE, text);
 }
 
 void formatTemp(bool valid, float c, char *out, size_t len)
@@ -75,11 +161,12 @@ void render(const Rendered &r)
   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);
+  if (r.battery) {
+    drawBattery(r.batteryBars);
+  }
 
-  g_u8g2->setFont(FONT_DATE);
-  drawCentred(DISPLAY_WIDTH / 2, DATE_BASELINE, r.date);
+  drawTimeBlock(r);
+  drawDateLine(r.date);
 
   const int leftCentre = LAYOUT_MID_X / 2;
   const int rightCentre = LAYOUT_MID_X + LAYOUT_MID_X / 2;
@@ -129,12 +216,50 @@ void showBootMessage(const char *message)
   }
   g_u8g2->clearBuffer();
   g_u8g2->setDrawColor(1);
-  g_u8g2->setFont(FONT_DATE);
+  g_u8g2->setFont(FONT_MESSAGE);
   drawCentred(DISPLAY_WIDTH / 2, DISPLAY_HEIGHT / 2, message);
   g_u8g2->sendBuffer();
   g_onScreen.valid = false;
 }
 
+void showSetupScreen(const char *apSsid, const char *apPassword, const char *url)
+{
+  if (!g_u8g2) {
+    return;
+  }
+
+  g_u8g2->clearBuffer();
+  g_u8g2->setDrawColor(1);
+  g_u8g2->drawFrame(FRAME_X, FRAME_Y, FRAME_W, FRAME_H);
+
+  const int centre = DISPLAY_WIDTH / 2;
+
+  g_u8g2->setFont(FONT_SETUP_TITLE);
+  drawCentred(centre, 38, "WI-FI SETUP");
+  g_u8g2->drawHLine(FRAME_X + 40, 50, FRAME_W - 80);
+
+  g_u8g2->setFont(FONT_SETUP_BODY);
+  drawCentred(centre, 84, "1.  Join this Wi-Fi network");
+  g_u8g2->setFont(FONT_SETUP_VALUE);
+  drawCentred(centre, 112, apSsid);
+  g_u8g2->setFont(FONT_SETUP_BODY);
+
+  char line[64];
+  snprintf(line, sizeof(line), "password:  %s", apPassword);
+  drawCentred(centre, 136, line);
+
+  drawCentred(centre, 180, "2.  Open this address");
+  g_u8g2->setFont(FONT_SETUP_VALUE);
+  drawCentred(centre, 208, url);
+
+  g_u8g2->setFont(FONT_SETUP_BODY);
+  drawCentred(centre, 252, "The page should open by itself.");
+  drawCentred(centre, 274, "Hold KEY at power-up to return here.");
+
+  g_u8g2->sendBuffer();
+  g_onScreen.valid = false;  // force a full redraw when normal service resumes
+}
+
 bool update(const Model &m)
 {
   if (!g_u8g2) {
@@ -143,14 +268,32 @@ bool update(const Model &m)
 
   Rendered next;
   next.valid = true;
-  formatTime(m, next.time, sizeof(next.time));
+
+  // 0-24% empty, then a bar per quarter. Deliberately coarse: the indicator
+  // only has to answer "should I be thinking about charging".
+  next.battery = m.batteryValid;
+  next.batteryBars = (uint8_t)(m.batteryPercent / 25);
+  if (next.batteryBars > 4) {
+    next.batteryBars = 4;
+  }
+
+  if (m.timeValid) {
+    formatTimeText(m.hour, m.minute, m.twelveHour, next.time, sizeof(next.time),
+                   next.suffix, sizeof(next.suffix));
+  } else {
+    snprintf(next.time, sizeof(next.time), "--:--");
+    next.suffix[0] = '\0';
+  }
   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
+      && next.battery == g_onScreen.battery
+      && next.batteryBars == g_onScreen.batteryBars
       && strcmp(next.time, g_onScreen.time) == 0
+      && strcmp(next.suffix, g_onScreen.suffix) == 0
       && strcmp(next.date, g_onScreen.date) == 0
       && strcmp(next.indoor, g_onScreen.indoor) == 0
       && strcmp(next.outdoor, g_onScreen.outdoor) == 0
@@ -169,4 +312,55 @@ void invalidate()
   g_onScreen.valid = false;
 }
 
+void setPanelLowPower(bool low)
+{
+  g_lcd.setLowPowerMode(low);
+}
+
+void formatTimeText(int hour, int minute, bool twelveHour,
+                    char *out, size_t outLen, char *suffix, size_t suffixLen)
+{
+  if (!twelveHour) {
+    snprintf(out, outLen, "%02d:%02d", hour, minute);
+    if (suffix && suffixLen > 0) {
+      suffix[0] = '\0';
+    }
+    return;
+  }
+
+  // Midnight and noon are 12, not 0, and the hour carries no leading zero.
+  int h = hour % 12;
+  if (h == 0) {
+    h = 12;
+  }
+  snprintf(out, outLen, "%d:%02d", h, minute);
+  if (suffix && suffixLen > 0) {
+    snprintf(suffix, suffixLen, "%s", hour < 12 ? "AM" : "PM");
+  }
+}
+
+void formatDateLine(const struct tm &local, const char *format, bool uppercase,
+                    char *out, size_t outLen)
+{
+  if (outLen == 0) {
+    return;
+  }
+
+  char buf[96];
+  if (strftime(buf, sizeof(buf), format, &local) == 0) {
+    // An unusable format string would otherwise leave the line blank.
+    strftime(buf, sizeof(buf), DATE_FORMAT, &local);
+  }
+
+  // "%e" pads single-digit days with a space; collapse the resulting run.
+  size_t w = 0;
+  for (size_t i = 0; buf[i] != '\0' && w + 1 < outLen; i++) {
+    if (buf[i] == ' ' && w > 0 && out[w - 1] == ' ') {
+      continue;
+    }
+    out[w++] = uppercase ? (char)toupper((unsigned char)buf[i]) : buf[i];
+  }
+  out[w] = '\0';
+}
+
 }  // namespace display

+ 32 - 2
src/display.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <Arduino.h>
+#include <time.h>
 
 // Display engine for the 400 x 300 (rotated) ST7305 reflective LCD.
 //
@@ -12,9 +13,10 @@ namespace display {
 // flags rather than sentinel values, so the renderer can draw "--.-".
 struct Model {
   bool timeValid = false;
-  uint8_t hour = 0;          // 24-hour
+  uint8_t hour = 0;          // always 0-23; twelveHour only affects rendering
   uint8_t minute = 0;
-  char dateLine[40] = "";    // pre-formatted, e.g. "MONDAY 24 AUGUST"
+  bool twelveHour = false;   // draw as 1-12 with an AM/PM suffix
+  char dateLine[48] = "";    // pre-formatted, e.g. "MONDAY 24 AUGUST"
 
   bool indoorValid = false;
   float indoorC = 0.0f;
@@ -22,6 +24,9 @@ struct Model {
   bool outdoorValid = false;
   float outdoorC = 0.0f;
   bool outdoorStale = false;  // last known value, source not reachable
+
+  bool batteryValid = false;  // false hides the indicator entirely
+  uint8_t batteryPercent = 0;
 };
 
 bool begin();
@@ -29,6 +34,11 @@ bool begin();
 // Shows a one-line boot message before real data is available.
 void showBootMessage(const char *message);
 
+// Full-screen joining instructions, shown for as long as the setup portal is
+// running. This is the only user interface the device has before it is on a
+// network, so it carries the AP name, its password and the portal address.
+void showSetupScreen(const char *apSsid, const char *apPassword, const char *url);
+
 // 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);
@@ -36,4 +46,24 @@ bool update(const Model &m);
 // Forces the next update() to refresh regardless of change.
 void invalidate();
 
+// Switches the panel between its low-power and high-power driving modes. The
+// low-power mode drops the panel's internal refresh rate, which suits content
+// that changes once a minute. See CLAUDE.md.
+void setPanelLowPower(bool low);
+
+// --------------------------------------------------------------------------
+// Formatting, shared with the setup portal so its preview cannot disagree
+// with what the panel actually draws.
+
+// Renders hour:minute. In 12-hour mode the hour becomes 1-12 with no leading
+// zero and suffix receives "AM" or "PM"; in 24-hour mode suffix is emptied.
+// hour is always 0-23.
+void formatTimeText(int hour, int minute, bool twelveHour,
+                    char *out, size_t outLen, char *suffix, size_t suffixLen);
+
+// Applies a strftime(3) format to local, optionally uppercasing the result.
+// Runs of spaces are collapsed, since "%e" pads single-digit days.
+void formatDateLine(const struct tm &local, const char *format, bool uppercase,
+                    char *out, size_t outLen);
+
 }  // namespace display

+ 75 - 13
src/main.cpp

@@ -8,11 +8,16 @@
 #include <Arduino.h>
 #include <time.h>
 
+#include "ambient.h"
+#include "battery.h"
 #include "config.h"
 #include "display.h"
 #include "network.h"
 #include "outdoor.h"
+#include "portal.h"
+#include "power.h"
 #include "rtc.h"
+#include "settings.h"
 #include "temperature.h"
 
 namespace {
@@ -21,6 +26,7 @@ bool g_indoorValid = false;
 float g_indoorC = 0.0f;
 uint32_t g_lastIndoorMs = 0;
 uint32_t g_lastDisplayTickMs = 0;
+bool g_setupMode = false;
 
 void readIndoor()
 {
@@ -39,21 +45,24 @@ void readIndoor()
   g_lastIndoorMs = millis();
 }
 
-// "MONDAY 24 AUGUST" — strftime output, uppercased.
-void formatDateLine(const struct tm &local, char *out, size_t len)
+// True if KEY is held down for KEY_FORCE_SETUP_MS at power-up. This is the
+// only way back into the setup portal once Wi-Fi has been stored.
+bool keyHeldAtBoot()
 {
-  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;
+  pinMode(KEY_BUTTON_PIN, INPUT_PULLUP);
+  if (digitalRead(KEY_BUTTON_PIN) != LOW) {
+    return false;
+  }
+
+  Serial.println("[boot] KEY down, hold to enter setup");
+  const uint32_t deadline = millis() + KEY_FORCE_SETUP_MS;
+  while (millis() < deadline) {
+    if (digitalRead(KEY_BUTTON_PIN) != LOW) {
+      return false;  // released early: treat as a normal boot
     }
-    out[w++] = (char)toupper((unsigned char)buf[i]);
+    delay(20);
   }
-  out[w] = '\0';
+  return true;
 }
 
 display::Model buildModel()
@@ -66,11 +75,15 @@ display::Model buildModel()
   struct tm local;
   localtime_r(&t, &local);
 
+  const settings::Config &cfg = settings::get();
+
   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.twelveHour = !cfg.clock24h;
+    display::formatDateLine(local, cfg.dateFormat, cfg.dateUppercase,
+                            m.dateLine, sizeof(m.dateLine));
   }
 
   m.indoorValid = g_indoorValid;
@@ -80,30 +93,62 @@ display::Model buildModel()
   m.outdoorC = outdoor::lastCelsius();
   m.outdoorStale = outdoor::isStale();
 
+  m.batteryValid = cfg.showBattery && battery::hasReading();
+  m.batteryPercent = battery::percent();
+
   return m;
 }
 
+void enterSetupMode()
+{
+  g_setupMode = true;
+  portal::startAccessPoint();
+
+  char url[32];
+  snprintf(url, sizeof(url), "http://%s", portal::apIp().toString().c_str());
+  display::showSetupScreen(portal::apSsid(), portal::apPassword(), url);
+}
+
 }  // namespace
 
 void setup()
 {
+  // Before Serial, so the UART dividers are set up for the final clock.
+  power::begin();
+
   Serial.begin(115200);
   delay(200);
   Serial.println("\n[boot] ESP32-S3-RLCD-4.2 temperature display");
 
+  const bool forceSetup = keyHeldAtBoot();
+
   if (!display::begin()) {
     Serial.println("[boot] display init failed");
   }
   display::showBootMessage("Starting...");
 
+  settings::begin();
+  power::applyPanelMode();
+
   rtc::begin();
   rtc::syncSystemClockFromRtc();
 
   temperature::begin();
   readIndoor();
 
+  battery::begin();
+  ambient::begin();
   outdoor::begin();
 
+  // Without stored credentials there is nothing to connect to and no way for
+  // the user to supply them except the portal, so it takes over the screen.
+  if (forceSetup || !settings::hasWifi()) {
+    Serial.println(forceSetup ? "[boot] setup forced by KEY"
+                              : "[boot] no Wi-Fi stored, entering setup");
+    enterSetupMode();
+    return;
+  }
+
   // Draw everything we already know before the network attempt, so the screen
   // is useful immediately.
   display::update(buildModel());
@@ -113,8 +158,25 @@ void setup()
 
 void loop()
 {
+  portal::loop();
+
+  if (g_setupMode) {
+    // The setup screen is static and the clock is almost certainly wrong;
+    // serving the portal promptly is all that matters here.
+    delay(5);
+    return;
+  }
+
+  // Nothing on a reflective panel is readable in the dark, so this is where
+  // most of the battery is saved. Returns only once the room is lit again.
+  if (power::sleepWhileDark()) {
+    readIndoor();
+    battery::sample();
+  }
+
   network::loop();
   outdoor::loop();
+  battery::loop();
 
   const uint32_t nowMs = millis();
 

+ 62 - 10
src/network.cpp

@@ -4,7 +4,9 @@
 #include <time.h>
 
 #include "config.h"
+#include "portal.h"
 #include "rtc.h"
+#include "settings.h"
 
 namespace {
 
@@ -12,12 +14,15 @@ bool g_synced = false;
 uint32_t g_lastAttemptMs = 0;
 uint32_t g_lastSyncMs = 0;
 bool g_connecting = false;
+bool g_portalStarted = false;
+bool g_suspended = false;
 
 void startConnect()
 {
-  Serial.printf("[net] connecting to %s\n", WIFI_SSID);
+  const settings::Config &cfg = settings::get();
+  Serial.printf("[net] connecting to %s\n", cfg.ssid);
   WiFi.disconnect();
-  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
+  WiFi.begin(cfg.ssid, cfg.password);
   g_lastAttemptMs = millis();
   g_connecting = true;
 }
@@ -28,21 +33,30 @@ namespace network {
 
 void begin()
 {
+  // The timezone is applied to the system clock regardless of connectivity, so
+  // the RTC-derived time renders correctly offline.
+  setenv("TZ", settings::get().timezone, 1);
+  tzset();
+
+  if (!settings::hasWifi()) {
+    Serial.println("[net] no Wi-Fi configured");
+    return;  // main() has already raised the setup portal
+  }
+
   WiFi.mode(WIFI_STA);
-  WiFi.setHostname(WIFI_HOSTNAME);
+  WiFi.setHostname(DEVICE_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()
 {
+  if (!settings::hasWifi() || g_suspended) {
+    return;
+  }
+
   const uint32_t nowMs = millis();
 
   if (WiFi.status() == WL_CONNECTED) {
@@ -51,13 +65,21 @@ void loop()
       Serial.printf("[net] connected, ip=%s\n", WiFi.localIP().toString().c_str());
       syncTimeNow();
     }
+    if (!g_portalStarted) {
+      // Serve the settings page on the LAN so the outdoor URL and timezone can
+      // be changed without re-provisioning.
+      portal::startStation();
+      g_portalStarted = true;
+    }
     if (g_synced && nowMs - g_lastSyncMs >= NTP_RESYNC_MS) {
       syncTimeNow();
     }
     return;
   }
 
-  // Not connected: retry, but never more often than WIFI_RETRY_MS.
+  // Not connected: retry, but never more often than WIFI_RETRY_MS. The device
+  // deliberately does not fall back to the setup AP here — the clock and the
+  // indoor reading must stay on screen through a router reboot.
   const uint32_t since = nowMs - g_lastAttemptMs;
   if (g_connecting && since < WIFI_CONNECT_TIMEOUT_MS) {
     return;
@@ -84,7 +106,8 @@ bool syncTimeNow()
     return false;
   }
 
-  configTzTime(TIMEZONE_POSIX, NTP_SERVER_1, NTP_SERVER_2);
+  const settings::Config &cfg = settings::get();
+  configTzTime(cfg.timezone, cfg.ntpServer, 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.
@@ -125,4 +148,33 @@ bool hasSyncedTime()
   return g_synced;
 }
 
+void suspend()
+{
+  if (g_suspended || !settings::hasWifi()) {
+    return;
+  }
+  WiFi.disconnect(true);
+  WiFi.mode(WIFI_OFF);
+  g_suspended = true;
+  g_connecting = false;
+}
+
+void resume()
+{
+  if (!g_suspended) {
+    return;
+  }
+  g_suspended = false;
+
+  WiFi.mode(WIFI_STA);
+  WiFi.setHostname(DEVICE_HOSTNAME);
+  WiFi.setAutoReconnect(true);
+  WiFi.setSleep(true);
+
+  // The settings page and mDNS have to be re-announced on the new association.
+  g_portalStarted = false;
+
+  startConnect();
+}
+
 }  // namespace network

+ 7 - 0
src/network.h

@@ -22,4 +22,11 @@ bool syncTimeNow();
 // True once NTP has succeeded at least once since boot.
 bool hasSyncedTime();
 
+// Powers the radio down entirely, for a dark room. loop() does nothing while
+// suspended.
+void suspend();
+
+// Brings the radio back up and reconnects. Harmless if not suspended.
+void resume();
+
 }  // namespace network

+ 60 - 35
src/outdoor.cpp

@@ -5,6 +5,7 @@
 
 #include "config.h"
 #include "network.h"
+#include "settings.h"
 
 namespace {
 
@@ -18,49 +19,40 @@ 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)
+// Walks a dotted path such as "main.temp" through a JSON document.
+JsonVariantConst resolvePath(JsonVariantConst root, const char *path)
 {
-  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;
+  JsonVariantConst node = root;
+  const char *segment = path;
+
+  while (*segment && !node.isNull()) {
+    const char *dot = strchr(segment, '.');
+    if (!dot) {
+      return node[segment];
     }
-    if (doc.is<float>()) {  // a bare JSON number
-      const float t = doc.as<float>();
-      if (inRange(t)) {
-        out = t;
-        return true;
-      }
-      return false;
+    // ArduinoJson keys are NUL-terminated, so copy out this segment.
+    char name[48];
+    const size_t len = (size_t)(dot - segment);
+    if (len == 0 || len >= sizeof(name)) {
+      return JsonVariantConst();
     }
-    return false;
+    memcpy(name, segment, len);
+    name[len] = '\0';
+    node = node[name];
+    segment = dot + 1;
   }
-
-  // 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;
+  return node;
 }
 
 bool poll()
 {
+  const settings::Config &cfg = settings::get();
+
   HTTPClient http;
   http.setTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
   http.setConnectTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
 
-  if (!http.begin(OUTDOOR_URL)) {
+  if (!http.begin(cfg.outdoorUrl)) {
     Serial.println("[outdoor] bad URL");
     return false;
   }
@@ -76,7 +68,7 @@ bool poll()
   http.end();
 
   float t = 0.0f;
-  if (!parseBody(body, t)) {
+  if (!outdoor::parseTemperature(body, cfg.outdoorKey, t)) {
     Serial.printf("[outdoor] unparseable response: %s\n", body.c_str());
     return false;
   }
@@ -92,15 +84,48 @@ bool poll()
 
 namespace outdoor {
 
+bool parseTemperature(const String &body, const char *key, float &out)
+{
+  JsonDocument doc;
+  if (deserializeJson(doc, body) == DeserializationError::Ok) {
+    JsonVariantConst v = (key && key[0] != '\0')
+        ? resolvePath(doc.as<JsonVariantConst>(), key)
+        : doc.as<JsonVariantConst>();
+    if (v.isNull() || !v.is<float>()) {
+      return false;
+    }
+    const float t = v.as<float>();
+    if (!inRange(t)) {
+      return false;
+    }
+    out = t;
+    return true;
+  }
+
+  // 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;
+}
+
 void begin()
 {
-  // Force the first poll as soon as Wi-Fi comes up.
-  g_lastPollMs = (uint32_t)(0 - OUTDOOR_POLL_MS);
+  pollSoon();
+}
+
+void pollSoon()
+{
+  // Backdate the last poll past the interval so the next loop() fires one.
+  g_lastPollMs = millis() - OUTDOOR_POLL_MS;
 }
 
 void loop()
 {
-  if (!network::isConnected()) {
+  if (!network::isConnected() || !settings::hasOutdoorSource()) {
     return;
   }
   const uint32_t nowMs = millis();

+ 12 - 1
src/outdoor.h

@@ -10,7 +10,8 @@ namespace outdoor {
 
 void begin();
 
-// Polls the source when due. Call from loop(); does nothing when offline.
+// Polls the source when due. Call from loop(); does nothing when offline or
+// when no source URL is configured.
 void loop();
 
 // True if a reading has ever been received.
@@ -22,4 +23,14 @@ float lastCelsius();
 // True when the last reading is older than OUTDOOR_STALE_MS.
 bool isStale();
 
+// Makes the next loop() poll immediately, rather than waiting out the
+// interval. Used on waking from a rest, when the retained reading is old.
+void pollSoon();
+
+// Extracts a temperature from a source response. Accepts a bare number
+// ("14.8") or JSON; for JSON, key names the field and may be a dotted path
+// ("main.temp"). An empty key takes the document itself as the number.
+// Exposed so the setup portal can test a URL the user has just typed.
+bool parseTemperature(const String &body, const char *key, float &out);
+
 }  // namespace outdoor

+ 772 - 0
src/portal.cpp

@@ -0,0 +1,772 @@
+#include "portal.h"
+
+#include <ArduinoJson.h>
+#include <DNSServer.h>
+#include <ESPmDNS.h>
+#include <HTTPClient.h>
+#include <WebServer.h>
+#include <WiFi.h>
+
+#include "ambient.h"
+#include "battery.h"
+#include "config.h"
+#include "display.h"
+#include "outdoor.h"
+#include "power.h"
+#include "settings.h"
+
+namespace {
+
+WebServer g_server(80);
+DNSServer g_dns;
+bool g_captive = false;
+bool g_running = false;
+uint32_t g_rebootAtMs = 0;  // non-zero once a save has scheduled a restart
+uint32_t g_lastRequestMs = 0;
+bool g_everServed = false;
+
+// Setup page. Kept to one self-contained document — no CDN, no icon fonts —
+// because in setup mode the phone loading it has no route to the internet.
+const char PAGE_HTML[] PROGMEM = R"HTML(<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Temperature Display Setup</title>
+<style>
+:root{color-scheme:light dark}
+*{box-sizing:border-box}
+body{margin:0;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+background:#f4f4f5;color:#18181b}
+main{max-width:34rem;margin:0 auto;padding:24px 16px 48px}
+h1{font-size:1.4rem;margin:0 0 4px}
+p.sub{margin:0 0 24px;color:#71717a;font-size:.9rem}
+fieldset{border:1px solid #d4d4d8;border-radius:10px;background:#fff;margin:0 0 20px;padding:16px}
+legend{padding:0 6px;font-weight:600;font-size:.85rem;text-transform:uppercase;letter-spacing:.04em;color:#52525b}
+label{display:block;margin:12px 0 4px;font-size:.85rem;font-weight:600}
+label:first-of-type{margin-top:0}
+input,select{width:100%;padding:10px;font-size:1rem;border:1px solid #d4d4d8;border-radius:8px;
+background:#fff;color:inherit}
+input:focus,select:focus{outline:2px solid #2563eb;outline-offset:-1px;border-color:#2563eb}
+small{display:block;margin-top:4px;color:#71717a;font-size:.78rem}
+.row{display:flex;gap:8px}
+.row>*{flex:1}
+button{width:100%;padding:12px;font-size:1rem;font-weight:600;border:0;border-radius:8px;
+background:#2563eb;color:#fff;cursor:pointer}
+button:disabled{opacity:.5;cursor:default}
+button.alt{background:#e4e4e7;color:#18181b;margin-top:8px}
+button.link{background:none;color:#2563eb;width:auto;padding:4px 0;font-size:.85rem;text-decoration:underline}
+#status{display:flex;gap:8px;margin-bottom:4px}
+#status div{flex:1;padding:8px 6px;border:1px solid #d4d4d8;border-radius:8px;
+background:#fafafa;text-align:center}
+#status b{display:block;font-size:1.1rem}
+#status span{font-size:.7rem;font-weight:600;letter-spacing:.06em;
+text-transform:uppercase;color:#71717a}
+#preview{margin-top:14px;padding:14px 10px;border:1px solid #d4d4d8;border-radius:8px;
+background:#fafafa;text-align:center;line-height:1.25}
+#preview .t{font-size:2rem;font-weight:700;letter-spacing:-.01em}
+#preview .d{font-size:1rem;font-weight:700;margin-top:2px}
+#preview .cap{display:block;margin-top:8px;font-size:.7rem;font-weight:600;letter-spacing:.06em;
+text-transform:uppercase;color:#71717a}
+#msg{margin:16px 0 0;padding:12px;border-radius:8px;display:none;font-size:.9rem}
+#msg.ok{display:block;background:#dcfce7;color:#14532d}
+#msg.err{display:block;background:#fee2e2;color:#7f1d1d}
+#msg.busy{display:block;background:#e0e7ff;color:#1e3a8a}
+@media(prefers-color-scheme:dark){
+body{background:#18181b;color:#f4f4f5}
+fieldset{background:#27272a;border-color:#3f3f46}
+input,select{background:#18181b;border-color:#3f3f46}
+legend,small,p.sub{color:#a1a1aa}
+#preview{background:#18181b;border-color:#3f3f46}
+#preview .cap{color:#a1a1aa}
+#status div{background:#18181b;border-color:#3f3f46}
+#status span{color:#a1a1aa}
+button.alt{background:#3f3f46;color:#f4f4f5}
+#msg.ok{background:#14532d;color:#dcfce7}
+#msg.err{background:#7f1d1d;color:#fee2e2}
+#msg.busy{background:#1e3a8a;color:#e0e7ff}
+}
+</style>
+</head>
+<body>
+<main>
+<h1>Temperature Display</h1>
+<p class="sub">Wi-Fi, clock and outdoor temperature settings.</p>
+
+<form id="f">
+<fieldset>
+<legend>Wi-Fi</legend>
+<label for="ssid">Network</label>
+<div class="row">
+<select id="ssidSel"><option value="">Scanning...</option></select>
+</div>
+<button type="button" class="link" id="rescan">Rescan</button>
+<button type="button" class="link" id="manual">Enter name manually</button>
+<input id="ssid" name="ssid" placeholder="Network name" hidden>
+<label for="password">Password</label>
+<input id="password" name="password" type="password" autocomplete="off" placeholder="Leave blank to keep current">
+<small id="pwHint"></small>
+</fieldset>
+
+<fieldset>
+<legend>Outdoor temperature</legend>
+<label for="outdoorUrl">Source URL</label>
+<input id="outdoorUrl" name="outdoorUrl" placeholder="http://192.168.1.50/temperature">
+<small>Returns either a bare number (<code>14.8</code>) or JSON.</small>
+<label for="outdoorKey">JSON field</label>
+<input id="outdoorKey" name="outdoorKey" placeholder="temperature">
+<small>The field holding the value, for a JSON response. Leave blank for a bare number. Use dots for nesting, e.g. <code>main.temp</code>.</small>
+<button type="button" class="alt" id="test">Test this source</button>
+</fieldset>
+
+<fieldset>
+<legend>Clock and date</legend>
+<label for="clockFormat">Time format</label>
+<select id="clockFormat">
+<option value="24">24 hour &mdash; 14:05</option>
+<option value="12">12 hour &mdash; 2:05 PM</option>
+</select>
+
+<label for="datePreset">Date format</label>
+<select id="datePreset">
+<option value="%A %e %B">Monday 24 August</option>
+<option value="%A %e %B %Y">Monday 24 August 2026</option>
+<option value="%A, %e %B">Monday, 24 August</option>
+<option value="%B %e, %Y">August 24, 2026</option>
+<option value="%a %e %b %Y">Mon 24 Aug 2026</option>
+<option value="%e %B %Y">24 August 2026</option>
+<option value="%d/%m/%Y">24/08/2026</option>
+<option value="%m/%d/%Y">08/24/2026</option>
+<option value="%Y-%m-%d">2026-08-24</option>
+<option value="%A">Monday</option>
+<option value="">Custom...</option>
+</select>
+<label for="dateFormat">strftime pattern</label>
+<input id="dateFormat" name="dateFormat">
+<small>%A day, %a short day, %B month, %b short month, %e date, %d date padded, %Y year, %y short year.</small>
+<label><input type="checkbox" id="dateUppercase" style="width:auto;margin-right:8px">Capitals</label>
+<div id="preview">&nbsp;</div>
+
+<label for="tzPreset">Timezone</label>
+<select id="tzPreset">
+<option value="AEST-10AEDT,M10.1.0,M4.1.0/3">Australia/Hobart, Melbourne, Sydney</option>
+<option value="AEST-10">Australia/Brisbane</option>
+<option value="ACST-9:30ACDT,M10.1.0,M4.1.0/3">Australia/Adelaide</option>
+<option value="ACST-9:30">Australia/Darwin</option>
+<option value="AWST-8">Australia/Perth</option>
+<option value="NZST-12NZDT,M9.5.0,M4.1.0/3">Pacific/Auckland</option>
+<option value="GMT0BST,M3.5.0/1,M10.5.0">Europe/London</option>
+<option value="UTC0">UTC</option>
+<option value="">Custom...</option>
+</select>
+<label for="timezone">POSIX TZ string</label>
+<input id="timezone" name="timezone">
+<label for="ntpServer">NTP server</label>
+<input id="ntpServer" name="ntpServer" placeholder="pool.ntp.org">
+</fieldset>
+
+<fieldset>
+<legend>Battery and power</legend>
+<div id="status">&nbsp;</div>
+<label><input type="checkbox" id="showBattery" style="width:auto;margin-right:8px">Show the battery indicator</label>
+<label><input type="checkbox" id="panelLowPower" style="width:auto;margin-right:8px">Panel low-power mode</label>
+<small>Reduces the screen's internal refresh rate. Suits a clock that changes once a minute. Turn off if the screen looks wrong.</small>
+<label><input type="checkbox" id="sleepWhenDark" style="width:auto;margin-right:8px">Sleep when the room is dark</label>
+<small>The screen has no backlight, so it cannot be read in the dark. Sleeping then costs nothing and saves the most power. Press KEY to wake it at once.</small>
+<div id="darkOpts">
+<label for="darkLux">Darkness threshold (lux)</label>
+<input id="darkLux" type="number" min="0" max="1000" step="1">
+<small id="luxHint"></small>
+<label>Quiet hours, used when no light sensor is fitted</label>
+<div class="row">
+<select id="quietStartHour"></select>
+<select id="quietEndHour"></select>
+</div>
+</div>
+</fieldset>
+
+<button type="submit" id="save">Save and restart</button>
+<button type="button" class="alt" id="factory">Erase all settings</button>
+<p id="msg"></p>
+</form>
+</main>
+
+<script>
+var captive = false, hasPw = false;
+function $(id){ return document.getElementById(id); }
+function msg(text, cls){ var m = $('msg'); m.textContent = text; m.className = cls || ''; }
+
+// Selects the option matching value, or the trailing "Custom..." entry.
+function selectMatching(id, value){
+  var sel = $(id);
+  sel.value = '';
+  for (var i = 0; i < sel.options.length; i++) {
+    if (sel.options[i].value === value) { sel.value = value; return; }
+  }
+}
+
+function showManual(on){
+  $('ssid').hidden = !on;
+  $('ssidSel').parentElement.hidden = on;
+  $('manual').textContent = on ? 'Choose from list' : 'Enter name manually';
+}
+$('manual').onclick = function(){ showManual($('ssid').hidden); };
+
+$('tzPreset').onchange = function(){
+  if (this.value) { $('timezone').value = this.value; }
+  else { $('timezone').focus(); }
+};
+
+$('datePreset').onchange = function(){
+  if (this.value) { $('dateFormat').value = this.value; preview(); }
+  else { $('dateFormat').focus(); }
+};
+
+// The device formats the preview with the same code that draws the panel, so
+// what is shown here is exactly what will appear on screen.
+var previewTimer = null;
+function preview(){
+  clearTimeout(previewTimer);
+  previewTimer = setTimeout(function(){
+    var q = 'api/preview?clock=' + ($('clockFormat').value)
+          + '&date=' + encodeURIComponent($('dateFormat').value)
+          + '&upper=' + ($('dateUppercase').checked ? '1' : '0');
+    fetch(q).then(function(r){ return r.json(); }).then(function(d){
+      $('preview').innerHTML =
+        '<div class="t"></div><div class="d"></div><span class="cap"></span>';
+      $('preview').querySelector('.t').textContent = d.time + (d.suffix ? ' ' + d.suffix : '');
+      $('preview').querySelector('.d').textContent = d.date;
+      $('preview').querySelector('.cap').textContent =
+        d.sample ? 'Sample — the clock is not set yet' : 'Preview';
+    }).catch(function(){});
+  }, 250);
+}
+$('clockFormat').onchange = preview;
+$('dateUppercase').onchange = preview;
+$('dateFormat').oninput = preview;
+
+// Hour pickers for the quiet-hours window.
+(function(){
+  for (var h = 0; h < 24; h++) {
+    var label = (h < 10 ? '0' : '') + h + ':00';
+    $('quietStartHour').add(new Option('from ' + label, h));
+    $('quietEndHour').add(new Option('to ' + label, h));
+  }
+})();
+
+function syncDarkOpts(){ $('darkOpts').hidden = !$('sleepWhenDark').checked; }
+$('sleepWhenDark').onchange = syncDarkOpts;
+
+function status(){
+  fetch('api/status').then(function(r){ return r.json(); }).then(function(s){
+    var cells = [];
+    cells.push(s.batteryValid
+      ? ['<b>' + s.batteryPercent + '%</b>', s.batteryVolts.toFixed(2) + ' V']
+      : ['<b>&ndash;</b>', 'no battery']);
+    if (s.hasLightSensor) {
+      cells.push(['<b>' + s.lux.toFixed(0) + '</b>', 'lux' + (s.dark ? ', dark' : '')]);
+    } else {
+      cells.push(['<b>&ndash;</b>', 'no light sensor']);
+    }
+    $('status').innerHTML = cells.map(function(c){
+      return '<div>' + c[0] + '<span>' + c[1] + '</span></div>';
+    }).join('');
+    $('luxHint').textContent = s.hasLightSensor
+      ? 'BH1750 detected. The room is ' + s.lux.toFixed(0) + ' lux right now.'
+      : 'No BH1750 found, so the quiet hours below are used instead.';
+  }).catch(function(){});
+}
+
+function scan(){
+  $('ssidSel').innerHTML = '<option value="">Scanning...</option>';
+  fetch('api/scan').then(function(r){ return r.json(); }).then(function(list){
+    var sel = $('ssidSel');
+    sel.innerHTML = '';
+    if (!list.length) {
+      sel.innerHTML = '<option value="">No networks found</option>';
+      return;
+    }
+    list.forEach(function(n){
+      var o = document.createElement('option');
+      o.value = n.ssid;
+      o.textContent = n.ssid + '  (' + n.rssi + ' dBm' + (n.open ? ', open' : '') + ')';
+      sel.appendChild(o);
+    });
+    if ($('ssid').value) { sel.value = $('ssid').value; }
+  }).catch(function(){
+    $('ssidSel').innerHTML = '<option value="">Scan failed</option>';
+  });
+}
+$('rescan').onclick = scan;
+
+fetch('api/config').then(function(r){ return r.json(); }).then(function(c){
+  captive = c.captive;
+  hasPw = c.hasPassword;
+  $('ssid').value = c.ssid || '';
+  $('outdoorUrl').value = c.outdoorUrl || '';
+  $('outdoorKey').value = c.outdoorKey || '';
+  $('timezone').value = c.timezone || '';
+  $('ntpServer').value = c.ntpServer || '';
+  $('clockFormat').value = c.clock24h ? '24' : '12';
+  $('dateFormat').value = c.dateFormat || '';
+  $('dateUppercase').checked = !!c.dateUppercase;
+  $('showBattery').checked = !!c.showBattery;
+  $('panelLowPower').checked = !!c.panelLowPower;
+  $('sleepWhenDark').checked = !!c.sleepWhenDark;
+  $('darkLux').value = c.darkLux;
+  $('quietStartHour').value = c.quietStartHour;
+  $('quietEndHour').value = c.quietEndHour;
+  selectMatching('tzPreset', c.timezone);
+  selectMatching('datePreset', c.dateFormat);
+  syncDarkOpts();
+  preview();
+  status();
+  $('pwHint').textContent = hasPw
+    ? 'A password is already stored. Leave blank to keep it.'
+    : 'Leave blank if the network is open.';
+  if (captive) {
+    $('test').disabled = true;
+    $('test').textContent = 'Test available once connected to Wi-Fi';
+  }
+  scan();
+});
+
+$('test').onclick = function(){
+  var url = $('outdoorUrl').value.trim();
+  if (!url) { msg('Enter a source URL first.', 'err'); return; }
+  msg('Testing...', 'busy');
+  $('test').disabled = true;
+  fetch('api/test?url=' + encodeURIComponent(url) + '&key=' + encodeURIComponent($('outdoorKey').value.trim()))
+    .then(function(r){ return r.json(); })
+    .then(function(d){
+      msg(d.ok ? 'Read ' + d.value.toFixed(1) + ' °C from the source.'
+               : 'Failed: ' + d.error, d.ok ? 'ok' : 'err');
+    })
+    .catch(function(){ msg('Test request failed.', 'err'); })
+    .then(function(){ $('test').disabled = false; });
+};
+
+$('factory').onclick = function(){
+  if (!confirm('Erase Wi-Fi and all settings, then restart?')) { return; }
+  fetch('api/reset', {method:'POST'}).then(function(){
+    msg('Erased. The display will restart into setup mode.', 'ok');
+  });
+};
+
+$('f').onsubmit = function(e){
+  e.preventDefault();
+  var body = new URLSearchParams();
+  body.set('ssid', $('ssid').hidden ? $('ssidSel').value : $('ssid').value);
+  body.set('password', $('password').value);
+  body.set('outdoorUrl', $('outdoorUrl').value);
+  body.set('outdoorKey', $('outdoorKey').value);
+  body.set('timezone', $('timezone').value);
+  body.set('ntpServer', $('ntpServer').value);
+  body.set('clock24h', $('clockFormat').value === '24' ? '1' : '0');
+  body.set('dateFormat', $('dateFormat').value);
+  body.set('dateUppercase', $('dateUppercase').checked ? '1' : '0');
+  body.set('showBattery', $('showBattery').checked ? '1' : '0');
+  body.set('panelLowPower', $('panelLowPower').checked ? '1' : '0');
+  body.set('sleepWhenDark', $('sleepWhenDark').checked ? '1' : '0');
+  body.set('darkLux', $('darkLux').value);
+  body.set('quietStartHour', $('quietStartHour').value);
+  body.set('quietEndHour', $('quietEndHour').value);
+  if (!body.get('ssid')) { msg('Choose a Wi-Fi network.', 'err'); return; }
+  msg('Saving...', 'busy');
+  $('save').disabled = true;
+  fetch('api/save', {method:'POST', body: body})
+    .then(function(r){ return r.json(); })
+    .then(function(d){
+      if (d.ok) {
+        msg('Saved. Restarting — the display will connect to "' + body.get('ssid')
+            + '" and set its clock. This page will stop responding.', 'ok');
+      } else {
+        msg('Save failed: ' + d.error, 'err');
+        $('save').disabled = false;
+      }
+    })
+    .catch(function(){ msg('Save request failed.', 'err'); $('save').disabled = false; });
+};
+</script>
+</body>
+</html>
+)HTML";
+
+void noteRequest()
+{
+  g_lastRequestMs = millis();
+  g_everServed = true;
+}
+
+void sendJson(int code, const JsonDocument &doc)
+{
+  noteRequest();
+  String out;
+  serializeJson(doc, out);
+  g_server.send(code, "application/json", out);
+}
+
+void handleRoot()
+{
+  noteRequest();
+  g_server.sendHeader("Cache-Control", "no-store");
+  g_server.send_P(200, "text/html", PAGE_HTML);
+}
+
+void handleGetConfig()
+{
+  const settings::Config &c = settings::get();
+  JsonDocument doc;
+  doc["ssid"] = c.ssid;
+  doc["hasPassword"] = c.password[0] != '\0';  // never send the password itself
+  doc["outdoorUrl"] = c.outdoorUrl;
+  doc["outdoorKey"] = c.outdoorKey;
+  doc["timezone"] = c.timezone;
+  doc["ntpServer"] = c.ntpServer;
+  doc["clock24h"] = c.clock24h;
+  doc["dateFormat"] = c.dateFormat;
+  doc["dateUppercase"] = c.dateUppercase;
+  doc["showBattery"] = c.showBattery;
+  doc["sleepWhenDark"] = c.sleepWhenDark;
+  doc["panelLowPower"] = c.panelLowPower;
+  doc["darkLux"] = c.darkLux;
+  doc["quietStartHour"] = c.quietStartHour;
+  doc["quietEndHour"] = c.quietEndHour;
+  doc["captive"] = g_captive;
+  doc["hostname"] = DEVICE_HOSTNAME;
+  sendJson(200, doc);
+}
+
+void handleScan()
+{
+  // Synchronous scan; a few seconds with the web server blocked is acceptable
+  // here and much simpler than polling an async scan from the browser.
+  const int n = WiFi.scanNetworks(false, true);
+  JsonDocument doc;
+  JsonArray arr = doc.to<JsonArray>();
+
+  for (int i = 0; i < n; i++) {
+    const String ssid = WiFi.SSID(i);
+    if (ssid.isEmpty()) {
+      continue;  // hidden network: nothing useful to show or select
+    }
+    bool duplicate = false;
+    for (JsonObject existing : arr) {
+      if (ssid == existing["ssid"].as<const char *>()) {
+        duplicate = true;  // same network on two bands or two APs
+        break;
+      }
+    }
+    if (duplicate) {
+      continue;
+    }
+    JsonObject o = arr.add<JsonObject>();
+    o["ssid"] = ssid;
+    o["rssi"] = WiFi.RSSI(i);
+    o["open"] = WiFi.encryptionType(i) == WIFI_AUTH_OPEN;
+  }
+  WiFi.scanDelete();
+  sendJson(200, doc);
+}
+
+void handleSave()
+{
+  settings::Config cfg = settings::get();  // start from stored values
+
+  if (!g_server.hasArg("ssid") || g_server.arg("ssid").isEmpty()) {
+    JsonDocument doc;
+    doc["ok"] = false;
+    doc["error"] = "no network name given";
+    sendJson(400, doc);
+    return;
+  }
+
+  const String newSsid = g_server.arg("ssid");
+  const String newPassword = g_server.arg("password");
+
+  // An empty password field means "keep the stored one" — but only for the
+  // same network. Switching networks with a blank field means an open network.
+  const bool sameNetwork = newSsid == cfg.ssid;
+  if (!newPassword.isEmpty() || !sameNetwork) {
+    snprintf(cfg.password, sizeof(cfg.password), "%s", newPassword.c_str());
+  }
+  snprintf(cfg.ssid, sizeof(cfg.ssid), "%s", newSsid.c_str());
+  snprintf(cfg.outdoorUrl, sizeof(cfg.outdoorUrl), "%s",
+           g_server.arg("outdoorUrl").c_str());
+  snprintf(cfg.outdoorKey, sizeof(cfg.outdoorKey), "%s",
+           g_server.arg("outdoorKey").c_str());
+  snprintf(cfg.timezone, sizeof(cfg.timezone), "%s",
+           g_server.arg("timezone").c_str());
+  snprintf(cfg.ntpServer, sizeof(cfg.ntpServer), "%s",
+           g_server.arg("ntpServer").c_str());
+  snprintf(cfg.dateFormat, sizeof(cfg.dateFormat), "%s",
+           g_server.arg("dateFormat").c_str());
+  cfg.clock24h = g_server.arg("clock24h") != "0";
+  cfg.dateUppercase = g_server.arg("dateUppercase") == "1";
+  cfg.showBattery = g_server.arg("showBattery") == "1";
+  cfg.panelLowPower = g_server.arg("panelLowPower") == "1";
+  cfg.sleepWhenDark = g_server.arg("sleepWhenDark") == "1";
+  cfg.darkLux = (uint16_t)g_server.arg("darkLux").toInt();
+  cfg.quietStartHour = (uint8_t)g_server.arg("quietStartHour").toInt();
+  cfg.quietEndHour = (uint8_t)g_server.arg("quietEndHour").toInt();
+
+  JsonDocument doc;
+  if (!settings::save(cfg)) {
+    doc["ok"] = false;
+    doc["error"] = "could not write to flash";
+    sendJson(500, doc);
+    return;
+  }
+
+  doc["ok"] = true;
+  sendJson(200, doc);
+
+  // Restart rather than reconfigure in place: a clean boot exercises the same
+  // path as a power-on, so what the user sees now is what they get later.
+  g_rebootAtMs = millis() + 1200;
+}
+
+void handleTest()
+{
+  JsonDocument doc;
+
+  if (WiFi.status() != WL_CONNECTED) {
+    doc["ok"] = false;
+    doc["error"] = "the display is not on your network yet";
+    sendJson(200, doc);
+    return;
+  }
+  if (!g_server.hasArg("url")) {
+    doc["ok"] = false;
+    doc["error"] = "no URL given";
+    sendJson(400, doc);
+    return;
+  }
+
+  const String url = g_server.arg("url");
+  const String key = g_server.arg("key");
+
+  HTTPClient http;
+  http.setTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
+  http.setConnectTimeout(OUTDOOR_HTTP_TIMEOUT_MS);
+  if (!http.begin(url)) {
+    doc["ok"] = false;
+    doc["error"] = "URL is not valid";
+    sendJson(200, doc);
+    return;
+  }
+
+  const int code = http.GET();
+  if (code != HTTP_CODE_OK) {
+    http.end();
+    doc["ok"] = false;
+    doc["error"] = code > 0 ? String("HTTP ") + code : String("could not connect");
+    sendJson(200, doc);
+    return;
+  }
+
+  const String body = http.getString();
+  http.end();
+
+  float value = 0.0f;
+  if (!outdoor::parseTemperature(body, key.c_str(), value)) {
+    doc["ok"] = false;
+    doc["error"] = String("could not read a temperature from: ") + body.substring(0, 80);
+    sendJson(200, doc);
+    return;
+  }
+
+  doc["ok"] = true;
+  doc["value"] = value;
+  sendJson(200, doc);
+}
+
+// Live sensor readings, so the user can set the darkness threshold against
+// what the room actually measures rather than by guesswork.
+void handleStatus()
+{
+  JsonDocument doc;
+
+  battery::sample();
+  doc["batteryValid"] = battery::hasReading();
+  doc["batteryVolts"] = battery::volts();
+  doc["batteryPercent"] = battery::percent();
+
+  doc["hasLightSensor"] = ambient::hasSensor();
+  float lux = 0.0f;
+  if (ambient::readLux(lux)) {
+    doc["lux"] = lux;
+  } else {
+    doc["lux"] = ambient::lastLux() < 0.0f ? 0.0f : ambient::lastLux();
+  }
+  doc["dark"] = ambient::isDark();
+  doc["resting"] = power::isResting();
+
+  sendJson(200, doc);
+}
+
+// Renders the clock and date exactly as the panel would, so the user can see
+// the effect of a format change before committing it.
+void handlePreview()
+{
+  const bool clock24h = g_server.arg("clock") != "12";
+  const bool uppercase = g_server.arg("upper") == "1";
+  String format = g_server.arg("date");
+  if (format.isEmpty()) {
+    format = DATE_FORMAT;
+  }
+
+  // Before the first NTP sync the system clock is meaningless, so preview
+  // against a fixed sample instead of showing 1970.
+  const time_t now = time(nullptr);
+  struct tm local;
+  localtime_r(&now, &local);
+  const bool sample = local.tm_year + 1900 < 2024;
+  if (sample) {
+    local = {};
+    local.tm_year = 126;  // 2026
+    local.tm_mon = 7;     // August
+    local.tm_mday = 24;
+    local.tm_wday = 1;    // Monday
+    local.tm_yday = 235;
+    local.tm_hour = 14;
+    local.tm_min = 5;
+  }
+
+  char timeText[8];
+  char suffix[4];
+  char dateText[64];
+  display::formatTimeText(local.tm_hour, local.tm_min, !clock24h,
+                          timeText, sizeof(timeText), suffix, sizeof(suffix));
+  display::formatDateLine(local, format.c_str(), uppercase,
+                          dateText, sizeof(dateText));
+
+  JsonDocument doc;
+  doc["time"] = timeText;
+  doc["suffix"] = suffix;
+  doc["date"] = dateText;
+  doc["sample"] = sample;
+  sendJson(200, doc);
+}
+
+void handleReset()
+{
+  settings::clear();
+  JsonDocument doc;
+  doc["ok"] = true;
+  sendJson(200, doc);
+  g_rebootAtMs = millis() + 1200;
+}
+
+void handleNotFound()
+{
+  if (g_captive) {
+    // Redirect every stray request — including the OS connectivity probes that
+    // trigger the "sign in to network" prompt — at the setup page.
+    g_server.sendHeader("Location",
+                        String("http://") + WiFi.softAPIP().toString() + "/", true);
+    g_server.send(302, "text/plain", "");
+    return;
+  }
+  g_server.send(404, "text/plain", "not found");
+}
+
+void registerHandlers()
+{
+  g_server.on("/", HTTP_GET, handleRoot);
+  g_server.on("/api/config", HTTP_GET, handleGetConfig);
+  g_server.on("/api/scan", HTTP_GET, handleScan);
+  g_server.on("/api/save", HTTP_POST, handleSave);
+  g_server.on("/api/test", HTTP_GET, handleTest);
+  g_server.on("/api/preview", HTTP_GET, handlePreview);
+  g_server.on("/api/status", HTTP_GET, handleStatus);
+  g_server.on("/api/reset", HTTP_POST, handleReset);
+  g_server.onNotFound(handleNotFound);
+}
+
+}  // namespace
+
+namespace portal {
+
+void startAccessPoint()
+{
+  WiFi.mode(WIFI_AP_STA);  // STA kept up so the network scan still works
+  WiFi.softAP(SETUP_AP_SSID, SETUP_AP_PASSWORD);
+  delay(100);
+
+  g_captive = true;
+  g_dns.setErrorReplyCode(DNSReplyCode::NoError);
+  g_dns.start(53, "*", WiFi.softAPIP());  // every name resolves to us
+
+  registerHandlers();
+  g_server.begin();
+  g_running = true;
+
+  Serial.printf("[portal] setup AP '%s' at %s\n", SETUP_AP_SSID,
+                WiFi.softAPIP().toString().c_str());
+}
+
+void startStation()
+{
+  if (!g_running) {
+    registerHandlers();
+    g_server.begin();
+    g_running = true;
+  }
+
+  // Re-announced on every association, since this is also the path back from
+  // a dark-room rest with the radio powered down.
+  MDNS.end();
+  if (MDNS.begin(DEVICE_HOSTNAME)) {
+    MDNS.addService("http", "tcp", 80);
+  }
+  Serial.printf("[portal] settings at http://%s/ or http://%s.local/\n",
+                WiFi.localIP().toString().c_str(), DEVICE_HOSTNAME);
+}
+
+void loop()
+{
+  if (!g_running) {
+    return;
+  }
+  if (g_captive) {
+    g_dns.processNextRequest();
+  }
+  g_server.handleClient();
+
+  if (g_rebootAtMs != 0 && (int32_t)(millis() - g_rebootAtMs) >= 0) {
+    Serial.println("[portal] restarting");
+    delay(50);
+    ESP.restart();
+  }
+}
+
+bool isCaptive()
+{
+  return g_captive;
+}
+
+uint32_t millisSinceLastRequest()
+{
+  if (!g_everServed) {
+    return UINT32_MAX;
+  }
+  return millis() - g_lastRequestMs;
+}
+
+const char *apSsid()
+{
+  return SETUP_AP_SSID;
+}
+
+const char *apPassword()
+{
+  return SETUP_AP_PASSWORD;
+}
+
+IPAddress apIp()
+{
+  return WiFi.softAPIP();
+}
+
+}  // namespace portal

+ 39 - 0
src/portal.h

@@ -0,0 +1,39 @@
+#pragma once
+
+#include <Arduino.h>
+#include <IPAddress.h>
+
+// Configuration web UI.
+//
+// Two modes, both serving the same page:
+//
+//   Setup   — the device is its own access point with a captive portal. Used
+//             when no Wi-Fi is stored, or when KEY is held at power-up.
+//   Station — the device is on the user's network and the page is reachable at
+//             its IP or at http://<hostname>.local, so the outdoor URL can be
+//             changed later without re-provisioning.
+namespace portal {
+
+// Brings up the soft AP, captive-portal DNS and web server. Blocks the clock:
+// main() shows the setup screen instead while this is active.
+void startAccessPoint();
+
+// Serves the same page over the existing station connection.
+void startStation();
+
+// Services DNS and HTTP. Call from loop().
+void loop();
+
+// True while the setup access point is running.
+bool isCaptive();
+
+// Milliseconds since the last request was served, or a very large value if
+// none has been. Used to avoid dropping Wi-Fi while someone is configuring.
+uint32_t millisSinceLastRequest();
+
+// AP credentials and address, for display on the setup screen.
+const char *apSsid();
+const char *apPassword();
+IPAddress apIp();
+
+}  // namespace portal

+ 114 - 0
src/power.cpp

@@ -0,0 +1,114 @@
+#include "power.h"
+
+#include <driver/gpio.h>
+#include <esp_sleep.h>
+
+#include "ambient.h"
+#include "config.h"
+#include "display.h"
+#include "network.h"
+#include "outdoor.h"
+#include "portal.h"
+#include "settings.h"
+
+namespace {
+
+bool g_resting = false;
+
+// Do not sleep within this long of someone using the settings page; dropping
+// Wi-Fi mid-configuration because the room went dim is indefensible.
+constexpr uint32_t PORTAL_GRACE_MS = 60000UL;
+
+bool sleepingWanted()
+{
+  if (!settings::get().sleepWhenDark) {
+    return false;
+  }
+  // The setup access point is the only interface the user has at that point.
+  if (portal::isCaptive()) {
+    return false;
+  }
+  if (portal::millisSinceLastRequest() < PORTAL_GRACE_MS) {
+    return false;
+  }
+  return true;
+}
+
+// One light-sleep interval, woken by the timer or by KEY going low.
+void sleepOnce(uint32_t ms)
+{
+  gpio_wakeup_enable((gpio_num_t)KEY_BUTTON_PIN, GPIO_INTR_LOW_LEVEL);
+  esp_sleep_enable_gpio_wakeup();
+  esp_sleep_enable_timer_wakeup((uint64_t)ms * 1000ULL);
+
+  esp_light_sleep_start();
+
+  const esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
+  esp_sleep_disable_wakeup_source(ESP_SLEEP_WAKEUP_ALL);
+  gpio_wakeup_disable((gpio_num_t)KEY_BUTTON_PIN);
+
+  if (cause == ESP_SLEEP_WAKEUP_GPIO) {
+    Serial.println("[power] woken by KEY");
+  }
+}
+
+}  // namespace
+
+namespace power {
+
+void begin()
+{
+  // The APB clock stays at 80 MHz on the ESP32-S3 for any CPU frequency at or
+  // above 80 MHz, so the display SPI and the UART dividers are unaffected.
+  setCpuFrequencyMhz(CPU_FREQ_MHZ);
+}
+
+void applyPanelMode()
+{
+  display::setPanelLowPower(settings::get().panelLowPower);
+}
+
+bool sleepWhileDark()
+{
+  if (!sleepingWanted() || !ambient::isDark()) {
+    if (g_resting) {
+      g_resting = false;
+    }
+    return false;
+  }
+
+  Serial.printf("[power] room is dark (%.1f lux), resting\n", ambient::lastLux());
+  g_resting = true;
+
+  // Wi-Fi is the largest single consumer, and nothing needs the network while
+  // nobody can read the screen. The panel keeps its last image with no help
+  // from us, so there is nothing to redraw on the way out either.
+  network::suspend();
+
+  while (true) {
+    sleepOnce(DARK_RECHECK_MS);
+
+    // KEY, or a change of mind in settings, ends the rest immediately.
+    if (digitalRead(KEY_BUTTON_PIN) == LOW || !sleepingWanted()) {
+      break;
+    }
+    if (!ambient::isDark()) {
+      break;
+    }
+  }
+
+  Serial.printf("[power] waking (%.1f lux)\n", ambient::lastLux());
+  g_resting = false;
+
+  network::resume();
+  outdoor::pollSoon();     // the retained reading is now minutes or hours old
+  display::invalidate();   // and the clock on the panel is stale
+  return true;
+}
+
+bool isResting()
+{
+  return g_resting;
+}
+
+}  // namespace power

+ 36 - 0
src/power.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <Arduino.h>
+
+// Power management.
+//
+// Two regimes, because the panel is reflective and holds its image without
+// being driven:
+//
+//   Readable room — CPU at CPU_FREQ_MHZ, Wi-Fi associated with modem sleep,
+//                   panel in its own low-power mode. Refreshes once a minute.
+//   Dark room     — Wi-Fi off and the CPU in light sleep, waking every
+//                   DARK_RECHECK_MS only to sample the light level. The image
+//                   already on the panel stays there, unpowered. KEY wakes it
+//                   immediately.
+namespace power {
+
+// Lowers the CPU clock. Call before Serial.begin(), since changing the clock
+// mid-flight garbles the UART.
+void begin();
+
+// Puts the panel into its low-power driving mode, if enabled in settings.
+// Worth doing whenever the content changes once a minute rather than at video
+// rate.
+void applyPanelMode();
+
+// If the room is dark and sleeping is enabled, drops into light sleep and does
+// not return until the room is lit again, KEY is pressed, or sleeping is no
+// longer wanted. Returns true if it slept at all, in which case the caller
+// should refresh the display and let the network reconnect.
+bool sleepWhileDark();
+
+// True while the display is suppressed because the room is dark.
+bool isResting();
+
+}  // namespace power

+ 230 - 0
src/settings.cpp

@@ -0,0 +1,230 @@
+#include "settings.h"
+
+#include <Preferences.h>
+
+#include "config.h"
+
+namespace {
+
+// Bumping the namespace name is the cheapest way to discard an incompatible
+// stored layout, should Config ever change shape.
+constexpr char NVS_NAMESPACE[] = "tempdisp1";
+
+Preferences g_prefs;
+settings::Config g_cfg;
+bool g_loaded = false;
+
+// Copies src into a fixed-size field, always NUL-terminated, with leading and
+// trailing whitespace removed. Pasted values routinely carry a stray space.
+void assignTrimmed(char *dst, size_t dstLen, const char *src)
+{
+  if (!src) {
+    dst[0] = '\0';
+    return;
+  }
+  while (*src == ' ' || *src == '\t' || *src == '\r' || *src == '\n') {
+    src++;
+  }
+  size_t len = strlen(src);
+  while (len > 0 && (src[len - 1] == ' ' || src[len - 1] == '\t'
+                     || src[len - 1] == '\r' || src[len - 1] == '\n')) {
+    len--;
+  }
+  if (len > dstLen - 1) {
+    len = dstLen - 1;
+  }
+  memcpy(dst, src, len);
+  dst[len] = '\0';
+}
+
+void applyCompiledDefaults(settings::Config &cfg)
+{
+  // secrets.h is optional. When present its values seed the first boot, which
+  // saves re-entering them on a bench device after every erase.
+#ifdef WIFI_SSID
+  assignTrimmed(cfg.ssid, sizeof(cfg.ssid), WIFI_SSID);
+#endif
+#ifdef WIFI_PASSWORD
+  assignTrimmed(cfg.password, sizeof(cfg.password), WIFI_PASSWORD);
+#endif
+#ifdef OUTDOOR_URL
+  assignTrimmed(cfg.outdoorUrl, sizeof(cfg.outdoorUrl), OUTDOOR_URL);
+#endif
+#ifdef OUTDOOR_JSON_KEY
+  assignTrimmed(cfg.outdoorKey, sizeof(cfg.outdoorKey), OUTDOOR_JSON_KEY);
+#endif
+
+  // These always have a sane built-in value.
+  assignTrimmed(cfg.timezone, sizeof(cfg.timezone), TIMEZONE_POSIX);
+  assignTrimmed(cfg.ntpServer, sizeof(cfg.ntpServer), NTP_SERVER_1);
+  assignTrimmed(cfg.dateFormat, sizeof(cfg.dateFormat), DATE_FORMAT);
+  cfg.clock24h = CLOCK_24H_DEFAULT;
+  cfg.dateUppercase = DATE_UPPERCASE_DEFAULT;
+  cfg.showBattery = SHOW_BATTERY_DEFAULT;
+  cfg.sleepWhenDark = SLEEP_WHEN_DARK_DEFAULT;
+  cfg.panelLowPower = PANEL_LOW_POWER_DEFAULT;
+  cfg.darkLux = DARK_LUX_DEFAULT;
+  cfg.quietStartHour = QUIET_START_HOUR_DEFAULT;
+  cfg.quietEndHour = QUIET_END_HOUR_DEFAULT;
+
+  // A placeholder SSID from the example file is worse than none: it would stop
+  // the device offering its setup portal.
+  if (strcmp(cfg.ssid, "YOUR_WIFI") == 0) {
+    cfg.ssid[0] = '\0';
+    cfg.password[0] = '\0';
+  }
+}
+
+}  // namespace
+
+namespace settings {
+
+void begin()
+{
+  if (g_loaded) {
+    return;
+  }
+
+  applyCompiledDefaults(g_cfg);
+
+  if (!g_prefs.begin(NVS_NAMESPACE, true)) {
+    Serial.println("[settings] no stored settings, using defaults");
+    g_loaded = true;
+    return;
+  }
+
+  // getString() leaves the buffer alone when the key is absent, so a partially
+  // written namespace still falls back per-field.
+  char buf[sizeof(Config::outdoorUrl)];
+  if (g_prefs.getString("ssid", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.ssid, sizeof(g_cfg.ssid), buf);
+  }
+  if (g_prefs.getString("pass", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.password, sizeof(g_cfg.password), buf);
+  }
+  if (g_prefs.getString("url", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.outdoorUrl, sizeof(g_cfg.outdoorUrl), buf);
+  }
+  if (g_prefs.getString("key", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.outdoorKey, sizeof(g_cfg.outdoorKey), buf);
+  }
+  if (g_prefs.getString("tz", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.timezone, sizeof(g_cfg.timezone), buf);
+  }
+  if (g_prefs.getString("ntp", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.ntpServer, sizeof(g_cfg.ntpServer), buf);
+  }
+  if (g_prefs.getString("datefmt", buf, sizeof(buf)) > 0) {
+    assignTrimmed(g_cfg.dateFormat, sizeof(g_cfg.dateFormat), buf);
+  }
+  g_cfg.clock24h = g_prefs.getBool("h24", g_cfg.clock24h);
+  g_cfg.dateUppercase = g_prefs.getBool("dateupper", g_cfg.dateUppercase);
+  g_cfg.showBattery = g_prefs.getBool("battico", g_cfg.showBattery);
+  g_cfg.sleepWhenDark = g_prefs.getBool("sleepdark", g_cfg.sleepWhenDark);
+  g_cfg.panelLowPower = g_prefs.getBool("panellp", g_cfg.panelLowPower);
+  g_cfg.darkLux = g_prefs.getUShort("darklux", g_cfg.darkLux);
+  g_cfg.quietStartHour = g_prefs.getUChar("qstart", g_cfg.quietStartHour);
+  g_cfg.quietEndHour = g_prefs.getUChar("qend", g_cfg.quietEndHour);
+  g_prefs.end();
+
+  g_loaded = true;
+  Serial.printf("[settings] ssid='%s' url='%s' tz='%s'\n",
+                g_cfg.ssid, g_cfg.outdoorUrl, g_cfg.timezone);
+}
+
+const Config &get()
+{
+  return g_cfg;
+}
+
+bool save(const Config &cfg)
+{
+  Config clean;
+  assignTrimmed(clean.ssid, sizeof(clean.ssid), cfg.ssid);
+  assignTrimmed(clean.password, sizeof(clean.password), cfg.password);
+  assignTrimmed(clean.outdoorUrl, sizeof(clean.outdoorUrl), cfg.outdoorUrl);
+  assignTrimmed(clean.outdoorKey, sizeof(clean.outdoorKey), cfg.outdoorKey);
+  assignTrimmed(clean.timezone, sizeof(clean.timezone), cfg.timezone);
+  assignTrimmed(clean.ntpServer, sizeof(clean.ntpServer), cfg.ntpServer);
+  assignTrimmed(clean.dateFormat, sizeof(clean.dateFormat), cfg.dateFormat);
+  clean.clock24h = cfg.clock24h;
+  clean.dateUppercase = cfg.dateUppercase;
+  clean.showBattery = cfg.showBattery;
+  clean.sleepWhenDark = cfg.sleepWhenDark;
+  clean.panelLowPower = cfg.panelLowPower;
+
+  // A nonsense threshold would either never sleep or never wake.
+  clean.darkLux = cfg.darkLux > 1000 ? 1000 : cfg.darkLux;
+  clean.quietStartHour = cfg.quietStartHour > 23 ? QUIET_START_HOUR_DEFAULT
+                                                 : cfg.quietStartHour;
+  clean.quietEndHour = cfg.quietEndHour > 23 ? QUIET_END_HOUR_DEFAULT
+                                             : cfg.quietEndHour;
+
+  // A blank timezone, NTP server or date format would break the display
+  // outright; fall back rather than storing an unusable value.
+  if (clean.timezone[0] == '\0') {
+    assignTrimmed(clean.timezone, sizeof(clean.timezone), TIMEZONE_POSIX);
+  }
+  if (clean.ntpServer[0] == '\0') {
+    assignTrimmed(clean.ntpServer, sizeof(clean.ntpServer), NTP_SERVER_1);
+  }
+  if (clean.dateFormat[0] == '\0') {
+    assignTrimmed(clean.dateFormat, sizeof(clean.dateFormat), DATE_FORMAT);
+  }
+
+  if (!g_prefs.begin(NVS_NAMESPACE, false)) {
+    Serial.println("[settings] NVS open for write failed");
+    return false;
+  }
+  bool ok = true;
+  ok &= g_prefs.putString("ssid", clean.ssid) > 0 || clean.ssid[0] == '\0';
+  ok &= g_prefs.putString("pass", clean.password) > 0 || clean.password[0] == '\0';
+  ok &= g_prefs.putString("url", clean.outdoorUrl) > 0 || clean.outdoorUrl[0] == '\0';
+  ok &= g_prefs.putString("key", clean.outdoorKey) > 0 || clean.outdoorKey[0] == '\0';
+  ok &= g_prefs.putString("tz", clean.timezone) > 0;
+  ok &= g_prefs.putString("ntp", clean.ntpServer) > 0;
+  ok &= g_prefs.putString("datefmt", clean.dateFormat) > 0;
+  ok &= g_prefs.putBool("h24", clean.clock24h);
+  ok &= g_prefs.putBool("dateupper", clean.dateUppercase);
+  ok &= g_prefs.putBool("battico", clean.showBattery);
+  ok &= g_prefs.putBool("sleepdark", clean.sleepWhenDark);
+  ok &= g_prefs.putBool("panellp", clean.panelLowPower);
+  ok &= g_prefs.putUShort("darklux", clean.darkLux) > 0;
+  ok &= g_prefs.putUChar("qstart", clean.quietStartHour) > 0
+        || clean.quietStartHour == 0;
+  ok &= g_prefs.putUChar("qend", clean.quietEndHour) > 0
+        || clean.quietEndHour == 0;
+  g_prefs.end();
+
+  if (!ok) {
+    Serial.println("[settings] NVS write failed");
+    return false;
+  }
+
+  g_cfg = clean;
+  Serial.printf("[settings] saved ssid='%s'\n", g_cfg.ssid);
+  return true;
+}
+
+bool hasWifi()
+{
+  return g_cfg.ssid[0] != '\0';
+}
+
+bool hasOutdoorSource()
+{
+  return g_cfg.outdoorUrl[0] != '\0';
+}
+
+void clear()
+{
+  if (g_prefs.begin(NVS_NAMESPACE, false)) {
+    g_prefs.clear();
+    g_prefs.end();
+  }
+  g_cfg = Config();
+  applyCompiledDefaults(g_cfg);
+  Serial.println("[settings] cleared");
+}
+
+}  // namespace settings

+ 54 - 0
src/settings.h

@@ -0,0 +1,54 @@
+#pragma once
+
+#include <Arduino.h>
+
+#include "config.h"
+
+// Runtime configuration, held in NVS so it survives reflashing the firmware.
+//
+// Everything here used to be compile-time #defines in secrets.h. It is now
+// entered through the setup portal (see portal.h); secrets.h, if present,
+// only supplies first-boot defaults for development.
+namespace settings {
+
+struct Config {
+  char ssid[33] = "";        // 32 chars max per 802.11
+  char password[65] = "";    // 64 chars max for WPA2 PSK
+  char outdoorUrl[160] = "";
+  char outdoorKey[32] = "";
+  char timezone[64] = "";    // POSIX TZ string
+  char ntpServer[64] = "";
+
+  bool clock24h = CLOCK_24H_DEFAULT;       // false shows 12-hour with AM/PM
+  char dateFormat[48] = "";                // strftime(3)
+  bool dateUppercase = DATE_UPPERCASE_DEFAULT;
+
+  bool showBattery = SHOW_BATTERY_DEFAULT;
+  bool sleepWhenDark = SLEEP_WHEN_DARK_DEFAULT;
+  bool panelLowPower = PANEL_LOW_POWER_DEFAULT;
+  uint16_t darkLux = DARK_LUX_DEFAULT;
+  uint8_t quietStartHour = QUIET_START_HOUR_DEFAULT;  // no light sensor fitted
+  uint8_t quietEndHour = QUIET_END_HOUR_DEFAULT;
+};
+
+// Loads from NVS, falling back to the compiled-in defaults. Call first.
+void begin();
+
+const Config &get();
+
+// Writes to NVS. Fields are validated and trimmed; returns false if the
+// write failed. Does not apply the new values — the caller reboots.
+bool save(const Config &cfg);
+
+// True once a non-empty SSID is stored. Without one the device goes straight
+// to the setup portal.
+bool hasWifi();
+
+// True if an outdoor source URL is configured.
+bool hasOutdoorSource();
+
+// Erases stored settings, returning the device to the setup portal on the
+// next boot.
+void clear();
+
+}  // namespace settings