// pager_tx_gps_sht40_pocsag.ino // // ESP32 outdoor tracker that reads GPS position and SHT40 temperature/humidity, // transmits periodic status pages over POCSAG (449.925 MHz), and sends a drift // alarm when distance from a user-set home point exceeds a threshold. // // A WiFi SoftAP hosts a simple web UI to set home, drift threshold, and pager // transmit interval. GPIO33 (J4, internal pull-up, active low) toggles arm/disarm. // Hardware: LLCC68/CC68 radio, GPS UART, SHT40 on I2C. // // rob #include #include #include #include #include #include #include #include #include #include // ==================== Logging ==================== static constexpr uint32_t SERIAL_BAUD = 115200; static constexpr bool VERBOSE_LOG = true; #define LOGI(...) do { Serial.printf(__VA_ARGS__); Serial.print("\r\n"); } while(0) #define LOGE(...) do { Serial.printf("ERR: "); Serial.printf(__VA_ARGS__); Serial.print("\r\n"); } while(0) // ==================== Pin mapping (pinout.txt) ==================== static constexpr uint8_t PIN_I2C_SDA = 4; static constexpr uint8_t PIN_I2C_SCL = 15; static constexpr uint8_t PIN_GPS_TX = 17; // ESP32 TX -> GPS_RX static constexpr uint8_t PIN_GPS_RX = 16; // ESP32 RX <- GPS_TX static constexpr uint8_t PIN_CC68_CS = 5; static constexpr uint8_t PIN_CC68_SCK = 18; static constexpr uint8_t PIN_CC68_MISO = 19; static constexpr uint8_t PIN_CC68_MOSI = 23; static constexpr uint8_t PIN_CC68_RST = 14; static constexpr uint8_t PIN_CC68_BUSY = 34; static constexpr uint8_t PIN_CC68_DIO1 = 35; static constexpr uint8_t PIN_LED1 = 12; static constexpr uint8_t PIN_LED2 = 13; static constexpr uint8_t PIN_ARM_TOGGLE = 33; // J4, active low, internal pull-up // ==================== POCSAG / RF parameters ==================== static constexpr float PAGER_FREQ_MHZ = 449.925f; static constexpr uint16_t POCSAG_BPS = 1200; static constexpr uint32_t CAPCODE = 1234567; // POCSAG over 2-FSK. RadioLib PagerClient defaults to 4500 Hz shift. static constexpr float FSK_BR_KBPS = 1.2f; // 1200 bps static constexpr float FSK_DEV_KHZ = 4.5f; // ~4.5 kHz deviation (matches default shift) static constexpr float FSK_RXBW_KHZ = 93.8f; // reasonable narrowband setting // TCXO control voltage for your module static constexpr float TCXO_VOLTAGE = 1.6f; // ==================== WiFi host (SoftAP) ==================== static constexpr const char* WIFI_AP_SSID = "pagertracker"; static constexpr const char* WIFI_AP_PASS = "pagertracker"; // must be >= 8 chars; change if you want static constexpr uint16_t WIFI_HTTP_PORT = 80; WebServer server(WIFI_HTTP_PORT); // ==================== Drift alarm settings ==================== static constexpr uint32_t ALARM_CAPCODE = 1424200; static constexpr uint32_t ALARM_COOLDOWN_MS = 30UL * 1000UL; // 30 sec static bool homeSet = false; static double homeLat = 0.0; static double homeLon = 0.0; static float thresholdFeet = 50.0f; static uint32_t pagerTxIntervalSec = 30; static uint32_t lastAlarmMs = 0; static bool armBtnWasHigh = true; static bool armBtnPressLatched = false; static uint32_t armBtnLastPressMs = 0; static constexpr uint32_t ARM_BTN_DEBOUNCE_MS = 250; static bool pendingArmToggle = false; static char pendingArmAlarmMsg[48] = ""; // ==================== Peripherals ==================== Adafruit_SHT4x sht4; TinyGPSPlus gps; HardwareSerial GPSSerial(1); // Use a dedicated SPI settings/profile for the radio. SPISettings radioSpiSettings(2000000, MSBFIRST, SPI_MODE0); // CC68 modules are commonly LLCC68-class in RadioLib (SX126x family derivative). LLCC68 radio(new Module(PIN_CC68_CS, PIN_CC68_DIO1, PIN_CC68_RST, PIN_CC68_BUSY, SPI, radioSpiSettings)); PagerClient pager(&radio); // ==================== Helpers ==================== static void blink(uint8_t pin, int times, int onMs = 80, int offMs = 120) { for(int i = 0; i < times; i++) { digitalWrite(pin, HIGH); delay(onMs); digitalWrite(pin, LOW); delay(offMs); } } static void gpsPumpForMs(uint32_t ms) { const uint32_t start = millis(); uint32_t bytes = 0; while(millis() - start < ms) { while(GPSSerial.available()) { gps.encode(GPSSerial.read()); bytes++; } pollArmButton(); delay(1); } if(VERBOSE_LOG) { LOGI("GPS pump %lu ms: %lu bytes, locValid=%d age=%lu, satsValid=%d sats=%lu", (unsigned long)ms, (unsigned long)bytes, (int)gps.location.isValid(), (unsigned long)gps.location.age(), (int)gps.satellites.isValid(), (unsigned long)gps.satellites.value()); } } static void gpsService(uint32_t maxBytes = 256) { uint32_t n = 0; while(GPSSerial.available() && n < maxBytes) { gps.encode(GPSSerial.read()); n++; } } static bool gpsFixFresh(uint32_t maxAgeMs = 30000) { return gps.location.isValid() && (gps.location.age() < maxAgeMs); } static void appendf(char* out, size_t outLen, const char* fmt, ...) { const size_t used = strnlen(out, outLen); if(used >= outLen) return; va_list ap; va_start(ap, fmt); vsnprintf(out + used, outLen - used, fmt, ap); va_end(ap); } static void dieBlink(const char* what, int16_t code) { LOGE("%s failed, code=%d", what, (int)code); while(true) { // blink(PIN_LED2, 2, 120, 200); // LED2: armed indicator only delay(600); } } static void logPins() { LOGI("Pins: I2C SDA=%u SCL=%u", PIN_I2C_SDA, PIN_I2C_SCL); LOGI("Pins: GPS RX=%u (from GPS_TX) TX=%u (to GPS_RX)", PIN_GPS_RX, PIN_GPS_TX); LOGI("Pins: CC68 CS=%u SCK=%u MISO=%u MOSI=%u RST=%u BUSY=%u DIO1=%u", PIN_CC68_CS, PIN_CC68_SCK, PIN_CC68_MISO, PIN_CC68_MOSI, PIN_CC68_RST, PIN_CC68_BUSY, PIN_CC68_DIO1); LOGI("Pins: LED1=%u LED2=%u (armed) ARM_TOGGLE=%u (pull-up, active low)", PIN_LED1, PIN_LED2, PIN_ARM_TOGGLE); } static void updateArmLed() { digitalWrite(PIN_LED2, homeSet ? HIGH : LOW); } static void scanI2C() { LOGI("I2C scan start..."); uint8_t found = 0; for(uint8_t addr = 1; addr < 127; addr++) { Wire.beginTransmission(addr); const uint8_t err = Wire.endTransmission(); if(err == 0) { LOGI("I2C device at 0x%02X", addr); found++; } } LOGI("I2C scan done. Found=%u", found); } static void logRadioPins(const char* tag) { pinMode(PIN_CC68_BUSY, INPUT); pinMode(PIN_CC68_DIO1, INPUT); pinMode(PIN_CC68_RST, OUTPUT); pinMode(PIN_CC68_CS, OUTPUT); digitalWrite(PIN_CC68_CS, HIGH); LOGI("%s: CC68 pin states: BUSY=%d DIO1=%d RST=%d CS=%d", tag, digitalRead(PIN_CC68_BUSY), digitalRead(PIN_CC68_DIO1), digitalRead(PIN_CC68_RST), digitalRead(PIN_CC68_CS)); } static void hardResetCC68() { // Conservative reset pulse; many SX126x modules want >100 us low. pinMode(PIN_CC68_RST, OUTPUT); digitalWrite(PIN_CC68_RST, HIGH); delay(2); digitalWrite(PIN_CC68_RST, LOW); delay(10); digitalWrite(PIN_CC68_RST, HIGH); delay(20); } static double haversineMeters(double lat1Deg, double lon1Deg, double lat2Deg, double lon2Deg) { static constexpr double R = 6371000.0; const double lat1 = lat1Deg * (M_PI / 180.0); const double lon1 = lon1Deg * (M_PI / 180.0); const double lat2 = lat2Deg * (M_PI / 180.0); const double lon2 = lon2Deg * (M_PI / 180.0); const double dlat = lat2 - lat1; const double dlon = lon2 - lon1; const double a = sin(dlat / 2.0) * sin(dlat / 2.0) + cos(lat1) * cos(lat2) * sin(dlon / 2.0) * sin(dlon / 2.0); const double c = 2.0 * atan2(sqrt(a), sqrt(1.0 - a)); return R * c; } static double currentDriftMeters(bool* ok) { if(!(gpsFixFresh() && homeSet)) { if(ok) *ok = false; return 0.0; } if(ok) *ok = true; return haversineMeters(homeLat, homeLon, gps.location.lat(), gps.location.lng()); } static bool armSystem() { if(!gpsFixFresh()) { LOGE("armSystem: GPS not valid"); return false; } homeLat = gps.location.lat(); homeLon = gps.location.lng(); homeSet = true; lastAlarmMs = 0; updateArmLed(); strncpy(pendingArmAlarmMsg, "SYSTEM ARMED", sizeof(pendingArmAlarmMsg) - 1); pendingArmAlarmMsg[sizeof(pendingArmAlarmMsg) - 1] = '\0'; return true; } static void disarmSystem() { homeSet = false; lastAlarmMs = 0; updateArmLed(); strncpy(pendingArmAlarmMsg, "SYSTEM DISARMED", sizeof(pendingArmAlarmMsg) - 1); pendingArmAlarmMsg[sizeof(pendingArmAlarmMsg) - 1] = '\0'; } static void initArmButton() { pinMode(PIN_ARM_TOGGLE, INPUT_PULLUP); armBtnWasHigh = (digitalRead(PIN_ARM_TOGGLE) == HIGH); armBtnPressLatched = false; armBtnLastPressMs = 0; } // Debounced falling edge; sets pendingArmToggle only (no arm/disarm here). static void pollArmButton() { const bool high = (digitalRead(PIN_ARM_TOGGLE) == HIGH); const uint32_t now = millis(); if(!high && armBtnWasHigh && !armBtnPressLatched && (now - armBtnLastPressMs >= ARM_BTN_DEBOUNCE_MS)) { armBtnPressLatched = true; armBtnLastPressMs = now; pendingArmToggle = true; LOGI("ARM input pressed"); } if(high) { armBtnPressLatched = false; } armBtnWasHigh = high; } static void processArmToggle() { if(!pendingArmToggle) return; pendingArmToggle = false; gpsService(512); if(homeSet) { disarmSystem(); } else if(!armSystem()) { strncpy(pendingArmAlarmMsg, "ARM FAILED NO GPS", sizeof(pendingArmAlarmMsg) - 1); pendingArmAlarmMsg[sizeof(pendingArmAlarmMsg) - 1] = '\0'; } } static bool flushPendingArmAlarm() { if(pendingArmAlarmMsg[0] == '\0') return false; LOGI("TX POCSAG (arm/disarm) to %lu: %s", (unsigned long)ALARM_CAPCODE, pendingArmAlarmMsg); digitalWrite(PIN_LED1, HIGH); const int16_t st = pager.transmit(pendingArmAlarmMsg, ALARM_CAPCODE, RADIOLIB_PAGER_ASCII); digitalWrite(PIN_LED1, LOW); LOGI("pager.transmit(arm/disarm) returned %d", (int)st); pendingArmAlarmMsg[0] = '\0'; return true; } // Poll, apply toggle, and TX arm/disarm page. Returns true if a page was sent. static bool serviceArmIfNeeded() { pollArmButton(); processArmToggle(); return flushPendingArmAlarm(); } static void waitWithWeb(uint32_t totalMs) { const uint32_t start = millis(); while(millis() - start < totalMs) { gpsService(256); server.handleClient(); pollArmButton(); if(pendingArmToggle) { break; } delay(3); } } static void startWifiHost() { WiFi.mode(WIFI_AP); const bool ok = WiFi.softAP(WIFI_AP_SSID, WIFI_AP_PASS); if(ok) { LOGI("WiFi AP started: SSID=%s PASS=%s IP=%s", WIFI_AP_SSID, WIFI_AP_PASS, WiFi.softAPIP().toString().c_str()); } else { LOGE("WiFi.softAP failed"); } } static String htmlPage() { // Very small, no external assets. String s; s.reserve(1600); s += F("" "" "PagerTracker" "" ""); s += F("

