Main »

LED Strip Thermometer

city of baker style linear strip led thermometer

Close-up

Full strip

The ESP32

In the kitchen

LED Strip Thermometer

An indoor display for the outdoor temperature: a 1 m strip of 144 WS2812 ("NeoPixel") LEDs driven by an ESP32. The strip is a thermometer scale from 16 °F at the bottom to 123 °F at the top, about 0.75 °F per LED. The lit LEDs go from blue (cold) through green to red (hot). Two white LEDs mark yesterday's high and low.

The ESP32 doesn't measure anything itself. It reads a separate outdoor sensor over Wi-Fi.

Features

  • 7 display patterns, cycled with a push button or from a web page. The choice is saved in flash.
  • Yesterday's high and low as two white LEDs (patterns 1–3).
    • The ESP32 tracks today's high/low from the sensor. At midnight that becomes "yesterday".
    • If it doesn't have a full day of readings (first boot, reboot mid-day, sensor outage), it gets yesterday's high/low from Open-Meteo instead. That's free and needs no API key.
    • If a marker falls on the current-temperature LED, it moves up one LED so both stay visible.
  • Over-the-air updates, so the unit never has to come off the wall:
    • Arduino IDE network port, or
    • browser upload of a compiled .bin at http://thermostrip.local/update (password protected).
    • The strip shows a blue progress bar during an update.
  • Status web page at http://thermostrip.local/: current temperature and humidity, today's and yesterday's high/low (and where yesterday's came from), pattern links, firmware version and build time, Wi-Fi signal, uptime, and why it last restarted.
  • Survives reboots: today's running high/low is saved to flash every 10 minutes and before any update or restart.
  • Self-recovery: reboots if Wi-Fi is down for 5 minutes, if the sensor fails 60 times in a row, or if the main loop stalls for 30 seconds (watchdog).

Display patterns

#PatternNotes
1Bar, solid colorWhole bar in the current temperature's color
2Bar, gradientEach LED in its own color (default)
3DotSingle LED at the current temperature
4Test: sweeping dotAnimation, checks every LED
5Test: sweeping barAnimation
6Test: full scaleEvery LED lit in its scale color
7Test: 10° ticksOne LED at every 10 °F from 20 to 120

Colors: blue at 32 °F and below, green at 63 °F, red at 100 °F and above, blended in between.

Hardware

  • ESP32 dev board (e.g. "ESP32 Dev Module", ESP32-D0WD)
  • WS2812B strip, 144 LEDs (1 m at 144/m)
  • Momentary push button
  • 5 V supply for the strip and the ESP32. At the default brightness (10 of 255) the strip draws very little, but a supply rated for a few amps leaves headroom for the full-scale test pattern.
  • An outdoor sensor that serves its readings over HTTP (see below)
ESP32 pinConnects to
GPIO 4Strip data in
GPIO 18Push button, other side to GND (internal pull-up used)
GPIO 2On-board LED: heartbeat blip every 2 s
5V / GNDStrip power (common ground with the ESP32)

The ESP32's 3.3 V data line usually drives a 5 V strip fine over a short run. If the first LED misbehaves, add a level shifter (e.g. 74AHCT125).

The outdoor sensor

Any device on your network will do, as long as these two URLs return just a number in plain text:

http://<sensor-ip>/temperature   ->  72.4      (degrees F)
http://<sensor-ip>/humidity      ->  35        (% RH)

Mine is a separate ESP board with a DHT sensor running a tiny web server. Readings are taken every 30 seconds. Non-numeric or out-of-range replies (like "nan" from a DHT glitch) are ignored.

