| tobias's point of view |
|
Main »
LED Strip Thermometercity of baker style linear strip led thermometer
LED Strip ThermometerAn 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
Display patterns
Colors: blue at 32 °F and below, green at 63 °F, red at 100 °F and above, blended in between. Hardware
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 sensorAny 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
Updating over Wi-FiFrom the Arduino IDE: Tools → Port → From a browser: Sketch → Export Compiled Binary, then go to An interrupted update is harmless: the ESP32 keeps running the old firmware until a new one is complete and verified. Settings worth knowingAll near the top of the sketch:
Gotchas
LicenseReleased 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 DownloadsSource
// 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
//
// 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) + " °F " + 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 |