PagerTracker

"); s += F("
Current: ?
"); s += F("
Home: not set
"); s += F("
Distance: ?
"); s += F("
Threshold (feet): " "
"); s += F("
Pager TX interval (seconds): " "
"); s += F("
" "
"); s += F("
");
  s += F("");
  s += F("");
  return s;
}

static void setupWeb() {
  server.on("/", HTTP_GET, []() {
    server.send(200, "text/html", htmlPage());
  });

  server.on("/status", HTTP_GET, []() {
    String cur = "LAT=? LON=?";
    if(gpsFixFresh()) {
      cur = String("LAT=") + String(gps.location.lat(), 6) + " LON=" + String(gps.location.lng(), 6);
    }

    String home = "not set";
    if(homeSet) {
      home = String("LAT=") + String(homeLat, 6) + " LON=" + String(homeLon, 6);
    }

    bool ok = false;
    const double dm = currentDriftMeters(&ok);
    String dist = ok ? (String(dm * 3.28084, 1) + " ft") : String("n/a");

    String note;
    if(!homeSet) note = "Disarmed.";
    else if(!gpsFixFresh()) note = "GPS fix not valid.";
    else note = "Armed.";

    String json = "{";
    json += "\"cur\":\"" + cur + "\",";
    json += "\"home\":\"" + home + "\",";
    json += "\"dist\":\"" + dist + "\",";
    json += "\"thr\":\"" + String(thresholdFeet, 0) + "\",";
    json += "\"txint\":\"" + String(pagerTxIntervalSec) + "\",";
    json += "\"note\":\"" + note + "\"";
    json += "}";
    server.send(200, "application/json", json);
  });

  server.on("/setThreshold", HTTP_POST, []() {
    if(!server.hasArg("feet")) {
      server.send(400, "text/plain", "missing feet");
      return;
    }
    const float f = server.arg("feet").toFloat();
    if(!(isfinite(f) && f >= 1.0f && f <= 52800.0f)) {
      server.send(400, "text/plain", "invalid feet (1..52800)");
      return;
    }
    thresholdFeet = f;
    server.send(200, "text/plain", String("OK thresholdFeet=") + String(thresholdFeet, 0));
  });

  server.on("/setPagerInterval", HTTP_POST, []() {
    if(!server.hasArg("seconds")) {
      server.send(400, "text/plain", "missing seconds");
      return;
    }
    const uint32_t sec = (uint32_t)server.arg("seconds").toInt();
    if(sec < 5 || sec > 3600) {
      server.send(400, "text/plain", "invalid seconds (5..3600)");
      return;
    }
    pagerTxIntervalSec = sec;
    server.send(200, "text/plain", String("OK pagerTxIntervalSec=") + String(pagerTxIntervalSec));
  });

  server.on("/setHome", HTTP_POST, []() {
    gpsService(512);
    if(!armSystem()) {
      server.send(409, "text/plain", "GPS not valid");
      return;
    }
    server.send(200, "text/plain", "OK home set");
  });

  server.on("/disarm", HTTP_POST, []() {
    disarmSystem();
    server.send(200, "text/plain", "OK disarmed");
  });

  server.begin();
  LOGI("HTTP server started on port %u", (unsigned)WIFI_HTTP_PORT);
}

