ProbeHarbor on a 16x2 LCD
Your ProbeHarbor sensors on the classic 16x2 character LCD. Turn a rotary encoder to step through them, press it for the device and the reading's age, and watch for the ! that marks freezing, water or lost power.
For ProbeHarbor, open-source freeze and flood monitoring for garages, workshops, attics and crawlspaces, with live charts and alerts. This board shows readings from your ProbeHarbor sensors through a read-only share link, made under Dashboard, Share at probeharbor.dev.
Wiring
| From | To board | Note |
|---|---|---|
| LCD 1 VSS | GND | |
| LCD 2 VDD | 5V | The LCD runs on 5 V |
| LCD 3 V0 | Trimmer wiper | Trimmer ends to 5V and GND. Turn it until the characters show |
| LCD 4 RS | GPIO 13 | |
| LCD 5 RW | GND | Always. Tied low, the LCD only listens and never sends 5 V back to the ESP32 |
| LCD 6 E | GPIO 14 | |
| LCD 11 D4 | GPIO 25 | 4-bit mode: D0 to D3 stay unconnected |
| LCD 12 D5 | GPIO 26 | |
| LCD 13 D6 | GPIO 27 | |
| LCD 14 D7 | GPIO 33 | |
| LCD 15 A (backlight +) | 5V | Many modules have the backlight resistor on board; if yours does not, add about 100 Ω |
| LCD 16 K (backlight -) | GND, or the transistor | Optional blink: K to the NPN collector, emitter to GND, base through 1 kΩ to GPIO 4 |
| Encoder A / CLK | GPIO 18 | The encoder's middle pin goes to GND. Internal pull-ups are used |
| Encoder B / DT | GPIO 19 | |
| Encoder push switch / SW | GPIO 23 | Other switch leg to GND |
| KY-040 board + (if you have one) | 3V3 | Never 5 V: its pull-ups would put 5 V on the GPIOs |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32 Dev Module
// ProbeHarbor on a 16x2 LCD: turn the knob to step through your sensors,
// press it for the device name and how old the reading is.
// Reads a ProbeHarbor share link (read-only, works on the free plan) once a minute.
// Libraries: ArduinoJson, LiquidCrystal.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <LiquidCrystal.h>
#include <ArduinoJson.h>
#include <time.h>
// ---- Your settings ----
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASS = "your-password";
// ProbeHarbor: Dashboard, Share, create a live link, copy its readings address.
const char* SHARE_URL = "https://probeharbor.dev/api/share/YOUR_SHARE_TOKEN/readings";
const unsigned REFRESH_SECONDS = 60; // ProbeHarbor allows 60 requests a minute per link
const float FREEZE_F = 35.0; // temperatures at or below this raise the alarm
const unsigned STALE_MINUTES = 30; // readings older than this are marked stale
// ---- Pins ----
LiquidCrystal lcd(13, 14, 25, 26, 27, 33); // RS, E, D4, D5, D6, D7
const int ENC_A = 18; // encoder CLK / A
const int ENC_B = 19; // encoder DT / B
const int ENC_SW = 23; // encoder push button
const int BACKLIGHT = 4; // transistor that switches the backlight, or -1 if it is wired to 5 V
// Root certificates for probeharbor.dev: Google Trust Services R1 and R4, and
// Let's Encrypt ISRG X1 and X2. Cloudflare issues the site's certificate from one of these.
const char* ROOT_CAS = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
)PEM";
struct Reading {
char device[24];
char label[24];
char kind[14];
char unit[10];
bool hasNum, hasBool;
float num;
bool flag;
time_t at;
};
const int MAX_READINGS = 16;
Reading readings[MAX_READINGS];
int count = 0, page = 0;
char status[40] = "Starting";
unsigned long lastFetch = 0, retryAfterMs = 0;
bool detailView = false; // true while the device name and age are shown
// ---- Rotary encoder: a table of valid Gray-code steps, so contact bounce cancels out ----
volatile int encSteps = 0;
uint8_t encState = 0;
const int8_t ENC_TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
portMUX_TYPE encLock = portMUX_INITIALIZER_UNLOCKED;
// A small background task samples the encoder every millisecond. That is quick
// enough for a knob turned by hand, and it keeps counting while loop() waits on
// ProbeHarbor, without the interrupt and fast-RAM rules an ISR would need.
void pollEncoder(void*) {
for (;;) {
encState = (uint8_t)(((encState << 2) | (digitalRead(ENC_A) << 1) | digitalRead(ENC_B)) & 0x0F);
const int8_t step = ENC_TABLE[encState];
if (step) {
taskENTER_CRITICAL(&encLock);
encSteps += step;
taskEXIT_CRITICAL(&encLock);
}
vTaskDelay(1);
}
}
// Most EC11 / KY-040 encoders give four steps per click.
int takeClicks() {
taskENTER_CRITICAL(&encLock);
const int clicks = encSteps / 4;
encSteps -= clicks * 4;
taskEXIT_CRITICAL(&encLock);
return clicks;
}
// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm), so we can
// turn "2026-11-12T15:50:44Z" into a UTC timestamp without timegm().
long daysFromCivil(int y, unsigned m, unsigned d) {
y -= m <= 2;
const long era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = (unsigned)(y - era * 400);
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + (long)doe - 719468;
}
time_t parseTime(const char* s) {
int Y, M, D, h, m, sec;
if (!s || sscanf(s, "%4d-%2d-%2d%*c%2d:%2d:%2d", &Y, &M, &D, &h, &m, &sec) != 6) return 0;
time_t t = (time_t)daysFromCivil(Y, M, D) * 86400 + h * 3600 + m * 60 + sec;
// Optional offset after the seconds: Z, +HH, +HH:MM, -HHMM ...
const char* p = s + 19;
while (*p == '.' || isdigit((unsigned char)*p)) p++;
if (*p == '+' || *p == '-') {
int oh = 0, om = 0;
sscanf(p + 1, "%2d%*[:]%2d", &oh, &om);
if (om == 0) sscanf(p + 1, "%2d%2d", &oh, &om);
const long off = oh * 3600L + om * 60L;
t += (*p == '+') ? -off : off;
}
return t;
}
void copyText(char* to, size_t size, const char* from) {
snprintf(to, size, "%s", from ? from : "");
}
const char* boolWord(const Reading& r) {
if (!strcmp(r.kind, "door")) return r.flag ? "OPEN" : "CLOSED";
if (!strcmp(r.kind, "power")) return r.flag ? "ON" : "OFF";
if (!strcmp(r.kind, "flood")) return r.flag ? "WET" : "DRY";
if (!strcmp(r.kind, "motion")) return r.flag ? "MOTION" : "CLEAR";
return r.flag ? "YES" : "NO";
}
// The things ProbeHarbor exists to catch: freezing, water, lost power.
bool alarming(const Reading& r) {
if (r.hasNum && !strcmp(r.kind, "temperature")) {
const bool celsius = strstr(r.unit, "C") != nullptr;
return r.num <= (celsius ? (FREEZE_F - 32) * 5 / 9 : FREEZE_F);
}
if (r.hasBool && !strcmp(r.kind, "flood")) return r.flag;
if (r.hasBool && !strcmp(r.kind, "power")) return !r.flag;
return false;
}
long ageMinutes(const Reading& r) {
const time_t now = time(nullptr);
if (now < 1700000000 || r.at == 0) return -1; // clock not set yet
return (long)(now - r.at) / 60;
}
void fetchReadings() {
WiFiClientSecure tls;
tls.setCACert(ROOT_CAS);
HTTPClient http;
http.useHTTP10(true); // a plain body, so ArduinoJson can read it as it arrives
const char* keep[] = {"Retry-After"};
http.collectHeaders(keep, 1);
if (!http.begin(tls, SHARE_URL)) {
copyText(status, sizeof(status), "Bad SHARE_URL");
return;
}
const int code = http.GET();
if (code == 200) {
JsonDocument filter;
JsonObject f = filter["readings"].add<JsonObject>();
for (const char* k : {"device", "label", "kind", "unit", "value_num", "value_bool", "recorded_at"}) f[k] = true;
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter));
if (err) {
snprintf(status, sizeof(status), "Bad reply: %s", err.c_str());
} else {
count = 0;
for (JsonObject r : doc["readings"].as<JsonArray>()) {
if (count == MAX_READINGS) break;
Reading& x = readings[count++];
copyText(x.device, sizeof(x.device), r["device"] | "");
copyText(x.label, sizeof(x.label), r["label"] | "");
copyText(x.kind, sizeof(x.kind), r["kind"] | "generic");
copyText(x.unit, sizeof(x.unit), r["unit"] | "");
x.hasNum = r["value_num"].is<float>();
x.num = r["value_num"] | 0.0f;
x.hasBool = r["value_bool"].is<bool>();
x.flag = r["value_bool"] | false;
x.at = parseTime(r["recorded_at"] | "");
}
snprintf(status, sizeof(status), "%d sensors", count);
if (page >= count) page = 0;
for (int i = 0; i < count; i++) {
const Reading& r = readings[i];
// "Garage: Freezer_probe = 4.2 °F (at 15:50 UTC)": one name per sensor, so the bench plots each one.
char name[sizeof(r.label)];
copyText(name, sizeof(name), r.label);
for (char* c = name; *c; c++) if (!isalnum((unsigned char)*c)) *c = '_';
Serial.printf("%s: %s = ", r.device, name);
if (r.hasNum) Serial.printf("%.1f %s", r.num, r.unit);
else if (r.hasBool) Serial.print(boolWord(r));
else Serial.print("no value");
if (r.at) {
struct tm t;
gmtime_r(&r.at, &t);
Serial.printf(" (at %02d:%02d UTC)", t.tm_hour, t.tm_min);
}
Serial.println();
if (alarming(r)) Serial.printf("ALERT %s: %s\n", r.device, r.label);
}
}
} else if (code == 404) {
copyText(status, sizeof(status), "Link invalid or expired");
} else if (code == 429) {
retryAfterMs = 1000UL * (unsigned long)max(60L, http.header("Retry-After").toInt());
copyText(status, sizeof(status), "Too many requests");
} else {
snprintf(status, sizeof(status), "HTTP %d", code);
}
if (code != 200) Serial.printf("ProbeHarbor: %s\n", status);
http.end();
}
// The LCD's character set has a degree sign (0xDF) and a micro sign (0xE4).
void printUnit(const char* unit) {
for (const char* p = unit; *p; p++) {
if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB0) { lcd.write((uint8_t)0xDF); p++; } // °
else if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB5) { lcd.write((uint8_t)0xE4); p++; } // µ
else if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB3) { lcd.write('3'); p++; } // ³
else if ((uint8_t)p[0] < 0x80) lcd.write(p[0]);
}
}
// Print text padded or cut to exactly \`width\` characters, so old text never lingers.
void printFixed(const char* text, int width) {
int n = 0;
for (; text[n] && n < width; n++) lcd.write(text[n]);
for (; n < width; n++) lcd.write(' ');
}
void draw() {
static char shown[2][17] = {"", ""};
char top[17], bottom[17];
if (count == 0) {
snprintf(top, sizeof(top), "ProbeHarbor");
snprintf(bottom, sizeof(bottom), "%.16s", status);
} else {
const Reading& r = readings[page];
const bool alarm = alarming(r);
const long age = ageMinutes(r);
if (!detailView) {
// Top: the sensor's label, with "!" at the end when it is alarming.
snprintf(top, sizeof(top), "%-15.15s%c", r.label, alarm ? '!' : ' ');
bottom[0] = 0; // drawn below, because of the unit characters
} else {
snprintf(top, sizeof(top), "%.16s", r.device);
if (age >= (long)STALE_MINUTES) snprintf(bottom, sizeof(bottom), "STALE %ldm", age);
else if (age >= 0) snprintf(bottom, sizeof(bottom), "%ldm ago", age);
else snprintf(bottom, sizeof(bottom), "time not set");
}
// Blink the backlight while the shown sensor is alarming, if a transistor drives it.
if (BACKLIGHT >= 0) digitalWrite(BACKLIGHT, alarm && (millis() / 500) % 2 ? LOW : HIGH);
}
if (strcmp(top, shown[0])) {
lcd.setCursor(0, 0);
printFixed(top, 16);
snprintf(shown[0], sizeof(shown[0]), "%s", top);
}
if (count > 0 && !detailView) {
const Reading& r = readings[page];
char value[17], pos[8];
if (r.hasNum) snprintf(value, sizeof(value), "%.1f ", r.num);
else if (r.hasBool) snprintf(value, sizeof(value), "%s", boolWord(r));
else snprintf(value, sizeof(value), "--");
snprintf(pos, sizeof(pos), "%d/%d", page + 1, count);
char key[40];
snprintf(key, sizeof(key), "%s|%s|%s", value, r.unit, pos);
if (strcmp(key, shown[1])) { // only redraw when something changed: no flicker
lcd.setCursor(0, 1);
printFixed("", 16);
lcd.setCursor(0, 1);
lcd.print(value);
if (r.hasNum) printUnit(r.unit);
lcd.setCursor(16 - (int)strlen(pos), 1);
lcd.print(pos);
snprintf(shown[1], sizeof(shown[1]), "%.16s", key);
}
} else if (strcmp(bottom, shown[1])) {
lcd.setCursor(0, 1);
printFixed(bottom, 16);
snprintf(shown[1], sizeof(shown[1]), "%s", bottom);
}
}
void setup() {
Serial.begin(115200);
lcd.begin(16, 2);
if (BACKLIGHT >= 0) {
pinMode(BACKLIGHT, OUTPUT);
digitalWrite(BACKLIGHT, HIGH);
}
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
encState = (uint8_t)((digitalRead(ENC_A) << 1) | digitalRead(ENC_B));
xTaskCreate(pollEncoder, "encoder", 2048, nullptr, 2, nullptr); // above loop()'s priority
copyText(status, sizeof(status), "Joining Wi-Fi");
draw();
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.printf("wifi ok %s\n", WiFi.localIP().toString().c_str());
configTime(0, 0, "pool.ntp.org", "time.google.com"); // UTC, for "minutes ago"
copyText(status, sizeof(status), "Fetching");
draw();
}
void loop() {
const unsigned long now = millis();
const unsigned long every = retryAfterMs ? retryAfterMs : REFRESH_SECONDS * 1000UL;
if (lastFetch == 0 || now - lastFetch >= every) {
lastFetch = now ? now : 1;
retryAfterMs = 0;
if (WiFi.status() == WL_CONNECTED) fetchReadings();
else {
copyText(status, sizeof(status), "Wi-Fi lost");
WiFi.reconnect();
}
}
const int clicks = takeClicks();
if (clicks && count) page = ((page + clicks) % count + count) % count;
static bool wasDown = false;
const bool down = digitalRead(ENC_SW) == LOW;
if (down && !wasDown) detailView = !detailView; // press: toggle the detail view
wasDown = down;
draw();
delay(20);
}
Wiring
| From | To board | Note |
|---|---|---|
| LCD 1 VSS | GND | |
| LCD 2 VDD | 5V | The LCD runs on 5 V |
| LCD 3 V0 | Trimmer wiper | Trimmer ends to 5V and GND. Turn it until the characters show |
| LCD 4 RS | GPIO 4 | |
| LCD 5 RW | GND | Always. Tied low, the LCD only listens and never sends 5 V back to the ESP32 |
| LCD 6 E | GPIO 5 | |
| LCD 11 D4 | GPIO 6 | 4-bit mode: D0 to D3 stay unconnected |
| LCD 12 D5 | GPIO 7 | |
| LCD 13 D6 | GPIO 15 | |
| LCD 14 D7 | GPIO 16 | |
| LCD 15 A (backlight +) | 5V | Many modules have the backlight resistor on board; if yours does not, add about 100 Ω |
| LCD 16 K (backlight -) | GND, or the transistor | Optional blink: K to the NPN collector, emitter to GND, base through 1 kΩ to GPIO 9 |
| Encoder A / CLK | GPIO 17 | The encoder's middle pin goes to GND. Internal pull-ups are used |
| Encoder B / DT | GPIO 18 | |
| Encoder push switch / SW | GPIO 8 | Other switch leg to GND |
| KY-040 board + (if you have one) | 3V3 | Never 5 V: its pull-ups would put 5 V on the GPIOs |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32S3 Dev Module
// ProbeHarbor on a 16x2 LCD: turn the knob to step through your sensors,
// press it for the device name and how old the reading is.
// Reads a ProbeHarbor share link (read-only, works on the free plan) once a minute.
// Libraries: ArduinoJson, LiquidCrystal.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <LiquidCrystal.h>
#include <ArduinoJson.h>
#include <time.h>
// ---- Your settings ----
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASS = "your-password";
// ProbeHarbor: Dashboard, Share, create a live link, copy its readings address.
const char* SHARE_URL = "https://probeharbor.dev/api/share/YOUR_SHARE_TOKEN/readings";
const unsigned REFRESH_SECONDS = 60; // ProbeHarbor allows 60 requests a minute per link
const float FREEZE_F = 35.0; // temperatures at or below this raise the alarm
const unsigned STALE_MINUTES = 30; // readings older than this are marked stale
// ---- Pins ----
LiquidCrystal lcd(4, 5, 6, 7, 15, 16); // RS, E, D4, D5, D6, D7
const int ENC_A = 17; // encoder CLK / A
const int ENC_B = 18; // encoder DT / B
const int ENC_SW = 8; // encoder push button
const int BACKLIGHT = 9; // transistor that switches the backlight, or -1 if it is wired to 5 V
// Root certificates for probeharbor.dev: Google Trust Services R1 and R4, and
// Let's Encrypt ISRG X1 and X2. Cloudflare issues the site's certificate from one of these.
const char* ROOT_CAS = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
)PEM";
struct Reading {
char device[24];
char label[24];
char kind[14];
char unit[10];
bool hasNum, hasBool;
float num;
bool flag;
time_t at;
};
const int MAX_READINGS = 16;
Reading readings[MAX_READINGS];
int count = 0, page = 0;
char status[40] = "Starting";
unsigned long lastFetch = 0, retryAfterMs = 0;
bool detailView = false; // true while the device name and age are shown
// ---- Rotary encoder: a table of valid Gray-code steps, so contact bounce cancels out ----
volatile int encSteps = 0;
uint8_t encState = 0;
const int8_t ENC_TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
portMUX_TYPE encLock = portMUX_INITIALIZER_UNLOCKED;
// A small background task samples the encoder every millisecond. That is quick
// enough for a knob turned by hand, and it keeps counting while loop() waits on
// ProbeHarbor, without the interrupt and fast-RAM rules an ISR would need.
void pollEncoder(void*) {
for (;;) {
encState = (uint8_t)(((encState << 2) | (digitalRead(ENC_A) << 1) | digitalRead(ENC_B)) & 0x0F);
const int8_t step = ENC_TABLE[encState];
if (step) {
taskENTER_CRITICAL(&encLock);
encSteps += step;
taskEXIT_CRITICAL(&encLock);
}
vTaskDelay(1);
}
}
// Most EC11 / KY-040 encoders give four steps per click.
int takeClicks() {
taskENTER_CRITICAL(&encLock);
const int clicks = encSteps / 4;
encSteps -= clicks * 4;
taskEXIT_CRITICAL(&encLock);
return clicks;
}
// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm), so we can
// turn "2026-11-12T15:50:44Z" into a UTC timestamp without timegm().
long daysFromCivil(int y, unsigned m, unsigned d) {
y -= m <= 2;
const long era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = (unsigned)(y - era * 400);
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + (long)doe - 719468;
}
time_t parseTime(const char* s) {
int Y, M, D, h, m, sec;
if (!s || sscanf(s, "%4d-%2d-%2d%*c%2d:%2d:%2d", &Y, &M, &D, &h, &m, &sec) != 6) return 0;
time_t t = (time_t)daysFromCivil(Y, M, D) * 86400 + h * 3600 + m * 60 + sec;
// Optional offset after the seconds: Z, +HH, +HH:MM, -HHMM ...
const char* p = s + 19;
while (*p == '.' || isdigit((unsigned char)*p)) p++;
if (*p == '+' || *p == '-') {
int oh = 0, om = 0;
sscanf(p + 1, "%2d%*[:]%2d", &oh, &om);
if (om == 0) sscanf(p + 1, "%2d%2d", &oh, &om);
const long off = oh * 3600L + om * 60L;
t += (*p == '+') ? -off : off;
}
return t;
}
void copyText(char* to, size_t size, const char* from) {
snprintf(to, size, "%s", from ? from : "");
}
const char* boolWord(const Reading& r) {
if (!strcmp(r.kind, "door")) return r.flag ? "OPEN" : "CLOSED";
if (!strcmp(r.kind, "power")) return r.flag ? "ON" : "OFF";
if (!strcmp(r.kind, "flood")) return r.flag ? "WET" : "DRY";
if (!strcmp(r.kind, "motion")) return r.flag ? "MOTION" : "CLEAR";
return r.flag ? "YES" : "NO";
}
// The things ProbeHarbor exists to catch: freezing, water, lost power.
bool alarming(const Reading& r) {
if (r.hasNum && !strcmp(r.kind, "temperature")) {
const bool celsius = strstr(r.unit, "C") != nullptr;
return r.num <= (celsius ? (FREEZE_F - 32) * 5 / 9 : FREEZE_F);
}
if (r.hasBool && !strcmp(r.kind, "flood")) return r.flag;
if (r.hasBool && !strcmp(r.kind, "power")) return !r.flag;
return false;
}
long ageMinutes(const Reading& r) {
const time_t now = time(nullptr);
if (now < 1700000000 || r.at == 0) return -1; // clock not set yet
return (long)(now - r.at) / 60;
}
void fetchReadings() {
WiFiClientSecure tls;
tls.setCACert(ROOT_CAS);
HTTPClient http;
http.useHTTP10(true); // a plain body, so ArduinoJson can read it as it arrives
const char* keep[] = {"Retry-After"};
http.collectHeaders(keep, 1);
if (!http.begin(tls, SHARE_URL)) {
copyText(status, sizeof(status), "Bad SHARE_URL");
return;
}
const int code = http.GET();
if (code == 200) {
JsonDocument filter;
JsonObject f = filter["readings"].add<JsonObject>();
for (const char* k : {"device", "label", "kind", "unit", "value_num", "value_bool", "recorded_at"}) f[k] = true;
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter));
if (err) {
snprintf(status, sizeof(status), "Bad reply: %s", err.c_str());
} else {
count = 0;
for (JsonObject r : doc["readings"].as<JsonArray>()) {
if (count == MAX_READINGS) break;
Reading& x = readings[count++];
copyText(x.device, sizeof(x.device), r["device"] | "");
copyText(x.label, sizeof(x.label), r["label"] | "");
copyText(x.kind, sizeof(x.kind), r["kind"] | "generic");
copyText(x.unit, sizeof(x.unit), r["unit"] | "");
x.hasNum = r["value_num"].is<float>();
x.num = r["value_num"] | 0.0f;
x.hasBool = r["value_bool"].is<bool>();
x.flag = r["value_bool"] | false;
x.at = parseTime(r["recorded_at"] | "");
}
snprintf(status, sizeof(status), "%d sensors", count);
if (page >= count) page = 0;
for (int i = 0; i < count; i++) {
const Reading& r = readings[i];
// "Garage: Freezer_probe = 4.2 °F (at 15:50 UTC)": one name per sensor, so the bench plots each one.
char name[sizeof(r.label)];
copyText(name, sizeof(name), r.label);
for (char* c = name; *c; c++) if (!isalnum((unsigned char)*c)) *c = '_';
Serial.printf("%s: %s = ", r.device, name);
if (r.hasNum) Serial.printf("%.1f %s", r.num, r.unit);
else if (r.hasBool) Serial.print(boolWord(r));
else Serial.print("no value");
if (r.at) {
struct tm t;
gmtime_r(&r.at, &t);
Serial.printf(" (at %02d:%02d UTC)", t.tm_hour, t.tm_min);
}
Serial.println();
if (alarming(r)) Serial.printf("ALERT %s: %s\n", r.device, r.label);
}
}
} else if (code == 404) {
copyText(status, sizeof(status), "Link invalid or expired");
} else if (code == 429) {
retryAfterMs = 1000UL * (unsigned long)max(60L, http.header("Retry-After").toInt());
copyText(status, sizeof(status), "Too many requests");
} else {
snprintf(status, sizeof(status), "HTTP %d", code);
}
if (code != 200) Serial.printf("ProbeHarbor: %s\n", status);
http.end();
}
// The LCD's character set has a degree sign (0xDF) and a micro sign (0xE4).
void printUnit(const char* unit) {
for (const char* p = unit; *p; p++) {
if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB0) { lcd.write((uint8_t)0xDF); p++; } // °
else if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB5) { lcd.write((uint8_t)0xE4); p++; } // µ
else if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB3) { lcd.write('3'); p++; } // ³
else if ((uint8_t)p[0] < 0x80) lcd.write(p[0]);
}
}
// Print text padded or cut to exactly \`width\` characters, so old text never lingers.
void printFixed(const char* text, int width) {
int n = 0;
for (; text[n] && n < width; n++) lcd.write(text[n]);
for (; n < width; n++) lcd.write(' ');
}
void draw() {
static char shown[2][17] = {"", ""};
char top[17], bottom[17];
if (count == 0) {
snprintf(top, sizeof(top), "ProbeHarbor");
snprintf(bottom, sizeof(bottom), "%.16s", status);
} else {
const Reading& r = readings[page];
const bool alarm = alarming(r);
const long age = ageMinutes(r);
if (!detailView) {
// Top: the sensor's label, with "!" at the end when it is alarming.
snprintf(top, sizeof(top), "%-15.15s%c", r.label, alarm ? '!' : ' ');
bottom[0] = 0; // drawn below, because of the unit characters
} else {
snprintf(top, sizeof(top), "%.16s", r.device);
if (age >= (long)STALE_MINUTES) snprintf(bottom, sizeof(bottom), "STALE %ldm", age);
else if (age >= 0) snprintf(bottom, sizeof(bottom), "%ldm ago", age);
else snprintf(bottom, sizeof(bottom), "time not set");
}
// Blink the backlight while the shown sensor is alarming, if a transistor drives it.
if (BACKLIGHT >= 0) digitalWrite(BACKLIGHT, alarm && (millis() / 500) % 2 ? LOW : HIGH);
}
if (strcmp(top, shown[0])) {
lcd.setCursor(0, 0);
printFixed(top, 16);
snprintf(shown[0], sizeof(shown[0]), "%s", top);
}
if (count > 0 && !detailView) {
const Reading& r = readings[page];
char value[17], pos[8];
if (r.hasNum) snprintf(value, sizeof(value), "%.1f ", r.num);
else if (r.hasBool) snprintf(value, sizeof(value), "%s", boolWord(r));
else snprintf(value, sizeof(value), "--");
snprintf(pos, sizeof(pos), "%d/%d", page + 1, count);
char key[40];
snprintf(key, sizeof(key), "%s|%s|%s", value, r.unit, pos);
if (strcmp(key, shown[1])) { // only redraw when something changed: no flicker
lcd.setCursor(0, 1);
printFixed("", 16);
lcd.setCursor(0, 1);
lcd.print(value);
if (r.hasNum) printUnit(r.unit);
lcd.setCursor(16 - (int)strlen(pos), 1);
lcd.print(pos);
snprintf(shown[1], sizeof(shown[1]), "%.16s", key);
}
} else if (strcmp(bottom, shown[1])) {
lcd.setCursor(0, 1);
printFixed(bottom, 16);
snprintf(shown[1], sizeof(shown[1]), "%s", bottom);
}
}
void setup() {
Serial.begin(115200);
lcd.begin(16, 2);
if (BACKLIGHT >= 0) {
pinMode(BACKLIGHT, OUTPUT);
digitalWrite(BACKLIGHT, HIGH);
}
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
encState = (uint8_t)((digitalRead(ENC_A) << 1) | digitalRead(ENC_B));
xTaskCreate(pollEncoder, "encoder", 2048, nullptr, 2, nullptr); // above loop()'s priority
copyText(status, sizeof(status), "Joining Wi-Fi");
draw();
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.printf("wifi ok %s\n", WiFi.localIP().toString().c_str());
configTime(0, 0, "pool.ntp.org", "time.google.com"); // UTC, for "minutes ago"
copyText(status, sizeof(status), "Fetching");
draw();
}
void loop() {
const unsigned long now = millis();
const unsigned long every = retryAfterMs ? retryAfterMs : REFRESH_SECONDS * 1000UL;
if (lastFetch == 0 || now - lastFetch >= every) {
lastFetch = now ? now : 1;
retryAfterMs = 0;
if (WiFi.status() == WL_CONNECTED) fetchReadings();
else {
copyText(status, sizeof(status), "Wi-Fi lost");
WiFi.reconnect();
}
}
const int clicks = takeClicks();
if (clicks && count) page = ((page + clicks) % count + count) % count;
static bool wasDown = false;
const bool down = digitalRead(ENC_SW) == LOW;
if (down && !wasDown) detailView = !detailView; // press: toggle the detail view
wasDown = down;
draw();
delay(20);
}
Wiring
| From | To board | Note |
|---|---|---|
| LCD 1 VSS | GND | |
| LCD 2 VDD | 5V | The LCD runs on 5 V |
| LCD 3 V0 | Trimmer wiper | Trimmer ends to 5V and GND. Turn it until the characters show |
| LCD 4 RS | GPIO 0 | |
| LCD 5 RW | GND | Always. Tied low, the LCD only listens and never sends 5 V back to the ESP32 |
| LCD 6 E | GPIO 1 | |
| LCD 11 D4 | GPIO 3 | 4-bit mode: D0 to D3 stay unconnected |
| LCD 12 D5 | GPIO 4 | |
| LCD 13 D6 | GPIO 5 | |
| LCD 14 D7 | GPIO 10 | |
| LCD 15 A (backlight +) | 5V | Many modules have the backlight resistor on board; if yours does not, add about 100 Ω |
| LCD 16 K (backlight -) | GND, or the transistor | Optional blink: K to the NPN collector, emitter to GND, base through 1 kΩ to Not used on the C3: wire the backlight to 5 V |
| Encoder A / CLK | GPIO 6 | The encoder's middle pin goes to GND. Internal pull-ups are used |
| Encoder B / DT | GPIO 7 | |
| Encoder push switch / SW | Not wired on the C3: press the BOOT button instead | Other switch leg to GND |
| KY-040 board + (if you have one) | 3V3 | Never 5 V: its pull-ups would put 5 V on the GPIOs |
Tested on this board
Not run on this board yet. Treat the wiring and code as a first draft.
Firmware, Arduino sketch for ESP32C3 Dev Module
// ProbeHarbor on a 16x2 LCD: turn the knob to step through your sensors,
// press it for the device name and how old the reading is.
// Reads a ProbeHarbor share link (read-only, works on the free plan) once a minute.
// Libraries: ArduinoJson, LiquidCrystal.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <LiquidCrystal.h>
#include <ArduinoJson.h>
#include <time.h>
// ---- Your settings ----
const char* WIFI_SSID = "your-wifi";
const char* WIFI_PASS = "your-password";
// ProbeHarbor: Dashboard, Share, create a live link, copy its readings address.
const char* SHARE_URL = "https://probeharbor.dev/api/share/YOUR_SHARE_TOKEN/readings";
const unsigned REFRESH_SECONDS = 60; // ProbeHarbor allows 60 requests a minute per link
const float FREEZE_F = 35.0; // temperatures at or below this raise the alarm
const unsigned STALE_MINUTES = 30; // readings older than this are marked stale
// ---- Pins ----
LiquidCrystal lcd(0, 1, 3, 4, 5, 10); // RS, E, D4, D5, D6, D7
const int ENC_A = 6; // encoder CLK / A
const int ENC_B = 7; // encoder DT / B
const int ENC_SW = 9; // encoder push button (the board's BOOT button: the C3 has no spare pin)
const int BACKLIGHT = -1; // transistor that switches the backlight, or -1 if it is wired to 5 V
// Root certificates for probeharbor.dev: Google Trust Services R1 and R4, and
// Let's Encrypt ISRG X1 and X2. Cloudflare issues the site's certificate from one of these.
const char* ROOT_CAS = R"PEM(
-----BEGIN CERTIFICATE-----
MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw
CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU
MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw
MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp
Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo
27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w
Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw
TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl
qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH
szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8
Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk
MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92
wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p
aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN
VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID
AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb
C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe
QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy
h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4
7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J
ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef
MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/
Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT
6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ
0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm
2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD
VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG
A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw
WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz
IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi
AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi
QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR
HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW
BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D
9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8
p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
/q4AaOeMSQ+2b1tbFfLn
-----END CERTIFICATE-----
)PEM";
struct Reading {
char device[24];
char label[24];
char kind[14];
char unit[10];
bool hasNum, hasBool;
float num;
bool flag;
time_t at;
};
const int MAX_READINGS = 16;
Reading readings[MAX_READINGS];
int count = 0, page = 0;
char status[40] = "Starting";
unsigned long lastFetch = 0, retryAfterMs = 0;
bool detailView = false; // true while the device name and age are shown
// ---- Rotary encoder: a table of valid Gray-code steps, so contact bounce cancels out ----
volatile int encSteps = 0;
uint8_t encState = 0;
const int8_t ENC_TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
portMUX_TYPE encLock = portMUX_INITIALIZER_UNLOCKED;
// A small background task samples the encoder every millisecond. That is quick
// enough for a knob turned by hand, and it keeps counting while loop() waits on
// ProbeHarbor, without the interrupt and fast-RAM rules an ISR would need.
void pollEncoder(void*) {
for (;;) {
encState = (uint8_t)(((encState << 2) | (digitalRead(ENC_A) << 1) | digitalRead(ENC_B)) & 0x0F);
const int8_t step = ENC_TABLE[encState];
if (step) {
taskENTER_CRITICAL(&encLock);
encSteps += step;
taskEXIT_CRITICAL(&encLock);
}
vTaskDelay(1);
}
}
// Most EC11 / KY-040 encoders give four steps per click.
int takeClicks() {
taskENTER_CRITICAL(&encLock);
const int clicks = encSteps / 4;
encSteps -= clicks * 4;
taskEXIT_CRITICAL(&encLock);
return clicks;
}
// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm), so we can
// turn "2026-11-12T15:50:44Z" into a UTC timestamp without timegm().
long daysFromCivil(int y, unsigned m, unsigned d) {
y -= m <= 2;
const long era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = (unsigned)(y - era * 400);
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + (long)doe - 719468;
}
time_t parseTime(const char* s) {
int Y, M, D, h, m, sec;
if (!s || sscanf(s, "%4d-%2d-%2d%*c%2d:%2d:%2d", &Y, &M, &D, &h, &m, &sec) != 6) return 0;
time_t t = (time_t)daysFromCivil(Y, M, D) * 86400 + h * 3600 + m * 60 + sec;
// Optional offset after the seconds: Z, +HH, +HH:MM, -HHMM ...
const char* p = s + 19;
while (*p == '.' || isdigit((unsigned char)*p)) p++;
if (*p == '+' || *p == '-') {
int oh = 0, om = 0;
sscanf(p + 1, "%2d%*[:]%2d", &oh, &om);
if (om == 0) sscanf(p + 1, "%2d%2d", &oh, &om);
const long off = oh * 3600L + om * 60L;
t += (*p == '+') ? -off : off;
}
return t;
}
void copyText(char* to, size_t size, const char* from) {
snprintf(to, size, "%s", from ? from : "");
}
const char* boolWord(const Reading& r) {
if (!strcmp(r.kind, "door")) return r.flag ? "OPEN" : "CLOSED";
if (!strcmp(r.kind, "power")) return r.flag ? "ON" : "OFF";
if (!strcmp(r.kind, "flood")) return r.flag ? "WET" : "DRY";
if (!strcmp(r.kind, "motion")) return r.flag ? "MOTION" : "CLEAR";
return r.flag ? "YES" : "NO";
}
// The things ProbeHarbor exists to catch: freezing, water, lost power.
bool alarming(const Reading& r) {
if (r.hasNum && !strcmp(r.kind, "temperature")) {
const bool celsius = strstr(r.unit, "C") != nullptr;
return r.num <= (celsius ? (FREEZE_F - 32) * 5 / 9 : FREEZE_F);
}
if (r.hasBool && !strcmp(r.kind, "flood")) return r.flag;
if (r.hasBool && !strcmp(r.kind, "power")) return !r.flag;
return false;
}
long ageMinutes(const Reading& r) {
const time_t now = time(nullptr);
if (now < 1700000000 || r.at == 0) return -1; // clock not set yet
return (long)(now - r.at) / 60;
}
void fetchReadings() {
WiFiClientSecure tls;
tls.setCACert(ROOT_CAS);
HTTPClient http;
http.useHTTP10(true); // a plain body, so ArduinoJson can read it as it arrives
const char* keep[] = {"Retry-After"};
http.collectHeaders(keep, 1);
if (!http.begin(tls, SHARE_URL)) {
copyText(status, sizeof(status), "Bad SHARE_URL");
return;
}
const int code = http.GET();
if (code == 200) {
JsonDocument filter;
JsonObject f = filter["readings"].add<JsonObject>();
for (const char* k : {"device", "label", "kind", "unit", "value_num", "value_bool", "recorded_at"}) f[k] = true;
JsonDocument doc;
const DeserializationError err = deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter));
if (err) {
snprintf(status, sizeof(status), "Bad reply: %s", err.c_str());
} else {
count = 0;
for (JsonObject r : doc["readings"].as<JsonArray>()) {
if (count == MAX_READINGS) break;
Reading& x = readings[count++];
copyText(x.device, sizeof(x.device), r["device"] | "");
copyText(x.label, sizeof(x.label), r["label"] | "");
copyText(x.kind, sizeof(x.kind), r["kind"] | "generic");
copyText(x.unit, sizeof(x.unit), r["unit"] | "");
x.hasNum = r["value_num"].is<float>();
x.num = r["value_num"] | 0.0f;
x.hasBool = r["value_bool"].is<bool>();
x.flag = r["value_bool"] | false;
x.at = parseTime(r["recorded_at"] | "");
}
snprintf(status, sizeof(status), "%d sensors", count);
if (page >= count) page = 0;
for (int i = 0; i < count; i++) {
const Reading& r = readings[i];
// "Garage: Freezer_probe = 4.2 °F (at 15:50 UTC)": one name per sensor, so the bench plots each one.
char name[sizeof(r.label)];
copyText(name, sizeof(name), r.label);
for (char* c = name; *c; c++) if (!isalnum((unsigned char)*c)) *c = '_';
Serial.printf("%s: %s = ", r.device, name);
if (r.hasNum) Serial.printf("%.1f %s", r.num, r.unit);
else if (r.hasBool) Serial.print(boolWord(r));
else Serial.print("no value");
if (r.at) {
struct tm t;
gmtime_r(&r.at, &t);
Serial.printf(" (at %02d:%02d UTC)", t.tm_hour, t.tm_min);
}
Serial.println();
if (alarming(r)) Serial.printf("ALERT %s: %s\n", r.device, r.label);
}
}
} else if (code == 404) {
copyText(status, sizeof(status), "Link invalid or expired");
} else if (code == 429) {
retryAfterMs = 1000UL * (unsigned long)max(60L, http.header("Retry-After").toInt());
copyText(status, sizeof(status), "Too many requests");
} else {
snprintf(status, sizeof(status), "HTTP %d", code);
}
if (code != 200) Serial.printf("ProbeHarbor: %s\n", status);
http.end();
}
// The LCD's character set has a degree sign (0xDF) and a micro sign (0xE4).
void printUnit(const char* unit) {
for (const char* p = unit; *p; p++) {
if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB0) { lcd.write((uint8_t)0xDF); p++; } // °
else if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB5) { lcd.write((uint8_t)0xE4); p++; } // µ
else if ((uint8_t)p[0] == 0xC2 && (uint8_t)p[1] == 0xB3) { lcd.write('3'); p++; } // ³
else if ((uint8_t)p[0] < 0x80) lcd.write(p[0]);
}
}
// Print text padded or cut to exactly \`width\` characters, so old text never lingers.
void printFixed(const char* text, int width) {
int n = 0;
for (; text[n] && n < width; n++) lcd.write(text[n]);
for (; n < width; n++) lcd.write(' ');
}
void draw() {
static char shown[2][17] = {"", ""};
char top[17], bottom[17];
if (count == 0) {
snprintf(top, sizeof(top), "ProbeHarbor");
snprintf(bottom, sizeof(bottom), "%.16s", status);
} else {
const Reading& r = readings[page];
const bool alarm = alarming(r);
const long age = ageMinutes(r);
if (!detailView) {
// Top: the sensor's label, with "!" at the end when it is alarming.
snprintf(top, sizeof(top), "%-15.15s%c", r.label, alarm ? '!' : ' ');
bottom[0] = 0; // drawn below, because of the unit characters
} else {
snprintf(top, sizeof(top), "%.16s", r.device);
if (age >= (long)STALE_MINUTES) snprintf(bottom, sizeof(bottom), "STALE %ldm", age);
else if (age >= 0) snprintf(bottom, sizeof(bottom), "%ldm ago", age);
else snprintf(bottom, sizeof(bottom), "time not set");
}
// Blink the backlight while the shown sensor is alarming, if a transistor drives it.
if (BACKLIGHT >= 0) digitalWrite(BACKLIGHT, alarm && (millis() / 500) % 2 ? LOW : HIGH);
}
if (strcmp(top, shown[0])) {
lcd.setCursor(0, 0);
printFixed(top, 16);
snprintf(shown[0], sizeof(shown[0]), "%s", top);
}
if (count > 0 && !detailView) {
const Reading& r = readings[page];
char value[17], pos[8];
if (r.hasNum) snprintf(value, sizeof(value), "%.1f ", r.num);
else if (r.hasBool) snprintf(value, sizeof(value), "%s", boolWord(r));
else snprintf(value, sizeof(value), "--");
snprintf(pos, sizeof(pos), "%d/%d", page + 1, count);
char key[40];
snprintf(key, sizeof(key), "%s|%s|%s", value, r.unit, pos);
if (strcmp(key, shown[1])) { // only redraw when something changed: no flicker
lcd.setCursor(0, 1);
printFixed("", 16);
lcd.setCursor(0, 1);
lcd.print(value);
if (r.hasNum) printUnit(r.unit);
lcd.setCursor(16 - (int)strlen(pos), 1);
lcd.print(pos);
snprintf(shown[1], sizeof(shown[1]), "%.16s", key);
}
} else if (strcmp(bottom, shown[1])) {
lcd.setCursor(0, 1);
printFixed(bottom, 16);
snprintf(shown[1], sizeof(shown[1]), "%s", bottom);
}
}
void setup() {
Serial.begin(115200);
lcd.begin(16, 2);
if (BACKLIGHT >= 0) {
pinMode(BACKLIGHT, OUTPUT);
digitalWrite(BACKLIGHT, HIGH);
}
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_SW, INPUT_PULLUP);
encState = (uint8_t)((digitalRead(ENC_A) << 1) | digitalRead(ENC_B));
xTaskCreate(pollEncoder, "encoder", 2048, nullptr, 2, nullptr); // above loop()'s priority
copyText(status, sizeof(status), "Joining Wi-Fi");
draw();
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.printf("wifi ok %s\n", WiFi.localIP().toString().c_str());
configTime(0, 0, "pool.ntp.org", "time.google.com"); // UTC, for "minutes ago"
copyText(status, sizeof(status), "Fetching");
draw();
}
void loop() {
const unsigned long now = millis();
const unsigned long every = retryAfterMs ? retryAfterMs : REFRESH_SECONDS * 1000UL;
if (lastFetch == 0 || now - lastFetch >= every) {
lastFetch = now ? now : 1;
retryAfterMs = 0;
if (WiFi.status() == WL_CONNECTED) fetchReadings();
else {
copyText(status, sizeof(status), "Wi-Fi lost");
WiFi.reconnect();
}
}
const int clicks = takeClicks();
if (clicks && count) page = ((page + clicks) % count + count) % count;
static bool wasDown = false;
const bool down = digitalRead(ENC_SW) == LOW;
if (down && !wasDown) detailView = !detailView; // press: toggle the detail view
wasDown = down;
draw();
delay(20);
}
Parts
| Quantity | Part | Where to buy |
|---|---|---|
| 1× | ESP32 dev board | Buy at Amazon |
| 1× | 16x2 character LCD (HD44780), 5 V | |
| 1× | 10 kΩ trimmer potentiometer, for the contrast | |
| 1× | Rotary encoder with push button (EC11 or a KY-040 board) | |
| 1× | Optional: NPN transistor (2N2222, BC547 or similar) and 1 kΩ resistor, to blink the backlight | |
| 16× | Jumper wires and a breadboard |
Amazon buy links may include an Associates tag, and this site may earn a commission from qualifying Amazon purchases at no extra cost to you. Adafruit links are direct.
Libraries
ArduinoJson, LiquidCrystal, WiFi (built in), HTTPClient (built in)
Load it
- In the Arduino IDE, install the esp32 board package and pick the board named above the sketch.
- Install the libraries listed above from the Library Manager.
- Paste the sketch, choose the board's port and upload. Hold BOOT if the upload stalls at Connecting.
- Close the IDE's serial monitor, then open the bench here to watch the output.
Field notes
- It reads a ProbeHarbor share link, which is read-only and works on the free plan. In ProbeHarbor, open Dashboard, Share, create a live link and paste its readings address into SHARE_URL.
- The HD44780 runs on 5 V but accepts the ESP32's 3.3 V signals, because it treats anything above 2.2 V as high. That only works one way, which is why RW goes to GND: the LCD never drives its data pins, so no 5 V reaches the ESP32.
- Blank or solid blocks on the top row usually means the contrast: turn the trimmer slowly from one end to the other.
- A small background task reads the encoder every millisecond against a table of valid Gray-code steps, so a bouncy contact cancels itself out instead of skipping sensors, and turns still count while the board is fetching from ProbeHarbor. If one click moves two sensors, change the 4 in takeClicks() to 2.
- On a 16x2 an alarm shows as ! at the end of the sensor's name. With the optional transistor the backlight also blinks while that sensor is on screen. Without it, BACKLIGHT is -1 and the backlight simply stays on.
- The ESP32-C3 has eight pins free of boot, USB and serial duties, and this project needs nine, so on the C3 the board's BOOT button stands in for the encoder's push switch and the backlight blink is left out. Do not hold BOOT while the board resets.
- GPIO 14 on the classic ESP32 wiggles briefly at power-up. On the LCD's E pin that is harmless: the screen is cleared when the sketch starts.
Not tested yet
- Not tested on hardware or against a real ProbeHarbor share link yet. Compiles for every board listed with Arduino-ESP32 core 3.3.12.
Community builds
Loading build reports from people who made this.
Built it? Send a build report with your board and what the serial port printed. Working reports become the "Tested on this board" entries above.