Source
weather_station.ino
/*
* weather_station.ino — LCARS outdoor weather station (producer node WX-01)
*
* Board : Seeed XIAO ESP32S3 (u.FL antenna — ATTACH BEFORE POWER)
* Sensor : Waveshare BME280 breakout, I2C on the XIAO defaults (D4/D5)
* Power : USB-C mains (optional LiPo UPS on the BAT pads — see README)
* Role : push-only producer — POST a reading to lcars-core every
* PUSH_INTERVAL_S, 06:00–23:00 local (NTP wall clock, not millis),
* raw HTTP over WiFiClient. No inbound server of any kind.
*
* Libraries (Library Manager): Adafruit BME280 Library + Adafruit Unified
* Sensor. Nothing else — HTTPClient links the TLS stack and overflows flash
* (see docs/runbooks/new-node.md), and push-only needs no web server or JSON
* library.
*
* Build from a SKETCHBOOK COPY, never the repo checkout (README, Flashing).
*/
#include <WiFi.h>
#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <time.h>
#include "config.h"
#define FW_VERSION "1.0.0"
#if DEBUG
#define LOG(x) Serial.print(x)
#define LOGLN(x) Serial.println(x)
#else
#define LOG(x)
#define LOGLN(x)
#endif
Adafruit_BME280 bme;
bool sensorOk = false; // sensor detected and last read valid
bool pushOk = true; // last push reached the server
// Last-good readings (NAN until the first successful read)
float lastTempC = NAN;
float lastHumidity = NAN;
float lastPressure = NAN;
long lastPushSlot = -1; // wall-clock slot of the last push
unsigned long lastPushMs = 0; // millis fallback until NTP syncs
unsigned long lastWifiTryMs = 0;
unsigned long ledPulseUntil = 0; // OK double-blink window after a push
// ---------- Time ----------
bool timeSynced() { return time(nullptr) > 1600000000; } // sanity: past 2020
bool inPushWindow() {
if (!timeSynced()) return true; // fail open — data beats silence after an outage
struct tm t;
time_t now = time(nullptr);
localtime_r(&now, &t);
return t.tm_hour >= WINDOW_START_HOUR && t.tm_hour < WINDOW_END_HOUR;
}
// ---------- Sensor ----------
bool readSensor() {
if (!bme.takeForcedMeasurement()) return false;
float t = bme.readTemperature();
float h = bme.readHumidity();
float p = bme.readPressure() / 100.0F; // Pa -> hPa
if (isnan(t) || isnan(h) || isnan(p)) return false;
lastTempC = t; lastHumidity = h; lastPressure = p;
return true;
}
// Append a float or JSON null (a dead sensor still reports aliveness).
void numOrNull(char *out, size_t n, float v, int dp) {
if (isnan(v)) snprintf(out, n, "null");
else snprintf(out, n, "%.*f", dp, v);
}
// ---------- Push ----------
void pushReading() {
sensorOk = readSensor();
if (WiFi.status() != WL_CONNECTED) { pushOk = false; return; }
char t[16], h[16], p[16], body[256];
numOrNull(t, sizeof t, lastTempC, 2);
numOrNull(h, sizeof h, lastHumidity, 1);
numOrNull(p, sizeof p, lastPressure, 1);
int blen = snprintf(body, sizeof body,
"{\"device_id\":\"%s\",\"fw_version\":\"%s\",\"temperature_c\":%s,"
"\"humidity_pct\":%s,\"pressure_hpa\":%s,\"rssi\":%d,\"sensor_ok\":%s}",
DEVICE_ID, FW_VERSION, t, h, p, WiFi.RSSI(), sensorOk ? "true" : "false");
WiFiClient client;
client.setTimeout(HTTP_TIMEOUT_MS);
if (!client.connect(SERVER_HOST, SERVER_PORT)) {
LOGLN("connect failed");
pushOk = false;
return;
}
client.printf(
"POST %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\n"
"X-Device-Token: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
INGEST_PATH, SERVER_HOST, DEVICE_TOKEN, blen, body);
String status = client.readStringUntil('\n'); // "HTTP/1.1 200 OK"
client.stop();
pushOk = status.indexOf("200") > 0;
LOG("push -> "); LOGLN(status);
if (pushOk) ledPulseUntil = millis() + 600;
}
// ---------- WiFi ----------
void ensureWifi() {
if (WiFi.status() == WL_CONNECTED) return;
if (millis() - lastWifiTryMs < WIFI_RETRY_MS) return;
lastWifiTryMs = millis();
LOGLN("WiFi connecting...");
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
}
// ---------- Status LED (yellow user LED, GPIO21, ACTIVE LOW) ----------
// dark = nominal/night · double-blink = push sent · slow blink = WiFi down ·
// fast blink = push failing
void updateLed() {
bool on = false;
unsigned long ms = millis();
if (ms < ledPulseUntil) on = (ms / 150) % 2; // OK double-blink
else if (WiFi.status() != WL_CONNECTED) on = (ms / 1000) % 2; // slow: WiFi down
else if (!pushOk) on = (ms / 250) % 2; // fast: push failing
digitalWrite(PIN_LED, on ? LOW : HIGH);
}
// ---------- Setup / loop ----------
void setup() {
#if DEBUG
Serial.begin(115200);
delay(200);
#endif
pinMode(PIN_LED, OUTPUT);
digitalWrite(PIN_LED, HIGH); // off (active low)
#if USE_BUTTON
pinMode(PIN_BUTTON, INPUT_PULLUP);
#endif
Wire.begin(); // XIAO ESP32S3 defaults: SDA=D4/GPIO5, SCL=D5/GPIO6
sensorOk = bme.begin(BME280_ADDR);
if (!sensorOk) sensorOk = bme.begin(BME280_ADDR_ALT);
if (sensorOk) {
// Weather-station profile: forced mode, 1x oversampling, no IIR filter —
// one on-demand conversion per push minimizes sensor self-heating.
bme.setSampling(Adafruit_BME280::MODE_FORCED,
Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::SAMPLING_X1,
Adafruit_BME280::SAMPLING_X1, Adafruit_BME280::FILTER_OFF);
}
LOGLN(sensorOk ? "BME280 OK" : "BME280 NOT FOUND");
ensureWifi();
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) {
digitalWrite(PIN_LED, (millis() / 250) % 2 ? LOW : HIGH);
delay(50);
}
if (WiFi.status() == WL_CONNECTED) {
LOG("MAC: "); LOGLN(WiFi.macAddress()); // first flash: DHCP reservation
LOG("IP: "); LOGLN(WiFi.localIP());
}
configTzTime(TZ_INFO, NTP_SERVER); // wall-clock scheduling survives reboots
if (inPushWindow()) pushReading(); // boot push — announces the node
lastPushMs = millis();
if (timeSynced()) lastPushSlot = time(nullptr) / PUSH_INTERVAL_S;
}
void loop() {
ensureWifi();
updateLed();
bool due;
if (timeSynced()) {
long slot = time(nullptr) / PUSH_INTERVAL_S; // aligned to the wall clock
due = slot != lastPushSlot;
if (due) lastPushSlot = slot;
} else {
due = millis() - lastPushMs >= PUSH_INTERVAL_S * 1000UL;
}
if (due) {
if (inPushWindow()) pushReading();
lastPushMs = millis();
}
#if USE_BUTTON
static unsigned long lastBtn = 0;
if (digitalRead(PIN_BUTTON) == LOW && millis() - lastBtn > 500) {
lastBtn = millis();
LOGLN("button: forced push");
pushReading();
}
#endif
delay(10);
}