void setup() {
  pinMode(PIN_LED1, OUTPUT);
  pinMode(PIN_LED2, OUTPUT);
  digitalWrite(PIN_LED1, LOW);
  updateArmLed();  // disarmed at boot
  initArmButton();

  Serial.begin(SERIAL_BAUD);
  delay(400);
  LOGI("Boot. Serial baud=%lu", (unsigned long)SERIAL_BAUD);
  LOGI("Build: %s %s", __DATE__, __TIME__);
  logPins();

  startWifiHost();
  setupWeb();

  // I2C for SHT40 (pinout.txt says SHT40 at 0x40)
  Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL);
  // Prevent hard hangs on a wedged I2C bus/device.
#if defined(ARDUINO_ARCH_ESP32)
  Wire.setTimeOut(50);  // ms
#endif
  LOGI("I2C started.");
  scanI2C();
  LOGI("SHT40: calling begin() (expected address is usually 0x44)");
  const bool shtBeginOk = sht4.begin();
  LOGI("SHT40: begin() returned %d", (int)shtBeginOk);
  if(!shtBeginOk) {
    LOGE("SHT40 begin() failed. Check wiring/power/address.");
  } else {
    // Avoid readSerial() here—on some I2C failures it can block depending on core/library behavior.
    sht4.setPrecision(SHT4X_HIGH_PRECISION);
    sht4.setHeater(SHT4X_NO_HEATER);
    LOGI("SHT40 configured: high precision, no heater");
  }

  // GPS UART (most NMEA modules default to 9600)
  GPSSerial.begin(9600, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX);
  LOGI("GPS UART started: 9600 8N1 on Serial1 (RX=%u TX=%u)", PIN_GPS_RX, PIN_GPS_TX);

  // SPI pins for CC68
  SPI.begin(PIN_CC68_SCK, PIN_CC68_MISO, PIN_CC68_MOSI, PIN_CC68_CS);
  LOGI("SPI started: SCK=%u MISO=%u MOSI=%u CS=%u", PIN_CC68_SCK, PIN_CC68_MISO, PIN_CC68_MOSI, PIN_CC68_CS);

  // Start radio in FSK mode (required for POCSAG).
  logRadioPins("Before CC68 reset");
  LOGI("Resetting CC68...");
  hardResetCC68();
  logRadioPins("After CC68 reset");

  // If BUSY is stuck high here, the chip isn't coming out of reset or BUSY wiring/power is wrong.
  LOGI("Waiting up to 2000 ms for BUSY=0...");
  const uint32_t busyStart = millis();
  while(digitalRead(PIN_CC68_BUSY) == HIGH && (millis() - busyStart) < 2000) {
    delay(5);
  }
  LOGI("BUSY wait done after %lu ms, BUSY=%d", (unsigned long)(millis() - busyStart), digitalRead(PIN_CC68_BUSY));

  // Critical for TCXO modules: force RadioLib to enable TCXO control on DIO3.
  // Without this, the LLCC68 driver may assume XTAL and never issue 0x97 (SET_DIO3_AS_TCXO_CTRL),
  // which leads to XOSC_START_ERR (0x0020) and init failing with -707.
  radio.XTAL = false;
  delay(100);
  LOGI("Radio: forced XTAL=%d (0 means TCXO mode)", (int)radio.XTAL);

  // Use the LLCC68 beginFSK overload that also sets frequency/power,
  // matching the init pattern that worked in your llcc68_lora_test.
  LOGI("Radio beginFSK: freq=%.6f MHz br=%.3f kbps dev=%.3f kHz rxbw=%.1f kHz pwr=%d preamble=%u tcxoV=%.1f",
       PAGER_FREQ_MHZ, FSK_BR_KBPS, FSK_DEV_KHZ, FSK_RXBW_KHZ, 14, 16, TCXO_VOLTAGE);
  int16_t state = radio.beginFSK(PAGER_FREQ_MHZ, FSK_BR_KBPS, FSK_DEV_KHZ, FSK_RXBW_KHZ, 14, 16, TCXO_VOLTAGE);
  LOGI("radio.beginFSK returned %d", (int)state);
  if(state != RADIOLIB_ERR_NONE) dieBlink("radio.beginFSK", state);
  LOGI("radio.beginFSK OK");

  // Initialize POCSAG protocol layer.
  LOGI("Pager begin: base=%.6f MHz speed=%u bps", PAGER_FREQ_MHZ, (unsigned)POCSAG_BPS);
  state = pager.begin(PAGER_FREQ_MHZ, POCSAG_BPS);
  if(state != RADIOLIB_ERR_NONE) dieBlink("pager.begin", state);
  LOGI("pager.begin OK");

  LOGI("POCSAG TX ready: freq=%.6f MHz, bps=%u, capcode=%lu", PAGER_FREQ_MHZ, (unsigned)POCSAG_BPS, (unsigned long)CAPCODE);
  blink(PIN_LED1, 3);
}

