// ============================================================ // WEATHER SENSOR (outdoor unit) -- ESP32 // // Sensors (all I2C, shared bus): // DS3231 RTC SDA/SCL // AHT20+BMP280 combo SDA/SCL (AHT20 = temp, BMP280 = pressure) // EL-SM-008 128x64 OLED SDA/SCL (SSD1306 driver, addr 0x3C) // // Wiring (all four wires shared across all three modules): // VCC -> 3V3 // GND -> GND // SDA -> D21 (GPIO21) // SCL -> D22 (GPIO22) // // Shows: current temp (C, compact) next to a larger drawn moon-phase // icon, plus pressure (hPa), date + time, and the phase name -- // all computed from the RTC date (no internet / almanac needed, // Conway's algorithm, +/-1 day). // // Also broadcasts every reading over ESP-NOW (fixed WiFi channel 1, // no pairing needed) so an IndoorDisplay unit can show it -- see // ESP32_WeatherStation/IndoorDisplay. StationPacket below must stay // byte-identical between the two sketches. // // Libraries needed (Library Manager): // RTClib (Adafruit) -- already installed // Adafruit SSD1306 -- already installed // Adafruit GFX Library -- already installed // Adafruit AHTX0 -- NEW, install this // Adafruit BMP280 Library -- NEW, install this // Adafruit Unified Sensor -- already installed (dependency) // // NOTE: run rtc_set.ino once first if the DS3231 hasn't been // time-set yet, then come back to this sketch. // ============================================================ #include #include #include #include #include #include #include #include #include #include #include #include // ---------- pins / addresses ---------- #define SDA_PIN 21 #define SCL_PIN 22 #define OLED_W 128 #define OLED_H 64 #define OLED_ADDR 0x3C // some panels are strapped to 0x3D // ---------- layout (tweak these if spacing looks off on the real panel) ---------- #define Y_DATETIME 0 // top line, text size 1 ("August 8 19:59") #define Y_PRESSURE 10 // text size 1 #define Y_TEMP 22 // temp, text size 2 #define Y_MOONNAME 48 // text size 1 #define MOON_CX 97 // moon icon center x #define MOON_CY 30 // moon icon center y (roughly centered on temp row) #define MOON_R 14 // moon icon radius (px) const char* MONTH_NAMES[] = { "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" }; // ---------- refresh timing ---------- #define REFRESH_MS 2000 // ---------- home WiFi upload (logs to your own server; separate from ESP-NOW) ---------- // This station spends almost all its time in ESP-NOW-only mode on ESPNOW_CHANNEL // so the indoor display link stays live. Every UPLOAD_MS it briefly joins your // home WiFi, POSTs one reading, then drops back to ESP-NOW-only mode. This // avoids the two radios ever needing to agree on a channel permanently -- the // indoor display may miss one or two of its 2-second broadcasts during the // upload window, which is well inside its 8-second "link lost" tolerance. #define WIFI_SSID "m2g" // <-- fill in #define WIFI_PASSWORD "your-password-here" // <-- fill in #define UPLOAD_URL "https://marshallevetts.com/weather/ingest.php" #define UPLOAD_KEY "" // must match API_KEY in ingest.php; "" = no check #define UPLOAD_MS 60000 // how often to log a reading, ms #define WIFI_CONNECT_TIMEOUT_MS 4000 // give up and resume ESP-NOW if slower than this unsigned long lastUpload = 0; // ---------- wireless (broadcasts to IndoorDisplay) ---------- #define ESPNOW_CHANNEL 1 #define PKT_MAGIC 0x57 // 'W' typedef struct __attribute__((packed)) { uint8_t magic; uint32_t seq; float tempC; float pressureHpa; uint16_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute; uint8_t moonAge; // 0-29, see moonAgeDays() } StationPacket; // A byte-identical copy lives in IndoorDisplay.ino -- keep both in sync. static_assert(sizeof(StationPacket) == 20, "wire format changed -- update IndoorDisplay.ino too"); static const uint8_t BCAST[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; uint32_t txSeq = 0; Adafruit_SSD1306 display(OLED_W, OLED_H, &Wire, -1); bool displayOk = false; // OLED is optional -- station runs headless without one RTC_DS3231 rtc; Adafruit_AHTX0 aht; Adafruit_BMP280 bmp; unsigned long lastRefresh = 0; // ---------- I2C helpers ---------- bool probe(uint8_t addr) { Wire.beginTransmission(addr); return Wire.endTransmission() == 0; } void scanBus() { Serial.println(F("I2C scan:")); for (uint8_t a = 1; a < 127; a++) { if (probe(a)) Serial.printf(" found 0x%02X\n", a); } } // ---------- moon phase ---------- // Conway's "cheap" moon age algorithm. Returns 0-29 (days into the // synodic month). Good to about +/-1 day -- plenty for a fun display. int moonAgeDays(int year, int month, int day) { int r = year % 100; r %= 19; if (r > 9) r -= 19; r = ((r * 11) % 30) + month + day; if (month < 3) r += 2; r -= (year < 2000) ? 4 : 8; int age = r % 30; if (age < 0) age += 30; return age; } const char* moonPhaseName(int age) { if (age == 0 || age == 29) return "New Moon"; else if (age < 7) return "Waxing Crescent"; else if (age == 7) return "First Quarter"; else if (age < 14) return "Waxing Gibbous"; else if (age == 14) return "Full Moon"; else if (age < 21) return "Waning Gibbous"; else if (age == 21) return "Last Quarter"; else return "Waning Crescent"; } // Draws a lit/dark moon disc. phase: 0 = new, 0.5 = full (fraction // of the 29.53-day synodic month). void drawMoonIcon(int16_t cx, int16_t cy, int16_t r, float phase) { float angle = phase * 2.0f * PI; for (int16_t y = -r; y <= r; y++) { int16_t halfW = (int16_t)sqrt((float)(r * r - y * y)); if (halfW == 0) continue; float termX = halfW * cos(angle); for (int16_t x = -halfW; x <= halfW; x++) { bool lit = (phase <= 0.5f) ? (x > termX) : (x < -termX); if (lit) display.drawPixel(cx + x, cy + y, SSD1306_WHITE); } } display.drawCircle(cx, cy, r, SSD1306_WHITE); } // Draws "24.3" followed by a small hand-drawn degree circle and "C", // left-aligned at (x,y), text size 2. void drawTempC(int16_t x, int16_t y, float tempC) { display.setTextSize(2); display.setCursor(x, y); display.printf("%.1f", tempC); int16_t degX = display.getCursorX() + 2; int16_t degY = y + 2; display.drawCircle(degX, degY, 2, SSD1306_WHITE); display.setCursor(degX + 6, y); display.print("C"); } // ---------- ESP-NOW ---------- void espNowInit() { WiFi.mode(WIFI_STA); WiFi.disconnect(false, true); WiFi.setSleep(false); esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE); if (esp_now_init() != ESP_OK) { Serial.println(F("esp_now_init failed -- rebooting")); delay(2000); ESP.restart(); } esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, BCAST, 6); peer.channel = ESPNOW_CHANNEL; peer.encrypt = false; esp_now_add_peer(&peer); Serial.println(F("ESP-NOW broadcasting on ch1")); } void sendPacket(float tempC, float pressureHpa, const DateTime &dt, uint8_t moonAge) { StationPacket p{}; p.magic = PKT_MAGIC; p.seq = ++txSeq; p.tempC = tempC; p.pressureHpa = pressureHpa; p.year = dt.year(); p.month = dt.month(); p.day = dt.day(); p.hour = dt.hour(); p.minute = dt.minute(); p.moonAge = moonAge; esp_err_t r = esp_now_send(BCAST, (const uint8_t*)&p, sizeof(p)); if (r != ESP_OK) { Serial.printf("esp_now_send failed: %s\n", esp_err_to_name(r)); } } // Briefly joins home WiFi, POSTs one JSON reading to UPLOAD_URL, then always // drops back to ESP-NOW-only mode on ESPNOW_CHANNEL -- whether the upload // succeeded, failed, or timed out -- so the indoor display link is never left // broken by a flaky home network. void uploadReading(float tempC, float pressureHpa, const DateTime &dt, uint8_t moonAge) { Serial.println(F("[wifi] connecting to log a reading...")); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); uint32_t start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < WIFI_CONNECT_TIMEOUT_MS) { delay(100); } if (WiFi.status() == WL_CONNECTED) { // UPLOAD_URL is https:// -- plain HTTPClient::begin(url) can't do TLS on // its own. setInsecure() skips certificate validation entirely (fine for // a hobby sensor posting to your own server; swap in setCACert() with // your Let's Encrypt chain if you want it verified). WiFiClientSecure client; client.setInsecure(); HTTPClient http; http.begin(client, UPLOAD_URL); http.addHeader("Content-Type", "application/json"); char body[192]; snprintf(body, sizeof(body), "{\"key\":\"%s\",\"temp_c\":%.2f,\"pressure_hpa\":%.2f," "\"year\":%d,\"month\":%d,\"day\":%d,\"hour\":%d,\"minute\":%d,\"moon_age\":%d}", UPLOAD_KEY, tempC, pressureHpa, dt.year(), dt.month(), dt.day(), dt.hour(), dt.minute(), moonAge); int code = http.POST((uint8_t*)body, strlen(body)); Serial.printf("[wifi] upload -> HTTP %d\n", code); http.end(); } else { Serial.println(F("[wifi] connect timed out -- skipping this upload")); } // disconnect(true) would power the radio off entirely, so the channel set // right after it would silently do nothing -- drop the AP association only, // leave the radio on, then restore ESP-NOW's channel. WiFi.disconnect(false); WiFi.mode(WIFI_STA); delay(50); esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE); } void setup() { Serial.begin(115200); delay(400); Serial.println(); Serial.println(F("=== WEATHER SENSOR ===")); Wire.begin(SDA_PIN, SCL_PIN); scanBus(); // --- OLED (optional -- station runs headless without one) --- displayOk = probe(OLED_ADDR); if (displayOk) { display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR); display.clearDisplay(); display.setTextColor(SSD1306_WHITE); display.setTextSize(1); display.setCursor(0, 0); display.println(F("Weather sensor")); display.println(F("starting...")); display.display(); } else { Serial.printf("no ACK at 0x%02X -- running headless (no OLED)\n", OLED_ADDR); } // --- RTC --- while (!rtc.begin()) { Serial.println(F("Could not find DS3231 - check wiring")); delay(1000); } if (rtc.lostPower()) { Serial.println(F("RTC lost power - time is unset! Run rtc_set.ino.")); } // --- AHT20 (temperature) --- while (!aht.begin()) { Serial.println(F("Could not find AHT20 - check wiring")); delay(1000); } // --- BMP280 (pressure) --- try both common addresses while (!bmp.begin(0x76) && !bmp.begin(0x77)) { Serial.println(F("Could not find BMP280 - check wiring")); delay(1000); } bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, Adafruit_BMP280::SAMPLING_X2, // temp (unused here) Adafruit_BMP280::SAMPLING_X16, // pressure Adafruit_BMP280::FILTER_X4, Adafruit_BMP280::STANDBY_MS_63); espNowInit(); Serial.println(F("All sensors online.")); lastRefresh = millis() - REFRESH_MS; // force first draw immediately } void loop() { unsigned long now = millis(); if (now - lastRefresh < REFRESH_MS) return; lastRefresh = now; DateTime dt = rtc.now(); sensors_event_t humEvt, tempEvt; aht.getEvent(&humEvt, &tempEvt); float tempC = tempEvt.temperature; float pressureHpa = bmp.readPressure() / 100.0f; int age = moonAgeDays(dt.year(), dt.month(), dt.day()); float phase = age / 29.53f; const char* phaseName = moonPhaseName(age); Serial.printf("%s %d %02d:%02d %.1fC %.1fhPa %s (age %d)\n", MONTH_NAMES[dt.month() - 1], dt.day(), dt.hour(), dt.minute(), tempC, pressureHpa, phaseName, age); sendPacket(tempC, pressureHpa, dt, (uint8_t)age); if (now - lastUpload >= UPLOAD_MS) { lastUpload = now; uploadReading(tempC, pressureHpa, dt, (uint8_t)age); } if (displayOk) { display.clearDisplay(); // date + time (e.g. "August 8 19:59") display.setTextSize(1); display.setCursor(0, Y_DATETIME); display.printf("%s %d %02d:%02d", MONTH_NAMES[dt.month() - 1], dt.day(), dt.hour(), dt.minute()); // pressure display.setCursor(0, Y_PRESSURE); display.printf("%.1f hPa", pressureHpa); // temperature drawTempC(0, Y_TEMP, tempC); // moon icon drawMoonIcon(MOON_CX, MOON_CY, MOON_R, phase); // moon phase name display.setTextSize(1); display.setCursor(0, Y_MOONNAME); display.print(phaseName); display.display(); } }