Setup

  1. Install the ESP32 board package in the Arduino IDE (Boards Manager, "esp32 by Espressif Systems").
  2. Install the libraries Adafruit NeoPixel and ArduinoJson (version 7) from the Library Manager.
  3. Put ledstrip_thermometer.ino and secrets.h in a folder named ledstrip_thermometer.
  4. Edit secrets.h: your Wi-Fi name and password, and an OTA password.
  5. Edit the top of the sketch:
    1. URL_TEMP / URL_HUMI: your sensor's IP address
    2. LATITUDE / LONGITUDE: your location, for the Open-Meteo fallback
    3. TZ_POSIX / TZ_URL: your time zone (examples are in the comments)
  6. Board: ESP32 Dev Module. Tools → Partition Scheme → "Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS)". The sketch is about 1.16 MB, which nearly fills the default scheme's app slot.
  7. Flash it once over USB. After that, updates can go over Wi-Fi.
  8. Open the serial monitor at 115200 baud (the same rate as the ESP32's boot messages) and press reset. You should see the IP address and a reading every 30 seconds.
  9. Browse to http://thermostrip.local/ or http://<its IP>/.

Updating over Wi-Fi

From the Arduino IDE: Tools → Port → thermostrip (network). Upload as usual and enter the OTA password when asked. The network port can take a minute to appear.

From a browser: Sketch → Export Compiled Binary, then go to http://thermostrip.local/update (user admin, your OTA password) and upload the file ending in .ino.bin. Don't upload the bootloader or partitions files. The unit reboots into the new build. Check the Built time on the status page to confirm.

An interrupted update is harmless: the ESP32 keeps running the old firmware until a new one is complete and verified.

Settings worth knowing

All near the top of the sketch:

SettingDefaultWhat it does
BRIGHTNESS10Strip brightness, 0–255
SCALE_LO_F / SCALE_HI_F16 / 123Temperatures at the bottom and top LED
POLL_MS30 sHow often the sensor is read
MIN_READS2000Readings needed (of ~2880/day) to trust the sensor's own high/low over the weather service
MARKER_WHITE255Brightness of the white high/low markers
DEFAULT_PATTERN2Pattern on first boot
HOSTNAMEthermostripNetwork name (thermostrip.local)
LOG_SERIAL10 silences the serial port
STRIP_ENABLED, USE_OTA, USE_WEB, WIFI_NOSLEEP1Feature switches, handy for troubleshooting

Gotchas

  • Page won't load? Many browsers silently switch http:// to https://, and the ESP32 only serves plain HTTP. Type the full http:// address and bookmark it, or add an exception to your browser's HTTPS-only setting. A DHCP reservation for the ESP32 keeps the IP-address bookmark valid.
  • Garbage on the serial monitor. With ESP32 core 3.3.x I see an occasional burst of junk characters, or a line repeated many times, even with the strip disconnected. It's only the serial output; the readings and display are unaffected. Set LOG_SERIAL 0 if it bothers you.
  • Changing the board package can reset Tools menu settings, including the partition scheme. Check it before flashing over USB.
  • Mesh Wi-Fi: the ESP32 joins the first access point it hears with your network name, which isn't always the strongest. The signal strength on the status page can vary between reboots for that reason.
  • After a reboot mid-day, the first line on the serial monitor may show today's high/low as --/--. That's just the reading landing before the clock syncs; it fills in a second later.

License

Released under the MIT License: use, modify and share it freely, keeping the copyright notice. The full license text is at the top of the sketch.

Copyright (c) 2026 Tobias's Point of View

Downloads

Source

secrets.h (template):

// secrets.h - your credentials. Keep this file out of anything you publish.
// Part of ledstrip_thermometer, MIT License (see ledstrip_thermometer.ino).
#pragma once

#define WIFI_SSID "your-wifi-name"
#define WIFI_PASS "your-wifi-password"

// Password for OTA updates: Arduino IDE network port, and the /update web
// page (login "admin" / this password).
#define OTA_PASS  "pick-a-password"

ledstrip_thermometer.ino:

// ledstrip_thermometer.ino
//
// MIT License
//
// Copyright (c) 2026 Tobias's Point of View (https://pov.us/)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// Outdoor thermometer, indoor display. ESP32 + 144 x WS2812 strip.
//  - Polls the outdoor sensor's web server for temperature (and humidity).
//  - Shows it on the strip in one of 7 patterns (button on GPIO18 cycles them).
//  - Tracks today's high/low from the sensor; at midnight it becomes
//    "yesterday" and is shown as two white pixels (patterns 1-3).
//    If there's no complete sensor record for yesterday (rebooted mid-day,
//    sensor down a lot, first boot) it asks Open-Meteo instead.
//  - Firmware updates over Wi-Fi: Arduino IDE network port (ArduinoOTA)
//    or browser upload of a .bin at http://thermostrip.local/update
//  - Status page at http://thermostrip.local/
//
// Libraries: Adafruit NeoPixel, ArduinoJson (v7). Everything else comes
// with the ESP32 Arduino core.
// Board: ESP32 Dev Module. Partition Scheme: one with OTA, e.g.
//   "Minimal SPIFFS (1.9MB APP with OTA/190KB SPIFFS)".

#include <WiFi.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <Update.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <Adafruit_NeoPixel.h>
#include <time.h>
#include <esp_task_wdt.h>
#include "secrets.h"  // WIFI_SSID, WIFI_PASS, OTA_PASS

static const char* VERSION = "ledstrip_thermometer 1.0";
static const char* BUILT = __DATE__ " " __TIME__;

// ------------------------------------------------------------------ config
#define HOSTNAME "thermostrip"
#define WEB_USER "admin"

#define HEARTBEAT_PIN 2   // on-board LED
#define BUTTON_PIN 18     // momentary to GND
#define STRIP_PIN 4
#define NUM_PIXELS 144
#define BRIGHTNESS 10

// Feature switches. All 1 for normal use.
// LOG_SERIAL 0 silences the serial port (on ESP32 core 3.3.x the serial
// output occasionally shows bursts of garbage; it's cosmetic).
#define STRIP_ENABLED 1  // 0 = never send data to the LED strip
#define USE_OTA 1        // Arduino IDE network-port updates
#define USE_WEB 1        // status page + /update browser uploads + mDNS http
#define WIFI_NOSLEEP 1   // 1 = Wi-Fi power saving off (0 = core default)
#define LOG_SERIAL 1     // 0 = write nothing to the serial port (status page still works)
// NOTE: with USE_OTA and USE_WEB both 0 the board can only be updated over USB.

// Serial logging, switchable with LOG_SERIAL.
#if LOG_SERIAL
#define LOGF(...) Serial.printf(__VA_ARGS__)
#define LOGLN(x) Serial.println(x)
#define LOG(x) Serial.print(x)
#else
#define LOGF(...) do {} while (0)
#define LOGLN(x) do {} while (0)
#define LOG(x) do {} while (0)
#endif

// Strip scale: pixel 0 = 16 F, last pixel = 123 F (~0.75 F per LED)
const float SCALE_LO_F = 16.0;
const float SCALE_HI_F = 123.0;

// Outdoor sensor: any device that answers these URLs with a plain-text number,
// e.g. "72.4" (deg F) and "35" (% RH).  Change the IP to your sensor's.
const char* URL_TEMP = "http://192.168.1.50/temperature";
const char* URL_HUMI = "http://192.168.1.50/humidity";

// Weather service fallback for yesterday's high/low (Open-Meteo, no API key).
// Set to your location; two decimals (~1 km) is plenty.
const char* LATITUDE = "00.00";
const char* LONGITUDE = "00.00";
// Your time zone: POSIX string for the ESP32 clock, and the IANA name
// (with "/" written as %2F) for Open-Meteo. Examples:
//   US Pacific  "PST8PDT,M3.2.0,M11.1.0"   "America%2FLos_Angeles"
//   US Mountain "MST7MDT,M3.2.0,M11.1.0"   "America%2FDenver"
//   US Central  "CST6CDT,M3.2.0,M11.1.0"   "America%2FChicago"
//   US Eastern  "EST5EDT,M3.2.0,M11.1.0"   "America%2FNew_York"
const char* TZ_POSIX = "PST8PDT,M3.2.0,M11.1.0";
const char* TZ_URL = "America%2FLos_Angeles";

const uint32_t POLL_MS = 30UL * 1000;             // normal sensor poll
const uint32_t RETRY_MS = 5UL * 1000;             // poll after a failed read
const uint8_t MAX_FAILS = 60;                     // consecutive failures -> reboot (~5+ min)
const uint32_t WIFI_LOST_MS = 5UL * 60 * 1000;    // Wi-Fi down this long -> reboot
const uint32_t SAVE_TODAY_MS = 10UL * 60 * 1000;  // persist today's running hi/lo
const uint32_t SAVE_PATTERN_MS = 5UL * 60 * 1000; // persist pattern once stable this long
const uint32_t WX_RETRY_MS = 15UL * 60 * 1000;    // retry weather service
const uint16_t MIN_READS = 2000;                  // of ~2880/day, to trust sensor hi/lo
const uint32_t DEBOUNCE_MS = 250;
const uint32_t WDT_MS = 30UL * 1000;              // main loop stalled this long -> reboot
const uint8_t MARKER_WHITE = 255;                 // strip brightness still applies

const uint8_t N_PATTERNS = 7;
const uint8_t DEFAULT_PATTERN = 2;
const char* PATTERN_NAMES[] = { "", "bar, solid color", "bar, gradient", "dot",
                                "test: sweeping dot", "test: sweeping bar",
                                "test: full scale", "test: 10 deg ticks" };

// ------------------------------------------------------------------ state
Adafruit_NeoPixel strip(NUM_PIXELS, STRIP_PIN, NEO_GRB + NEO_KHZ800);
#if USE_WEB
WebServer server(80);
#endif
Preferences prefs;

float tempF = NAN;
float humidity = NAN;
uint32_t lastPollMs = 0, lastGoodMs = 0, pollInterval = 0;
bool firstPoll = true;
uint8_t failCount = 0;

uint8_t pattern = DEFAULT_PATTERN, savedPattern = DEFAULT_PATTERN;
uint32_t patternChangedMs = 0;
volatile bool buttonFlag = false;
uint32_t lastButtonMs = 0;

bool needRedraw = true;
uint32_t lastRenderMs = 0, animStep = 0;

// Dates are YYYYMMDD, local time.
struct Today { uint32_t date; float hi, lo; bool complete; uint16_t n; };
enum Source : uint8_t { SRC_NONE, SRC_SENSOR, SRC_WEB };
const char* SRC_NAMES[] = { "none", "sensor", "weather service" };
struct Yday { uint32_t date; float hi, lo; Source src; };

Today today = { 0, NAN, NAN, false, 0 };
Yday yday = { 0, NAN, NAN, SRC_NONE };
bool dailyReady = false, ydayValid = false, todayDirty = false;
uint32_t lastTodaySaveMs = 0, lastWxTryMs = 0;
bool wxTried = false;

bool updateAuthed = false;

// ------------------------------------------------------------------ time
bool timeValid() { return time(nullptr) > 1700000000; }

uint32_t localDate(int offsetDays) {
  time_t now = time(nullptr);
  struct tm t;
  localtime_r(&now, &t);
  t.tm_mday += offsetDays;  // noon avoids DST edge cases
  t.tm_hour = 12; t.tm_min = 0; t.tm_sec = 0; t.tm_isdst = -1;
  mktime(&t);               // normalizes t
  return (t.tm_year + 1900) * 10000 + (t.tm_mon + 1) * 100 + t.tm_mday;
}

int minutesIntoDay() {
  time_t now = time(nullptr);
  struct tm t;
  localtime_r(&now, &t);
  return t.tm_hour * 60 + t.tm_min;
}

String ymdStr(uint32_t d) {
  char b[12];
  snprintf(b, sizeof b, "%04lu-%02lu-%02lu", (unsigned long)(d / 10000),
           (unsigned long)(d / 100 % 100), (unsigned long)(d % 100));
  return String(b);
}

String fmt(float v, int dec) { return isnan(v) ? String("--") : String(v, dec); }
String fmt(float v) { return fmt(v, 1); }

// ------------------------------------------------------------------ persistence
void saveToday() {
  prefs.putUInt("tDate", today.date);
  prefs.putFloat("tHi", today.hi);
  prefs.putFloat("tLo", today.lo);
  prefs.putBool("tCmp", today.complete);
  prefs.putUShort("tN", today.n);
  todayDirty = false;
  lastTodaySaveMs = millis();
}

void saveYday() {
  prefs.putUInt("yDate", yday.date);
  prefs.putFloat("yHi", yday.hi);
  prefs.putFloat("yLo", yday.lo);
  prefs.putUChar("ySrc", yday.src);
}

void loadDaily() {
  today.date = prefs.getUInt("tDate", 0);
  today.hi = prefs.getFloat("tHi", NAN);
  today.lo = prefs.getFloat("tLo", NAN);
  today.complete = prefs.getBool("tCmp", false);
  today.n = prefs.getUShort("tN", 0);
  yday.date = prefs.getUInt("yDate", 0);
  yday.hi = prefs.getFloat("yHi", NAN);
  yday.lo = prefs.getFloat("yLo", NAN);
  yday.src = (Source)prefs.getUChar("ySrc", SRC_NONE);
  LOGF("restored: today %s %s/%s n=%u%s, yesterday %s %s/%s (%s)\n",
                ymdStr(today.date).c_str(), fmt(today.hi).c_str(), fmt(today.lo).c_str(),
                today.n, today.complete ? "" : " partial", ymdStr(yday.date).c_str(),
                fmt(yday.hi).c_str(), fmt(yday.lo).c_str(), SRC_NAMES[yday.src]);
}

void restartSafely(const char* why) {
  LOGF("restarting: %s\n", why);
  if (dailyReady) saveToday();
  if (pattern != savedPattern) prefs.putUChar("pat", pattern);
  delay(200);
  ESP.restart();
}

// ------------------------------------------------------------------ daily hi/lo
// New day: promote today's record to yesterday if it covers the whole day.
// Called at midnight, and at boot when the stored record is from another day.
void rollover(uint32_t newDate) {
  uint32_t yd = localDate(-1);
  if (today.date == yd && today.complete && today.n >= MIN_READS && !isnan(today.hi)) {
    yday = { yd, today.hi, today.lo, SRC_SENSOR };
    saveYday();
    LOGF("rollover: yesterday %s/%s from sensor (%u reads)\n",
                  fmt(yday.hi).c_str(), fmt(yday.lo).c_str(), today.n);
  } else if (today.date == yd) {
    LOGF("rollover: yesterday's sensor record incomplete (%u reads), will ask weather service\n", today.n);
  }
  today = { newDate, NAN, NAN, minutesIntoDay() <= 15, 0 };
  saveToday();
}

void trackTemp(float f) {
  if (!dailyReady) return;  // no clock yet
  if (isnan(today.hi) || f > today.hi) today.hi = f;
  if (isnan(today.lo) || f < today.lo) today.lo = f;
  if (today.n < 65535) today.n++;
  todayDirty = true;
}

bool fetchYesterdayFromWeb() {
  uint32_t want = localDate(-1);
  String url = String("https://api.open-meteo.com/v1/forecast?latitude=") + LATITUDE +
               "&longitude=" + LONGITUDE +
               "&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit" +
               "&timezone=" + TZ_URL + "&past_days=1&forecast_days=1";
  WiFiClientSecure tls;
  tls.setInsecure();  // weather data, not worth managing a CA cert
  HTTPClient http;
  http.setConnectTimeout(5000);
  http.setTimeout(8000);
  if (!http.begin(tls, url)) return false;
  int code = http.GET();
  if (code != HTTP_CODE_OK) {
    LOGF("weather service: HTTP %d\n", code);
    http.end();
    return false;
  }
  JsonDocument doc;
  DeserializationError err = deserializeJson(doc, http.getString());
  http.end();
  if (err) {
    LOGF("weather service: JSON %s\n", err.c_str());
    return false;
  }
  const char* d = doc["daily"]["time"][0];  // "2026-10-27"
  JsonVariant hi = doc["daily"]["temperature_2m_max"][0];
  JsonVariant lo = doc["daily"]["temperature_2m_min"][0];
  if (!d || strlen(d) < 10 || !hi.is<float>() || !lo.is<float>()) {
    LOGLN("weather service: unexpected response");
    return false;
  }
  uint32_t got = atoi(d) * 10000 + atoi(d + 5) * 100 + atoi(d + 8);
  if (got != want) {
    LOGF("weather service: got date %s, wanted %s\n", d, ymdStr(want).c_str());
    return false;
  }
  yday = { want, hi.as<float>(), lo.as<float>(), SRC_WEB };
  LOGF("weather service: yesterday %s/%s\n", fmt(yday.hi).c_str(), fmt(yday.lo).c_str());
  return true;
}

void dailyTick() {
  static uint32_t lastTick = 0;
  if (millis() - lastTick < 1000) return;
  lastTick = millis();
  if (!timeValid()) return;

  if (!dailyReady) {
    loadDaily();
    dailyReady = true;
  }
  uint32_t todayDate = localDate(0);
  if (today.date != todayDate) rollover(todayDate);

  if (todayDirty && millis() - lastTodaySaveMs >= SAVE_TODAY_MS) saveToday();

  uint32_t yd = localDate(-1);
  if (yday.date != yd && WiFi.status() == WL_CONNECTED &&
      (!wxTried || millis() - lastWxTryMs >= WX_RETRY_MS)) {
    wxTried = true;
    lastWxTryMs = millis();
    if (fetchYesterdayFromWeb()) saveYday();
  }
  bool v = (yday.date == yd && !isnan(yday.hi) && !isnan(yday.lo));
  if (v != ydayValid) { ydayValid = v; needRedraw = true; }
}

// ------------------------------------------------------------------ sensor
bool httpGetFloat(const char* url, float& out) {
  WiFiClient client;
  HTTPClient http;
  http.setConnectTimeout(3000);
  http.setTimeout(3000);
  http.setReuse(false);
  if (!http.begin(client, url)) return false;
  int code = http.GET();
  bool ok = false;
  if (code == HTTP_CODE_OK) {
    String s = http.getString();
    s.trim();
    char* end;
    float v = strtof(s.c_str(), &end);
    if (end != s.c_str() && !isnan(v) && v > -60 && v < 160) {
      out = v;
      ok = true;
    } else {
      LOGF("bad payload from %s: '%s'\n", url, s.c_str());
    }
  } else {
    LOGF("GET %s failed: %d %s\n", url, code, http.errorToString(code).c_str());
  }
  http.end();
  return ok;
}

void pollSensor() {
  lastPollMs = millis();
  firstPoll = false;
  if (WiFi.status() != WL_CONNECTED) {
    pollInterval = RETRY_MS;
    return;
  }
  float t;
  if (httpGetFloat(URL_TEMP, t)) {
    tempF = t;
    lastGoodMs = millis();
    failCount = 0;
    pollInterval = POLL_MS;
    float h;
    if (httpGetFloat(URL_HUMI, h)) humidity = h;
    trackTemp(t);
    needRedraw = true;
    String sT = fmt(tempF), sH = fmt(humidity, 0);
    String sTh = fmt(today.hi), sTl = fmt(today.lo);
    String sYh = ydayValid ? fmt(yday.hi) : String("--");
    String sYl = ydayValid ? fmt(yday.lo) : String("--");
    char line[128];
    snprintf(line, sizeof line, "%s F  %s %%  today %s/%s  yday %s/%s (%s)  pattern %u",
             sT.c_str(), sH.c_str(), sTh.c_str(), sTl.c_str(), sYh.c_str(), sYl.c_str(),
             ydayValid ? SRC_NAMES[yday.src] : "none", pattern);
    LOGLN(line);
  } else {
    pollInterval = RETRY_MS;
    if (++failCount >= MAX_FAILS) restartSafely("too many failed sensor reads");
  }
}

// ------------------------------------------------------------------ drawing
void stripShow() {
#if STRIP_ENABLED
  strip.show();
#endif
}

int tempToPixel(float f) {
  long p = lroundf((f - SCALE_LO_F) * (NUM_PIXELS - 1) / (SCALE_HI_F - SCALE_LO_F));
  if (p < 0) p = 0;
  if (p > NUM_PIXELS - 1) p = NUM_PIXELS - 1;
  return (int)p;
}

float pixelToTemp(int i) { return SCALE_LO_F + i * (SCALE_HI_F - SCALE_LO_F) / (NUM_PIXELS - 1); }

// blue (32 F) -> green (63 F) -> red (100 F)
uint32_t tempColor(float f) {
  f = constrain(f, 32.0f, 100.0f);
  uint8_t r, g, b;
  if (f > 63) {
    r = (uint8_t)((f - 63) * 255 / 37);
    g = 255 - r;
    b = 0;
  } else {
    r = 0;
    g = (uint8_t)((f - 32) * 255 / 31);
    b = 255 - g;
  }
  return Adafruit_NeoPixel::Color(r, g, b);
}

int sweepPos() {  // 0 .. N-1 .. 0
  int span = NUM_PIXELS - 1;
  int ph = animStep % (2 * span);
  return ph <= span ? ph : 2 * span - ph;
}

void drawPattern() {
  bool have = !isnan(tempF);
  int top = have ? tempToPixel(tempF) : -1;
  switch (pattern) {
    case 1:  // bar, all in current temp's color
      for (int i = 0; i <= top; i++) strip.setPixelColor(i, tempColor(tempF));
      break;
    case 2:  // bar, each pixel its own color
      for (int i = 0; i < top; i++) strip.setPixelColor(i, tempColor(pixelToTemp(i)));
      if (have) strip.setPixelColor(top, tempColor(tempF));
      break;
    case 3:  // dot
      if (have) strip.setPixelColor(top, tempColor(tempF));
      break;
    case 4:  // test: sweeping dot
      strip.setPixelColor(sweepPos(), tempColor(pixelToTemp(sweepPos())));
      break;
    case 5:  // test: sweeping bar
      for (int i = 0, n = sweepPos(); i <= n; i++) strip.setPixelColor(i, tempColor(pixelToTemp(i)));
      break;
    case 6:  // test: whole scale
      for (int i = 0; i < NUM_PIXELS; i++) strip.setPixelColor(i, tempColor(pixelToTemp(i)));
      break;
    case 7:  // test: 20..120 F every 10
      for (int f = 20; f <= 120; f += 10) strip.setPixelColor(tempToPixel(f), tempColor(f));
      break;
  }
}

// Where a marker goes: its own pixel, or one up if that's the current-temp
// pixel (one down at the very top of the strip).
int markerPixel(float f, int cur) {
  int p = tempToPixel(f);
  if (p != cur) return p;
  return (p < NUM_PIXELS - 1) ? p + 1 : p - 1;
}

void drawMarkers() {  // yesterday's high/low as white pixels
  if (!ydayValid || pattern > 3) return;
  int cur = isnan(tempF) ? -1 : tempToPixel(tempF);
  uint32_t w = Adafruit_NeoPixel::Color(MARKER_WHITE, MARKER_WHITE, MARKER_WHITE);
  strip.setPixelColor(markerPixel(yday.hi, cur), w);
  strip.setPixelColor(markerPixel(yday.lo, cur), w);
}

void render() {
  bool animated = (pattern == 4 || pattern == 5);
  // Static patterns redraw only when something changed (new reading, pattern,
  // markers) - same as the old sketch. Animated test patterns run at ~33 fps.
  if (animated) {
    if (!needRedraw && millis() - lastRenderMs < 30) return;
  } else if (!needRedraw) {
    return;
  }
  lastRenderMs = millis();
  needRedraw = false;
  if (animated) animStep++;
  strip.clear();
  drawPattern();
  drawMarkers();
  stripShow();
}

void showProgress(uint32_t done, uint32_t total) {  // blue bar during OTA
  static int last = -1;
  int n = total ? (uint64_t)done * NUM_PIXELS / total : 0;
  if (n == last) return;
  last = n;
  strip.clear();
  for (int i = 0; i < n; i++) strip.setPixelColor(i, 0, 0, 255);
  stripShow();
}

// ------------------------------------------------------------------ button / pattern
void IRAM_ATTR onButton() { buttonFlag = true; }

void setPattern(uint8_t p) {
  if (p < 1 || p > N_PATTERNS) return;
  pattern = p;
  patternChangedMs = millis();
  animStep = 0;
  needRedraw = true;
  LOGF("pattern %u: %s\n", pattern, PATTERN_NAMES[pattern]);
}

void handleButton() {
  if (!buttonFlag) return;
  buttonFlag = false;
  // accept only if still held (rejects release bounce) and not too soon
  if (digitalRead(BUTTON_PIN) == LOW && millis() - lastButtonMs > DEBOUNCE_MS) {
    lastButtonMs = millis();
    setPattern(pattern % N_PATTERNS + 1);
  }
}

void savePatternIfStable() {
  if (pattern != savedPattern && millis() - patternChangedMs >= SAVE_PATTERN_MS) {
    prefs.putUChar("pat", pattern);
    savedPattern = pattern;
    LOGF("saved pattern %u\n", pattern);
  }
}

// ------------------------------------------------------------------ web
const char* resetReason() {
  switch (esp_reset_reason()) {
    case ESP_RST_POWERON: return "power on";
    case ESP_RST_SW: return "software (restart / update)";
    case ESP_RST_PANIC: return "crash";
    case ESP_RST_INT_WDT: return "interrupt watchdog";
    case ESP_RST_TASK_WDT: return "watchdog (loop stalled)";
    case ESP_RST_WDT: return "watchdog";
    case ESP_RST_BROWNOUT: return "brownout";
    case ESP_RST_EXT: return "reset pin";
    default: return "other";
  }
}

#if USE_WEB
void row(String& h, const char* k, const String& v) {
  h += "<tr><td>"; h += k; h += "</td><td>"; h += v; h += "</td></tr>";
}

void handleRoot() {
  String h;
  h.reserve(3000);
  h += F("<!doctype html><html><head><meta name=viewport content='width=device-width'>"
         "<meta http-equiv=refresh content=30><title>thermostrip</title><style>"
         "body{font-family:monospace;background:#111;color:#ddd;margin:1em}"
         "a{color:#8cf}td{padding:2px 12px 2px 0}</style></head><body><h3>thermostrip</h3><table>");
  row(h, "Outside", fmt(tempF) + " &deg;F &nbsp; " + fmt(humidity, 0) + " %");
  row(h, "Last good read", lastGoodMs ? String((millis() - lastGoodMs) / 1000) + " s ago" : String("never"));
  row(h, "Today hi / lo", fmt(today.hi) + " / " + fmt(today.lo) + " (" + today.n + " reads" +
                            (today.complete ? "" : ", partial day") + ")");
  row(h, "Yesterday hi / lo", ydayValid ? fmt(yday.hi) + " / " + fmt(yday.lo) + " (" + SRC_NAMES[yday.src] + ")"
                                        : String("--"));
  row(h, "Pattern", String(pattern) + ": " + PATTERN_NAMES[pattern]);
  row(h, "Version", VERSION);
  row(h, "Built", BUILT);
  row(h, "IP / RSSI", WiFi.localIP().toString() + " / " + WiFi.RSSI() + " dBm");
  row(h, "Uptime", String(millis() / 3600000.0, 1) + " h");
  row(h, "Last reset", resetReason());
  h += F("</table><p>Pattern:");
  for (int i = 1; i <= N_PATTERNS; i++) {
    h += " <a href='/pattern?n="; h += i; h += "' title='"; h += PATTERN_NAMES[i]; h += "'>"; h += i; h += "</a>";
  }
  h += F("</p><p><a href='/update'>firmware update</a></p></body></html>");
  server.send(200, "text/html", h);
}

void handlePattern() {
  setPattern(server.arg("n").toInt());
  server.sendHeader("Location", "/");
  server.send(303);
}

void handleUpdatePage() {
  if (!server.authenticate(WEB_USER, OTA_PASS)) return server.requestAuthentication();
  server.send(200, "text/html",
              F("<!doctype html><html><head><meta name=viewport content='width=device-width'>"
                "<title>thermostrip update</title></head><body style='font-family:monospace'>"
                "<h3>Upload firmware (.bin)</h3>"
                "<form method=POST action='/update' enctype='multipart/form-data'>"
                "<input type=file name=fw accept='.bin'> <input type=submit value=Upload></form>"
                "<p><a href='/'>back</a></p></body></html>"));
}

void handleUpdateUpload() {
  HTTPUpload& up = server.upload();
  if (up.status == UPLOAD_FILE_START) {
    updateAuthed = server.authenticate(WEB_USER, OTA_PASS);
    if (!updateAuthed) return;
    LOGF("web OTA: %s\n", up.filename.c_str());
    if (dailyReady) saveToday();
    if (!Update.begin(UPDATE_SIZE_UNKNOWN)) Update.printError(Serial);
  } else if (up.status == UPLOAD_FILE_WRITE) {
    esp_task_wdt_reset();  // uploads can take a minute
    if (!updateAuthed) return;
    if (Update.write(up.buf, up.currentSize) != up.currentSize) Update.printError(Serial);
    strip.clear();  // size unknown, so just a moving blue pixel
    strip.setPixelColor((up.totalSize / 8192) % NUM_PIXELS, 0, 0, 255);
    stripShow();
  } else if (up.status == UPLOAD_FILE_END) {
    if (!updateAuthed) return;
    if (Update.end(true)) LOGF("web OTA: %u bytes ok\n", up.totalSize);
    else Update.printError(Serial);
  } else if (up.status == UPLOAD_FILE_ABORTED) {
    Update.abort();
    needRedraw = true;
  }
}

void handleUpdateDone() {
  if (!updateAuthed) return server.requestAuthentication();
  bool ok = !Update.hasError();
  server.sendHeader("Connection", "close");
  server.send(200, "text/plain", ok ? "Update OK, rebooting." : "Update FAILED, see serial log.");
  if (ok) {
    delay(500);
    ESP.restart();
  }
  needRedraw = true;
}

#endif  // USE_WEB

// ------------------------------------------------------------------ misc
void heartbeat() {  // 25 ms on / 1975 ms off
  static uint32_t last = 0;
  static bool on = false;
  if (millis() - last >= (on ? 25 : 1975)) {
    last = millis();
    on = !on;
    digitalWrite(HEARTBEAT_PIN, on);
  }
}

void wifiWatchdog() {
  static uint32_t lostSince = 0;
  if (WiFi.status() == WL_CONNECTED) {
    lostSince = 0;
  } else if (!lostSince) {
    lostSince = millis();
    LOGLN("Wi-Fi lost");
  } else if (millis() - lostSince > WIFI_LOST_MS) {
    restartSafely("Wi-Fi down too long");
  }
}

// ------------------------------------------------------------------ setup / loop
void setup() {
  Serial.begin(115200);
  delay(500);
  LOGF("\n%s\nbuilt %s\nlast reset: %s\n", VERSION, BUILT, resetReason());

  pinMode(HEARTBEAT_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButton, FALLING);

  strip.begin();
  strip.setBrightness(BRIGHTNESS);
  strip.clear();
  stripShow();

  prefs.begin("thermo", false);
  pattern = savedPattern = prefs.getUChar("pat", DEFAULT_PATTERN);
  if (pattern < 1 || pattern > N_PATTERNS) pattern = savedPattern = DEFAULT_PATTERN;

  WiFi.setHostname(HOSTNAME);
  WiFi.mode(WIFI_STA);
#if WIFI_NOSLEEP
  WiFi.setSleep(false);  // snappier web/OTA
#endif
  WiFi.setAutoReconnect(true);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  LOG("connecting to Wi-Fi");
  for (int i = 0; WiFi.status() != WL_CONNECTED; i++) {
    if (i >= 30) restartSafely("no Wi-Fi after 30 s");
    delay(1000);
    LOG(".");
  }
  LOGF("\nIP %s  RSSI %d dBm\n", WiFi.localIP().toString().c_str(), WiFi.RSSI());

  configTzTime(TZ_POSIX, "pool.ntp.org", "time.nist.gov");

#if USE_OTA
  ArduinoOTA.setHostname(HOSTNAME);
  ArduinoOTA.setPassword(OTA_PASS);
  ArduinoOTA.onStart([]() {
    LOGLN("OTA start");
    if (dailyReady) saveToday();
  });
  ArduinoOTA.onProgress([](unsigned int done, unsigned int total) {
    esp_task_wdt_reset();
    showProgress(done, total);
  });
  ArduinoOTA.onEnd([]() { LOGLN("OTA done, rebooting"); });
  ArduinoOTA.onError([](ota_error_t e) {
    LOGF("OTA error %u\n", e);
    needRedraw = true;
  });
  ArduinoOTA.begin();  // also starts mDNS as thermostrip.local
#else
  if (USE_WEB) MDNS.begin(HOSTNAME);
#endif

#if USE_WEB
  MDNS.addService("http", "tcp", 80);
  server.on("/", HTTP_GET, handleRoot);
  server.on("/pattern", HTTP_GET, handlePattern);
  server.on("/update", HTTP_GET, handleUpdatePage);
  server.on("/update", HTTP_POST, handleUpdateDone, handleUpdateUpload);
  server.begin();
#endif

  // Watchdog on the main loop: if loop() stops running for WDT_MS, reboot.
  esp_task_wdt_config_t wdt = { .timeout_ms = WDT_MS, .idle_core_mask = 0, .trigger_panic = true };
  if (esp_task_wdt_reconfigure(&wdt) != ESP_OK) esp_task_wdt_init(&wdt);  // init if core didn't
  esp_task_wdt_add(NULL);
}

void loop() {
  esp_task_wdt_reset();
#if USE_OTA
  ArduinoOTA.handle();
#endif
#if USE_WEB
  server.handleClient();
#endif
  heartbeat();
  handleButton();
  if (firstPoll || millis() - lastPollMs >= pollInterval) pollSensor();
  dailyTick();
  savePatternIfStable();
  render();
  wifiWatchdog();
}

top level subjects:

Page last modified on October 31, 2026, at 05:47 PM

^