void loop() {
  static uint32_t loopN = 0;
  loopN++;

  const uint32_t sleepMs = pagerTxIntervalSec * 1000UL;

  // Fast path: arm button gets process + pager TX before any long sensor work.
  if(serviceArmIfNeeded()) {
    LOGI("Arm/disarm page sent (fast path)");
    waitWithWeb(sleepMs);
    return;
  }

  server.handleClient();
  gpsService(256);

  // Send a short known-good test page on first loop to confirm POCSAG TX works
  // before we depend on GPS/SHT40 data.
  if(loopN == 1) {
    const char* testMsg = "POCSAG TEST";
    LOGI("TX POCSAG (test): %s", testMsg);
    digitalWrite(PIN_LED1, HIGH);
    const int16_t st = pager.transmit(testMsg, CAPCODE, RADIOLIB_PAGER_ASCII);
    digitalWrite(PIN_LED1, LOW);
    LOGI("pager.transmit(test) returned %d", (int)st);
    delay(500);
  }

  // Keep parsing GPS in the background.
  gpsPumpForMs(800);

  if(serviceArmIfNeeded()) {
    LOGI("Arm/disarm page sent (fast path)");
    waitWithWeb(sleepMs);
    return;
  }

  // Drift alarm logic (based on GPS and user-set home point/threshold).
  bool driftOk = false;
  const double driftM = currentDriftMeters(&driftOk);
  const float driftFt = (float)(driftM * 3.28084);
  if(driftOk) {
    LOGI("Drift: %.1f ft (threshold %.1f ft)", driftFt, thresholdFeet);
    const bool over = driftFt > thresholdFeet;
    const bool cooldownOk = (lastAlarmMs == 0) || (millis() - lastAlarmMs > ALARM_COOLDOWN_MS);
    if(over && cooldownOk) {
      char alarmMsg[120];
      snprintf(alarmMsg, sizeof(alarmMsg), "WARNING! Drift is now %.0fft (limit %.0fft)", driftFt, thresholdFeet);
      LOGI("TX POCSAG (alarm) to %lu: %s", (unsigned long)ALARM_CAPCODE, alarmMsg);
      digitalWrite(PIN_LED1, HIGH);
      const int16_t st = pager.transmit(alarmMsg, ALARM_CAPCODE, RADIOLIB_PAGER_ASCII);
      digitalWrite(PIN_LED1, LOW);
      LOGI("pager.transmit(alarm) returned %d", (int)st);
      lastAlarmMs = millis();
      delay(500);
    }
  }

  if(serviceArmIfNeeded()) {
    LOGI("Arm/disarm page sent (fast path)");
    waitWithWeb(sleepMs);
    return;
  }

  // SHT40 read
  sensors_event_t humidityEvent, tempEvent;
  const bool shtOk = sht4.getEvent(&humidityEvent, &tempEvent);
  if(shtOk) {
    LOGI("SHT40: T=%.2f C  RH=%.1f %%", tempEvent.temperature, humidityEvent.relative_humidity);
  } else {
    LOGE("SHT40: getEvent failed");
  }

  if(serviceArmIfNeeded()) {
    LOGI("Arm/disarm page sent (fast path)");
    waitWithWeb(sleepMs);
    return;
  }

  // Build a short alphanumeric message (ASCII).
  char msg[160];
  msg[0] = '\0';

  if(gpsFixFresh()) {
    appendf(msg, sizeof(msg), "LAT=%.5f LON=%.5f", gps.location.lat(), gps.location.lng());
  } else {
    appendf(msg, sizeof(msg), "LAT=? LON=?");
  }

  if(gps.date.isValid() && gps.time.isValid()) {
    appendf(msg, sizeof(msg), " %04d-%02d-%02d %02d:%02d:%02dZ",
            gps.date.year(), gps.date.month(), gps.date.day(),
            gps.time.hour(), gps.time.minute(), gps.time.second());
  }

  if(driftOk) {
    appendf(msg, sizeof(msg), " D=%.0fft", driftFt);
  } else {
    appendf(msg, sizeof(msg), " D=n/a");
  }

  if(shtOk) {
    appendf(msg, sizeof(msg), " T=%.2fC RH=%.1f%%", tempEvent.temperature, humidityEvent.relative_humidity);
  } else {
    appendf(msg, sizeof(msg), " T=? RH=?");
  }

  if(serviceArmIfNeeded()) {
    LOGI("Arm/disarm page sent; skipping status TX this loop");
  } else {
    LOGI("TX POCSAG msgLen=%u: %s", (unsigned)strnlen(msg, sizeof(msg)), msg);

    digitalWrite(PIN_LED1, HIGH);
    const int16_t state = pager.transmit(msg, CAPCODE, RADIOLIB_PAGER_ASCII);
    digitalWrite(PIN_LED1, LOW);

    if(state != RADIOLIB_ERR_NONE) {
      LOGE("pager.transmit failed, code=%d", (int)state);
      // blink(PIN_LED2, 2);  // LED2: armed indicator only
    } else {
      LOGI("pager.transmit OK");
      blink(PIN_LED1, 1);
    }
  }

  LOGI("Sleeping %lu ms (pager TX interval %lu s)", (unsigned long)sleepMs, (unsigned long)pagerTxIntervalSec);
  waitWithWeb(sleepMs);
}