Skip to content

Instantly share code, notes, and snippets.

@Tech500
Created June 24, 2026 21:29
Show Gist options
  • Select an option

  • Save Tech500/d6947315357c3e17b17093381dcc1707 to your computer and use it in GitHub Desktop.

Select an option

Save Tech500/d6947315357c3e17b17093381dcc1707 to your computer and use it in GitHub Desktop.
Heating System Monitor --ESP_NOW Receiver IV No HVAC Hookup; uses acoustical, variance blower detection for on/off runtime.
/* Heating System Monitor III
ESP_NOW_Receeeiver.ino with temperature Offset
June 20,2026
ESP32_-NOW, ESP32 Core 2.0.17
Receives MSG_BLOWER_STATE from ESP_NOW_Blower node
Receives MSG_BME280 from ESP_NOW_BME280 node
Sends MSG_ALERT_FLAG to BME280 node to request outside temp
--- Updated June 20, 2026 ---
Inside sensing changed from MCP9808 (direct register I2C) to BME280I2C library.
Adds insideHumidity to Google Sheets logging; removes registerTemp column.
--- Updated June 20, 2026 (cont.) ---
Adds outside/inside pressure capture and diff (outsidePressure - insidePressure).
Fixes read-gate: was isnan(temp)||isnan(hum), which permanently rejects readings
on the interim HW-611/BMP280 since it has no humidity element (hum always NaN).
Now gates on temp/pressure only; humidity passes through as NaN.
--- Updated June 25, 2026 ---
Original HW-394 receiver board replaced -- defective 3.3V rail (2.04V measured),
causing corrupted I2C reads and frozen BME280 output.
Replacement HW-394: I2C reassigned to D4 (SDA/GPIO4) and D5 (SCL/GPIO5).
BME280 inside temp calibration offset updated to -2.51F (ref: HP-770HD DMM).
Note: re-verify offset once sensor is in permanent installed location.
ESP32 Core downgraded to 2.0.17 to resolve BME280 library I2C compatibility issues.
Pressure converted hPa->inHg (x 0.02953) for Serial, LittleFS, and Sheets POST.
Absolute pressure converted to sea-level relative using temperature-compensated
hypsometric formula (toSeaLevelHPa) before inHg conversion.
Pressure diff remains in absolute hPa converted to inHg -- both sensors same elevation.
Internal math remains in hPa throughout.
*/
// A Collaboration between myself and Claude (Anthropic AI).
#include <Arduino.h>
#include <Wire.h>
#include <BME280I2C.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <esp_wifi.h>
#include <ESP32_NOW.h>
#include <LittleFS.h>
#include <FTPServer.h>
#include <WebServer.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Ticker.h>
const char* ssid = "R2D2";
const char* password = "Sky7388500";
// ─── GOOGLE DEPLOYMENT ID ────────────────────────────────────────────────────
const String googleDeploymentID = "AKfycbyozVer3rLxLzk7YzBhB8RGc2-JrZgXS31niOrptmJRk6xQA6SwEqh1Ed0TTi_Ti7UQaA";
const String googleURL = "https://script.google.com/macros/s/" + googleDeploymentID + "/exec";
// ─────────────────────────────────────────────
// BME280 (Inside) — replaces MCP9808
// Default I2C address 0x76, same library as outside BME280 node
// ─────────────────────────────────────────────
BME280I2C bmeInside;
// ─────────────────────────────────────────────
// BME280 (Inside) Temperature Calibration
// TODO: Re-calibrate against HP-770HD DMM reference once installed in place
// Offset applied to BME280 (inside) temperature output
// ─────────────────────────────────────────────
const float BME280_INSIDE_TEMP_CAL_OFFSET_F = -2.51;
const float LOW_TEMP_F = 65.0;
const float HIGH_TEMP_F = 74.0;
const float LOW_TEMP_C = (LOW_TEMP_F - 32.0) / 1.8;
const float HIGH_TEMP_C = (HIGH_TEMP_F - 32.0) / 1.8;
// ─── NTP / Time ──────────────────────────────────────────────────────────────
const char* udpAddress1 = "pool.ntp.org";
const char* udpAddress2 = "time.nist.gov";
int DOW, MONTH, DATE, YEAR, HOUR, MINUTE, SECOND;
String weekDay;
char strftime_buf[64];
String dtStamp = "";
String lastUpdate = "";
time_t tnow;
// ─── Ticker / One-Second ISR ─────────────────────────────────────────────────
Ticker secondTicker;
volatile bool oneSecondElapsed = false;
void IRAM_ATTR countSecondsISR() {
oneSecondElapsed = true;
}
// ─── Message / Packet Structures ─────────────────────────────────────────────
enum MessageType : uint8_t {
MSG_BME280 = 0,
MSG_ALERT_FLAG = 1,
MSG_BLOWER_STATE = 2
};
struct __attribute__((packed)) BlowerData {
MessageType type;
bool on;
float elapsedMinutes;
float dailyTotalMinutes;
};
struct __attribute__((packed)) BME280Data {
MessageType type;
float temperature;
float humidity;
float pressure;
};
struct __attribute__((packed)) AlertFlag {
MessageType type;
bool alert;
};
struct SensorRegisters {
float outsideTemp;
float insideTemp;
float insideHumidity;
float thermostat;
float lastEventMinutes;
float dailyTotalMinutes;
float lastRecordedMinute;
float outsidePressure;
float insidePressure;
float pressureDiffHPa; // outsidePressure - insidePressure
};
SensorRegisters sensordata;
// ─── Peripherals ─────────────────────────────────────────────────────────────
FTPServer ftpSrv(LittleFS);
WebServer server(80);
// ─── ESP-NOW Peer Classes ─────────────────────────────────────────────────────
// Forward declaration required by peer classes
void processIncomingPacket(const uint8_t *data, int len);
// Blower node — receive only
class HeatingSenderPeer : public ESP_NOW_Peer {
public:
HeatingSenderPeer(const uint8_t *mac_addr, uint8_t channel,
wifi_interface_t iface = WIFI_IF_STA,
const uint8_t *lmk = NULL)
: ESP_NOW_Peer(mac_addr, channel, iface, lmk) {}
bool add_to_system() { return ESP_NOW_Peer::add(); }
protected:
void onReceive(const uint8_t *data, size_t len, bool broadcast) override {
processIncomingPacket(data, (int)len);
}
};
// BME280 node — bidirectional
class BME280Peer : public ESP_NOW_Peer {
public:
BME280Peer(const uint8_t *mac_addr, uint8_t channel,
wifi_interface_t iface = WIFI_IF_STA,
const uint8_t *lmk = NULL)
: ESP_NOW_Peer(mac_addr, channel, iface, lmk) {}
bool add_to_system() { return ESP_NOW_Peer::add(); }
bool sendAlert(bool alertState) {
AlertFlag pkt;
pkt.type = MSG_ALERT_FLAG;
pkt.alert = alertState;
return send((uint8_t *)&pkt, sizeof(AlertFlag));
}
protected:
void onReceive(const uint8_t *data, size_t len, bool broadcast) override {
processIncomingPacket(data, (int)len);
}
};
// ─── Hardware MAC Map ─────────────────────────────────────────────────────────
uint8_t senderBlowerMAC[] = { 0xE4, 0x65, 0xB8, 0x24, 0xF6, 0x4C };
uint8_t senderBmeMAC[] = { 0xE4, 0x65, 0xB8, 0x25, 0x42, 0xF8 };
#define CHANNEL 0
HeatingSenderPeer blowerNode(senderBlowerMAC, CHANNEL, WIFI_IF_STA);
BME280Peer bmeNode(senderBmeMAC, CHANNEL, WIFI_IF_STA);
// ─── Global Tracking Registers ───────────────────────────────────────────────
bool blowerIsOn = false;
bool alertFlag = false;
bool googleSheetsSent = false;
double elapsedMinutes = 0.0;
RTC_DATA_ATTR double dailyTotalMinutes = 0.0; // survives soft reset/crash; clears on power-on
float globalTemp = 0.0;
float globalHumidity = 0.0;
float globalPressure = 0.0;
float thermostatSetpoint = 74.0;
// ─── Forward Declarations ─────────────────────────────────────────────────────
// ─── Elevation / Sea-Level Pressure Conversion ───────────────────────────────
// Station elevation: 809 ft = 246.6 m Change to your elevation
// Converts absolute BME280 pressure (hPa) to sea-level relative pressure (hPa)
// using temperature-compensated hypsometric formula.
#define STATION_ELEVATION_M 246.6
float toSeaLevelHPa(float absoluteHPa, float tempF) {
float tempC = (tempF - 32.0) / 1.8;
float tempK = tempC + 273.15;
return absoluteHPa * exp(9.80665 * STATION_ELEVATION_M / (287.05 * tempK));
}
void sendGoogleSheetsData();
void sendDataToServer(String updateTime, float outT, float inT, float inHum,
float therm, double eventM, double dailyM,
float outP, float inP, float diffP);
void updateHeatingData();
void displayData();
void logData();
void disabledailyCooling();
void resetDailyStats();
void temperatureInterrupt();
void initNTP();
String getDateTime();
void initBME280Inside();
void readBME280Inside(float &tempF, float &humidity, float &pressureHPa);
String urlEncode(String str);
// ─── NTP Init ────────────────────────────────────────────────────────────────
void initNTP() {
configTime(0, 0, udpAddress1, udpAddress2);
setenv("TZ", "EST+5EDT,M3.2.0/2,M11.1.0/2", 2);
tzset();
Serial.print("Waiting for first valid NTP timestamp");
while (time(nullptr) < 100000ul) {
Serial.print(".");
delay(5000);
}
Serial.println("\nNTP Synchronized.");
}
// ─── Date/Time Stamp ─────────────────────────────────────────────────────────
String getDateTime() {
tnow = time(nullptr) + 1;
struct tm *ti = localtime(&tnow);
DOW = ti->tm_wday;
YEAR = ti->tm_year + 1900;
MONTH = ti->tm_mon + 1;
DATE = ti->tm_mday;
HOUR = ti->tm_hour;
MINUTE = ti->tm_min;
SECOND = ti->tm_sec;
strftime(strftime_buf, sizeof(strftime_buf), "%m/%d/%Y %H:%M:%S", localtime(&tnow));
dtStamp = strftime_buf;
return dtStamp;
}
// ─── BME280 (Inside) ─────────────────────────────────────────────────────────
void initBME280Inside() {
while (!bmeInside.begin()) {
Serial.println("Could not establish communication with inside BME280 sensor.");
delay(1000);
}
Serial.println("Inside BME280 sensor initialized.");
}
void readBME280Inside(float &tempF, float &humidity, float &pressureHPa) {
float temp = NAN, hum = NAN, pres = NAN;
BME280::TempUnit tempUnit(BME280::TempUnit_Fahrenheit);
BME280::PresUnit presUnit(BME280::PresUnit_hPa);
bmeInside.read(pres, temp, hum, tempUnit, presUnit);
// Gate on temp/pressure only -- humidity is legitimately NaN on the
// interim HW-611/BMP280 and must not block an otherwise valid reading.
if (isnan(temp) || isnan(pres)) {
Serial.println("Error reading inside BME280 sensor telemetry.");
tempF = NAN;
humidity = NAN;
pressureHPa = NAN;
return;
}
// Apply calibration offset — reference: HP-770HD DMM thermometer
temp += BME280_INSIDE_TEMP_CAL_OFFSET_F;
tempF = temp;
humidity = hum; // NAN on BMP280 (HW-611) until Monday's BME280 swap
pressureHPa = pres;
}
// ─── URL Encoder ─────────────────────────────────────────────────────────────
String urlEncode(String str) {
String encoded = "";
for (unsigned int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (isalnum(c)) {
encoded += c;
} else if (c == ' ') {
encoded += '+';
} else {
char code1 = (c & 0xf) + '0';
if ((c & 0xf) > 9) code1 = (c & 0xf) - 10 + 'A';
char c2 = (c >> 4) & 0xf;
char code0 = c2 + '0';
if (c2 > 9) code0 = c2 - 10 + 'A';
encoded += '%';
encoded += code0;
encoded += code1;
}
}
return encoded;
}
// ─── Packet Processing Pipeline ──────────────────────────────────────────────
void processIncomingPacket(const uint8_t *data, int len) {
Serial.printf(">>> processIncomingPacket: len=%d type=%d sizeof(BlowerData)=%d\n",
len, data[0], sizeof(BlowerData));
if (len < 1) return;
MessageType incomingType = (MessageType)data[0];
switch (incomingType) {
case MSG_BLOWER_STATE:
if (len == sizeof(BlowerData)) {
BlowerData blowerPacket;
memcpy(&blowerPacket, data, sizeof(BlowerData));
blowerIsOn = blowerPacket.on;
Serial.printf("\n[Radio Link] Blower Update Caught -> State: %s Elapsed: %.2f min Daily: %.2f min\n",
blowerIsOn ? "ON" : "OFF",
blowerPacket.elapsedMinutes,
blowerPacket.dailyTotalMinutes);
elapsedMinutes = blowerPacket.elapsedMinutes;
dailyTotalMinutes = blowerPacket.dailyTotalMinutes;
sensordata.lastEventMinutes = blowerPacket.elapsedMinutes;
sensordata.dailyTotalMinutes = blowerPacket.dailyTotalMinutes;
alertFlag = true;
} else {
Serial.printf("\n⚠️ Size Mismatch — BlowerData: Expected %d got %d bytes\n",
sizeof(BlowerData), len);
}
break;
case MSG_ALERT_FLAG:
if (len == sizeof(AlertFlag)) {
AlertFlag alertPacket;
memcpy(&alertPacket, data, sizeof(AlertFlag));
Serial.printf("\n[Radio Link] AlertFlag received: %s\n",
alertPacket.alert ? "true" : "false");
// alertFlag already set by MSG_BLOWER_STATE — no action needed here
}
break;
case MSG_BME280:
if (len == sizeof(BME280Data)) {
BME280Data bmePacket;
memcpy(&bmePacket, data, sizeof(BME280Data));
globalTemp = bmePacket.temperature;
globalHumidity = bmePacket.humidity;
globalPressure = bmePacket.pressure;
Serial.printf("\n[Radio Link] BME280 Update -> Temp: %.2f F Hum: %.1f%% Pres: %.4f inHg\n",
globalTemp, globalHumidity, globalPressure * 0.02953);
} else {
Serial.printf("\n⚠️ Size Mismatch — BmeData: Expected %d got %d bytes\n",
sizeof(BME280Data), len);
}
break;
default:
Serial.printf("\n⚠️ Unknown packet type: %d\n", data[0]);
break;
}
}
// ─── Setup ───────────────────────────────────────────────────────────────────
void setup() {
Serial.begin(9600);
delay(1000);
Serial.println("\n\nHeating System Monitor IV - ESP_NOW_Receiver.ino\n");
// HW-394 replacement board: D4=SDA(GPIO4), D5=SCL(GPIO5)
// Original board had defective 3.3V rail (2.04V), causing frozen I2C reads.
Wire.begin(4, 5);
WiFi.mode(WIFI_MODE_APSTA);
WiFi.setSleep(WIFI_PS_NONE);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(300);
Serial.print(".");
}
Serial.println("\nWiFi Infrastructure Connected.");
Serial.println("Receiver MAC: " + WiFi.macAddress());
Serial.printf("WiFi Channel: %d\n", WiFi.channel());
initNTP();
initBME280Inside();
bool fsok = LittleFS.begin(true);
Serial.printf("FS init: %s\n", fsok ? "ok" : "fail!");
ftpSrv.begin(F("admin"), F("admin"));
secondTicker.attach(1, countSecondsISR);
// ── ESP-NOW init — once only ──────────────────────────────────────────────
if (!ESP_NOW.begin()) {
Serial.println("ESP-NOW Engine Failed to Start");
return;
}
Serial.println("ESP-NOW init OK");
Serial.printf("sizeof(BlowerData)=%d sizeof(AlertFlag)=%d sizeof(BME280Data)=%d\n",
sizeof(BlowerData), sizeof(AlertFlag), sizeof(BME280Data));
if (!blowerNode.add_to_system()) {
Serial.println("Failed to bind Blower Node");
} else {
Serial.println("Blower node bound OK");
}
if (!bmeNode.add_to_system()) {
Serial.println("Failed to bind BME280 Node");
} else {
Serial.println("BME280 node bound OK");
}
Serial.println("Receiver listening... Setup complete.");
}
// ─── Loop State Machine ───────────────────────────────────────────────────────
void loop() {
ftpSrv.handleFTP();
// ── Gatekeeper: alertFlag handler ────────────────────────────────────────
if (alertFlag) {
alertFlag = false;
Serial.println("[ESP-NOW] Sending alert to BME280 node...");
bool sent = bmeNode.sendAlert(true);
Serial.printf("[ESP-NOW] Alert send: %s\n", sent ? "OK" : "FAILED");
delay(1000);
float insideTempF = NAN, insideHumidity = NAN, insidePressureHPa = NAN;
readBME280Inside(insideTempF, insideHumidity, insidePressureHPa);
sensordata.insideTemp = insideTempF;
sensordata.insideHumidity = insideHumidity;
sensordata.outsideTemp = globalTemp;
sensordata.thermostat = thermostatSetpoint;
sensordata.outsidePressure = globalPressure;
sensordata.insidePressure = insidePressureHPa;
sensordata.pressureDiffHPa = globalPressure - insidePressureHPa;
Serial.printf("[BME280 Inside] Temp: %.2f °F Hum: %.2f %% Pres: %.4f inHg\n",
insideTempF, insideHumidity, insidePressureHPa * 0.02953);
Serial.printf("[Pressure] Outside: %.4f inHg | Inside: %.4f inHg | Diff(out-in): %.4f inHg\n",
toSeaLevelHPa(sensordata.outsidePressure, sensordata.outsideTemp) * 0.02953,
toSeaLevelHPa(sensordata.insidePressure, sensordata.insideTemp) * 0.02953,
sensordata.pressureDiffHPa * 0.02953); // diff stays absolute
getDateTime();
displayData();
logData();
sendGoogleSheetsData();
googleSheetsSent = true;
}
// ── Reset after pipeline ──────────────────────────────────────────────────
if (googleSheetsSent) {
elapsedMinutes = 0;
googleSheetsSent = false;
}
// ── One-second — midnight reset ───────────────────────────────────────────
if (oneSecondElapsed) {
oneSecondElapsed = false;
static bool didMidnightReset = false;
if (HOUR == 0 && MINUTE == 0 && SECOND == 0) {
if (!didMidnightReset) {
dailyTotalMinutes = 0;
Serial.println("Midnight reset: dailyTotalMinutes = 0");
didMidnightReset = true;
}
} else {
didMidnightReset = false;
}
}
}
// ─── Google Sheets Bridge ─────────────────────────────────────────────────────
void sendGoogleSheetsData() {
sendDataToServer(dtStamp,
sensordata.outsideTemp,
sensordata.insideTemp,
sensordata.insideHumidity,
thermostatSetpoint,
elapsedMinutes,
dailyTotalMinutes,
sensordata.outsidePressure,
sensordata.insidePressure,
sensordata.pressureDiffHPa);
}
void sendDataToServer(String updateTime, float outT, float inT, float inHum,
float therm, double eventM, double dailyM,
float outP, float inP, float diffP) {
if (WiFi.status() != WL_CONNECTED) {
Serial.println("⚠️ Sheet update blocked: Wi-Fi offline.");
return;
}
WiFiClientSecure secureClient;
secureClient.setInsecure();
HTTPClient http;
String data = "?lastUpdate=" + urlEncode(updateTime)
+ "&outsideTemp=" + String(outT, 2)
+ "&insideTemp=" + String(inT, 2)
+ "&insideHumidity=" + String(inHum, 2)
+ "&thermostat=" + String(therm, 2)
+ "&elapsedMinutes=" + String(eventM, 2)
+ "&dailyTotalMinutes=" + String(dailyM, 2)
+ "&outsidePressure=" + String(toSeaLevelHPa(outP, sensordata.outsideTemp) * 0.02953, 4)
+ "&insidePressure=" + String(toSeaLevelHPa(inP, sensordata.insideTemp) * 0.02953, 4)
+ "&pressureDiff=" + String(diffP * 0.02953, 4); // diff stays absolute
String urlFinal = googleURL + data;
Serial.println("\n[HTTP] Target: " + urlFinal);
if (http.begin(secureClient, urlFinal)) {
http.addHeader("Content-Type", "application/x-www-form-urlencoded");
http.setFollowRedirects(HTTPC_FORCE_FOLLOW_REDIRECTS);
http.setTimeout(10000);
int httpCode = http.GET();
Serial.printf("[HTTP] Status Code: %d\n", httpCode);
if (httpCode > 0) {
Serial.println("[HTTP] Response: " + http.getString());
} else {
Serial.printf("[HTTP] Error: %s\n", http.errorToString(httpCode).c_str());
}
http.end();
} else {
Serial.println("⚠️ Secure connection failed.");
}
}
// ─── Local File Logging ───────────────────────────────────────────────────────
void logData() {
if (!LittleFS.exists("/log.txt")) {
File setupFile = LittleFS.open("/log.txt", FILE_WRITE);
if (!setupFile) return;
setupFile.println("Timestamp, OutsideTemp, InsideTemp, InsideHumidity, Setpoint, EventDuration, TotalRuntime, OutsidePressure_inHg, InsidePressure_inHg, PressureDiff_inHg");
setupFile.close();
}
File appendLog = LittleFS.open("/log.txt", FILE_APPEND);
if (!appendLog) return;
appendLog.print(dtStamp);
appendLog.print(" , Outside: "); appendLog.print(sensordata.outsideTemp, 2);
appendLog.print(" F, Inside: "); appendLog.print(sensordata.insideTemp, 2);
appendLog.print(" F, In.Humidity: "); appendLog.print(sensordata.insideHumidity, 2);
appendLog.print(" %, Thermostat: "); appendLog.print(thermostatSetpoint, 2);
appendLog.print(" F, Event: "); appendLog.print(sensordata.lastEventMinutes, 2);
appendLog.print(", Total 24HR: "); appendLog.print(sensordata.dailyTotalMinutes, 2);
appendLog.print(", OutP: "); appendLog.print(toSeaLevelHPa(sensordata.outsidePressure, sensordata.outsideTemp) * 0.02953, 4);
appendLog.print(" inHg, InP: "); appendLog.print(toSeaLevelHPa(sensordata.insidePressure, sensordata.insideTemp) * 0.02953, 4);
appendLog.print(" inHg, Diff: "); appendLog.print(sensordata.pressureDiffHPa * 0.02953, 4);
appendLog.print(" inHg\n");
appendLog.close();
}
// ─── Stubs ────────────────────────────────────────────────────────────────────
void updateHeatingData() {}
void displayData() {}
void disabledailyCooling() {}
void resetDailyStats() {}
void temperatureInterrupt() {}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment