Build Sheets

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);
}