Полная миссия (main_full)

«Полная миссия» — финальная прошивка кубсата. Она объединяет всё, что проверялось по отдельности на предыдущих страницах, и добавляет несколько служебных вещей, которых не было в учебных скетчах:

  • самотест при включении со звуковыми сигналами OK/FAIL для каждой подсистемы;

  • CSV-лог на microSD с 24 колонками — это «чёрный ящик» миссии;

  • бинарный пакет 32 байта TboyAirPkt — компактный, чтобы пройти через ограничение nRF24L01+;

  • WS2812 LED-лента показывает количество спутников и качество GPS-фикса цветом;

  • зуммер раз в минуту бипает «я жив».

Примечание

Перед запуском полной миссии стоит пройти все модульные тесты (от Первичная проверка платы до Телеметрия и радиосвязь (nRF24L01+)). Полная прошивка не сообщает причину ошибки в виде текста — только звуковыми сигналами. Если что-то «молчит», возвращайтесь к модульным тестам.

Прошивка кубсата

examples/main_full/main_full_arduino/main_full_arduino.ino
/**
 * tenderboy — полный цикл:
 *   MPU-9250/9255 (аксель/гиро) + BME280 + CCS811 + GPS NMEA
 *   → CSV на SD + эфир TboyAirPkt 32 B по nRF24 раз в 1 с; зуммер D3 — раз в минуту;
 *   лента WS2812: как main_gps_nrf_tx — min(sats,8) пикселей цветом по HDOP, остальное чёрное; sats=0 — всё чёрное;
 *   при старте: самотест (лента белая) — nRF / GPS / SD: 1 писк=OK, 2=нет; затем 3 писка «готово».
 *
 * SPI: MOSI=D11, MISO=D12, SCK=D13  (470Ω на MISO SD — обязательно при SD+nRF)
 * I²C: A4=SDA, A5=SCL
 * GPS NEO-6M: TX модуля → D0 (RX0), 9600 — один UART с Serial (без SoftwareSerial, меньше Flash).
 * Монитор: 9600; подробный лог в Serial — только сборка с TBOY_SERIAL_DIAG=1 (env full_diag).
 *
 * Сборка: pio run -e full -t upload
 */

#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <RF24.h>
#include <TinyGPSPlus.h>
#include <string.h>

#include <stddef.h>
#include <stdint.h>

#define TBOY_AIR_VER 2
struct __attribute__((packed)) TboyAirPkt {
  uint8_t  ver;
  uint8_t  fix;
  uint8_t  sats;
  uint8_t  hdop_x10;
  int32_t  lat_e7;
  int32_t  lon_e7;
  uint32_t utc_hhmmss;
  int16_t  temp_c10;
  uint16_t press_hpa10;
  int16_t  alt_m;
  uint8_t  res[10];
};
static_assert(sizeof(TboyAirPkt) == 32, "TboyAirPkt");

#ifndef TBOY_SERIAL_DIAG
#define TBOY_SERIAL_DIAG 0
#endif

/* ---- пины ---- */
static const uint8_t PIN_SD_CS   = 4;
static const uint8_t PIN_NRF_CE  = 9;
static const uint8_t PIN_NRF_CS  = 10;
static const uint8_t PIN_LED     = 6;   // WS2812, 8 пикселей
static const uint8_t PIN_BUZZ    = 3;   // активный, LOW = звук

static const uint8_t NUM_LEDS    = 8;
static const uint8_t BME_ADDR    = 0x76;
static const uint8_t CCS_ADDR    = 0x5A;

static const uint32_t DT_LED_MS   = 200;      // обновление ленты по GPS
static const uint32_t DT_LOG_MS   = 1000;     // 1 Гц: датчики + SD + nRF
static const uint32_t DT_BEEP_MS  = 60000UL;  // зуммер: раз в минуту

static const char LOG_FILE[] = "TBOY.CSV";
static const uint8_t NRF_PIPE[5] = {'T', 'B', 'O', 'Y', '1'};

/**
 * Пакет для SD (только лог CSV). По эфиру — TboyAirPkt 32 B.
 */
struct __attribute__((packed)) Packet {
  int16_t  ax, ay, az;
  int16_t  gx, gy, gz;
  int16_t  temp_c10;    // °C × 10
  uint16_t press_hpa10; // гПа × 10
  uint8_t  humid_x2;    // % × 2
  uint16_t co2_ppm;
  uint16_t tvoc_ppb;
};

static_assert(sizeof(Packet) == 21, "SD row binary snapshot");

static Packet pkt;
static TboyAirPkt air;
static RF24       radio(PIN_NRF_CE, PIN_NRF_CS);
static TinyGPSPlus gps;

static uint32_t tLed  = 0;
static uint32_t tLog  = 0;
static uint32_t tBeep = 0;

static uint8_t  mpuAddr = 0;
static uint8_t  mpuWhoAmI = 0;
static bool okMpu = false, okBme = false, okCcs = false;
static bool okSd  = false, okNrf = false;

static uint16_t lastCo2 = 400, lastTvoc = 0;

/* ================================================================== */
/* I²C вспомогательные                                                 */
/* ================================================================== */
static uint8_t i2cR8(uint8_t addr, uint8_t reg) {
  Wire.beginTransmission(addr);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(addr, (uint8_t)1);
  return Wire.available() ? Wire.read() : 0;
}
static void i2cRN(uint8_t addr, uint8_t reg, uint8_t *buf, uint8_t n) {
  Wire.beginTransmission(addr);
  Wire.write(reg);
  Wire.endTransmission(false);
  Wire.requestFrom(addr, n);
  for (uint8_t i = 0; i < n; i++) buf[i] = Wire.available() ? Wire.read() : 0;
}
static void i2cW(uint8_t addr, uint8_t reg, uint8_t val) {
  Wire.beginTransmission(addr);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
}

/* ================================================================== */
/* MPU                                                                 */
/* ================================================================== */
static bool mpuWrite(uint8_t reg, uint8_t val) {
  Wire.beginTransmission(mpuAddr);
  Wire.write(reg);
  Wire.write(val);
  return Wire.endTransmission() == 0;
}

static bool mpuInit() {
  const uint8_t addrs[] = {0x68, 0x69};
  uint8_t who = 0;
  mpuAddr = 0;
  for (uint8_t i = 0; i < 2; i++) {
    who = i2cR8(addrs[i], 0x75);
    if (who == 0x71 || who == 0x73 || who == 0x68 || who == 0x70) {
      mpuAddr = addrs[i];
      break;
    }
  }
  if (!mpuAddr) return false;

  mpuWhoAmI = who;
  mpuWrite(0x6B, 0x80);
  delay(100);
  mpuWrite(0x6B, 0x01);
  delay(50);
  mpuWrite(0x1C, 0x00);
  mpuWrite(0x1B, 0x00);
  mpuWrite(0x1A, 0x03);   // DLPF ~41 Гц (как в тесте MPU-9250)
  return true;
}

static void mpuRead() {
  if (!okMpu) return;
  Wire.beginTransmission(mpuAddr);
  Wire.write(0x3B);
  if (Wire.endTransmission(false) != 0) return;
  if (Wire.requestFrom(mpuAddr, (uint8_t)14) != 14) return;
  uint8_t b[14];
  for (uint8_t i = 0; i < 14; i++) b[i] = Wire.read();
  pkt.ax = (int16_t)((b[0]  << 8) | b[1]);
  pkt.ay = (int16_t)((b[2]  << 8) | b[3]);
  pkt.az = (int16_t)((b[4]  << 8) | b[5]);
  pkt.gx = (int16_t)((b[8]  << 8) | b[9]);
  pkt.gy = (int16_t)((b[10] << 8) | b[11]);
  pkt.gz = (int16_t)((b[12] << 8) | b[13]);
}

/* ================================================================== */
/* BME280                                                              */
/* ================================================================== */
static struct {
  uint16_t T1;
  int16_t T2, T3;
  uint16_t P1;
  int16_t P2, P3, P4, P5, P6, P7, P8, P9;
  uint8_t H1, H3;
  int16_t H2, H4, H5;
  int8_t H6;
} bCal;
static int32_t bTFine;

static bool bmeInit() {
  if (i2cR8(BME_ADDR, 0xD0) != 0x60) return false;
  i2cW(BME_ADDR, 0xE0, 0xB6);
  delay(15);
  uint8_t t[24];
  i2cRN(BME_ADDR, 0x88, t, 24);
  bCal.T1 = (uint16_t)(t[1] << 8 | t[0]);
  bCal.T2 = (int16_t)(t[3] << 8 | t[2]);
  bCal.T3 = (int16_t)(t[5] << 8 | t[4]);
  bCal.P1 = (uint16_t)(t[7] << 8 | t[6]);
  bCal.P2 = (int16_t)(t[9] << 8 | t[8]);
  bCal.P3 = (int16_t)(t[11] << 8 | t[10]);
  bCal.P4 = (int16_t)(t[13] << 8 | t[12]);
  bCal.P5 = (int16_t)(t[15] << 8 | t[14]);
  bCal.P6 = (int16_t)(t[17] << 8 | t[16]);
  bCal.P7 = (int16_t)(t[19] << 8 | t[18]);
  bCal.P8 = (int16_t)(t[21] << 8 | t[20]);
  bCal.P9 = (int16_t)(t[23] << 8 | t[22]);
  bCal.H1 = i2cR8(BME_ADDR, 0xA1);
  uint8_t h[7];
  i2cRN(BME_ADDR, 0xE1, h, 7);
  bCal.H2 = (int16_t)(h[1] << 8 | h[0]);
  bCal.H3 = h[2];
  bCal.H4 = (int16_t)((h[3] << 4) | (h[4] & 0x0F));
  bCal.H5 = (int16_t)((h[5] << 4) | (h[4] >> 4));
  bCal.H6 = (int8_t)h[6];
  i2cW(BME_ADDR, 0xF2, 0x01);
  i2cW(BME_ADDR, 0xF4, 0x27);
  i2cW(BME_ADDR, 0xF5, 0xA0);
  delay(100);
  return true;
}

static void bmeReadToPacket() {
  if (!okBme) {
    pkt.temp_c10 = 0;
    pkt.press_hpa10 = 0;
    pkt.humid_x2 = 0;
    return;
  }
  uint8_t b[8];
  i2cRN(BME_ADDR, 0xF7, b, 8);
  int32_t rP = ((int32_t)b[0] << 12) | ((int32_t)b[1] << 4) | (b[2] >> 4);
  int32_t rT = ((int32_t)b[3] << 12) | ((int32_t)b[4] << 4) | (b[5] >> 4);
  int32_t rH = ((int32_t)b[6] << 8) | b[7];

  int32_t v1 = (((rT >> 3) - ((int32_t)bCal.T1 << 1)) * (int32_t)bCal.T2) >> 11;
  int32_t v2 = ((((rT >> 4) - (int32_t)bCal.T1) * ((rT >> 4) - (int32_t)bCal.T1)) >> 12);
  v2 = (v2 * (int32_t)bCal.T3) >> 14;
  bTFine = v1 + v2;
  float T = ((bTFine * 5 + 128) >> 8) / 100.0f;

  float P = 0;
  int64_t w1 = (int64_t)bTFine - 128000;
  int64_t w2 = w1 * w1 * (int64_t)bCal.P6 + ((w1 * (int64_t)bCal.P5) << 17) + (((int64_t)bCal.P4) << 35);
  w1 = ((w1 * w1 * (int64_t)bCal.P3) >> 8) + ((w1 * (int64_t)bCal.P2) << 12);
  w1 = ((((int64_t)1 << 47) + w1) * (int64_t)bCal.P1) >> 33;
  if (w1) {
    int64_t p = 1048576 - rP;
    p = (((p << 31) - w2) * 3125) / w1;
    w1 = ((int64_t)bCal.P9 * (p >> 13) * (p >> 13)) >> 25;
    w2 = ((int64_t)bCal.P8 * p) >> 19;
    p = ((p + w1 + w2) >> 8) + ((int64_t)bCal.P7 << 4);
    P = (float)((uint32_t)p) / 25600.0f;
  }

  int32_t x = bTFine - 76800;
  x = ((((rH << 14) - ((int32_t)bCal.H4 << 20) - ((int32_t)bCal.H5 * x)) + 16384) >> 15) *
      (((((((x * (int32_t)bCal.H6) >> 10) * (((x * (int32_t)bCal.H3) >> 11) + 32768)) >> 10) + 2097152) *
        (int32_t)bCal.H2 + 8192) >>
       14);
  x -= (((((x >> 15) * (x >> 15)) >> 7) * (int32_t)bCal.H1) >> 4);
  if (x < 0) x = 0;
  if (x > 419430400) x = 419430400;
  float H = (float)((uint32_t)(x >> 12)) / 1024.0f;

  int16_t tc = (int16_t)(T * 10.0f + (T >= 0 ? 0.5f : -0.5f));
  if (tc < -300) tc = -300;
  if (tc > 1200) tc = 1200;
  pkt.temp_c10 = tc;
  uint32_t ph = (uint32_t)(P * 10.0f + 0.5f);
  if (ph > 65535) ph = 65535;
  pkt.press_hpa10 = (uint16_t)ph;
  uint8_t hx = (uint8_t)(H * 2.0f + 0.5f);
  if (hx > 200) hx = 200;
  pkt.humid_x2 = hx;
}

/* ================================================================== */
/* CCS811                                                              */
/* ================================================================== */
static bool ccsInit() {
  if (i2cR8(CCS_ADDR, 0x20) != 0x81) return false;
  Wire.beginTransmission(CCS_ADDR);
  Wire.write(0xF4);
  Wire.endTransmission();
  delay(100);
  if (!(i2cR8(CCS_ADDR, 0x00) & 0x80)) return false;
  i2cW(CCS_ADDR, 0x01, 0x10);
  return true;
}

static void ccsSetEnv(float temp, float humid) {
  uint16_t h = (uint16_t)(humid * 512.0f + 0.5f);
  uint16_t t = (uint16_t)((temp + 25.0f) * 512.0f + 0.5f);
  Wire.beginTransmission(CCS_ADDR);
  Wire.write(0x05);
  Wire.write(h >> 8);
  Wire.write(h & 0xFF);
  Wire.write(t >> 8);
  Wire.write(t & 0xFF);
  Wire.endTransmission();
}

static void ccsReadToPacket() {
  if (!okCcs) {
    pkt.co2_ppm = lastCo2;
    pkt.tvoc_ppb = lastTvoc;
    return;
  }
  if (!(i2cR8(CCS_ADDR, 0x00) & 0x08)) {
    pkt.co2_ppm = lastCo2;
    pkt.tvoc_ppb = lastTvoc;
    return;
  }
  Wire.beginTransmission(CCS_ADDR);
  Wire.write(0x02);
  Wire.endTransmission(false);
  Wire.requestFrom(CCS_ADDR, (uint8_t)4);
  uint8_t b[4] = {};
  for (uint8_t i = 0; i < 4; i++)
    if (Wire.available()) b[i] = Wire.read();
  lastCo2 = (uint16_t)((b[0] << 8) | b[1]);
  lastTvoc = (uint16_t)((b[2] << 8) | b[3]);
  pkt.co2_ppm = lastCo2;
  pkt.tvoc_ppb = lastTvoc;
}

/* ================================================================== */
/* WS2812 (D6 = PD6)                                                    */
/* ================================================================== */
static inline void ws2812Byte(uint8_t b) {
  for (uint8_t mask = 0x80; mask; mask >>= 1) {
    if (b & mask) {
      PORTD |= _BV(PIN_LED);
      __asm__ volatile("nop\nnop\nnop\nnop\nnop\nnop\n");
      PORTD &= ~_BV(PIN_LED);
      __asm__ volatile("nop\n");
    } else {
      PORTD |= _BV(PIN_LED);
      __asm__ volatile("nop\n");
      PORTD &= ~_BV(PIN_LED);
      __asm__ volatile("nop\nnop\nnop\nnop\nnop\n");
    }
  }
}

static inline void ws2812Pixel(uint8_t r, uint8_t g, uint8_t b) {
  ws2812Byte(g);
  ws2812Byte(r);
  ws2812Byte(b);
}

/** HDOP×10: меньше — лучше. 0 — нет данных (тускло-синий). */
/** Все пиксели одним цветом (WS2812, D6 = PD6). */
static void ws2812FillSolid(uint8_t r, uint8_t g, uint8_t b) {
  noInterrupts();
  for (uint16_t i = 0; i < NUM_LEDS; i++)
    ws2812Pixel(r, g, b);
  interrupts();
  delayMicroseconds(60);
}

static void hdopToRgb(uint8_t hdop_x10, uint8_t &r, uint8_t &g, uint8_t &b) {
  if (hdop_x10 == 0) {
    r = 0;
    g = 0;
    b = 48;
    return;
  }
  if (hdop_x10 >= 50) {
    r = 220;
    g = 0;
    b = 0;
    return;
  }
  if (hdop_x10 >= 38) {
    r = 255;
    g = 40;
    b = 0;
    return;
  }
  if (hdop_x10 >= 30) {
    r = 255;
    g = 120;
    b = 0;
    return;
  }
  if (hdop_x10 >= 25) {
    r = 255;
    g = 200;
    b = 0;
    return;
  }
  if (hdop_x10 >= 20) {
    r = 120;
    g = 255;
    b = 0;
    return;
  }
  r = 0;
  g = 220;
  b = 30;
}

static void refreshLedStripFromGps() {
  uint8_t sats = gps.satellites.isValid() ? (uint8_t)gps.satellites.value() : 0;
  uint8_t hx = 0;
  if (gps.hdop.isValid()) {
    double h = gps.hdop.hdop();
    if (h > 25.5)
      h = 25.5;
    hx = (uint8_t)(h * 10.0 + 0.5);
  }
  uint8_t r, g, b;
  hdopToRgb(hx, r, g, b);

  uint16_t n = sats;
  if (n > NUM_LEDS)
    n = NUM_LEDS;
  noInterrupts();
  for (uint16_t i = 0; i < NUM_LEDS; i++) {
    if (i < n)
      ws2812Pixel(r, g, b);
    else
      ws2812Pixel(0, 0, 0);
  }
  interrupts();
  delayMicroseconds(60);
}

/* ================================================================== */
/* GPS: во время длинного setup UART буфер 64 B — без encode() теряется NMEA. */
/* ================================================================== */
static inline void pollGps() {
  while (Serial.available())
    gps.encode(Serial.read());
}

static void delayPollGps(uint32_t ms) {
  uint32_t t0 = millis();
  while ((uint32_t)(millis() - t0) < ms) {
    pollGps();
    delay(3);
  }
}

/* ================================================================== */
/* Зуммер: активный, «инверсия» к тишине — LOW = звук, HIGH = выкл.   */
/* Во время писка крутим pollGps — иначе теряется поток с модуля.     */
/* ================================================================== */
static void beep(uint16_t ms = 80) {
  uint32_t t0 = millis();
  digitalWrite(PIN_BUZZ, LOW);
  while ((uint32_t)(millis() - t0) < (uint32_t)ms) {
    pollGps();
    delay(2);
  }
  digitalWrite(PIN_BUZZ, HIGH);
  t0 = millis();
  while ((uint32_t)(millis() - t0) < 20u) {
    pollGps();
    delay(2);
  }
}

static void selfTestBuzzOk() {
  beep(85);
}

static void selfTestBuzzFail() {
  beep(60);
  delayPollGps(55);
  beep(60);
}

static void selfTestBuzzTripleDone() {
  for (uint8_t i = 0; i < 3; i++) {
    beep(52);
    delayPollGps(88);
  }
}

/** Есть валидная NMEA-строка с CRC за timeout (поток с GPS на Serial). */
static bool selfTestGpsNmea(uint32_t timeout_ms) {
  uint32_t t0 = millis();
  while ((uint32_t)(millis() - t0) < timeout_ms) {
    pollGps();
    if (gps.passedChecksum() >= 1)
      return true;
    delay(3);
  }
  return false;
}

/** Пауза между этапами самотеста (секунда) + опрос GPS. */
static const uint32_t ST_GAP_MS = 1000;
/** Холодный старт GPS после setup — дольше, чем в коротком gps_nrf_tx. */
static const uint32_t ST_GPS_NMEA_MS = 8000;

/**
 * Самотест при каждом включении: лента на полную белую на всё время.
 * Порядок: nRF → GPS → SD: один писк = OK, два = нет.
 * Затем три писка — самотест окончен.
 */
static void runPowerOnSelfTest() {
  delayPollGps(800);

  ws2812FillSolid(255, 255, 255);

  if (okNrf)
    selfTestBuzzOk();
  else
    selfTestBuzzFail();
  delayPollGps(ST_GAP_MS);

  bool gpsOk = selfTestGpsNmea(ST_GPS_NMEA_MS);
  if (gpsOk)
    selfTestBuzzOk();
  else
    selfTestBuzzFail();
  delayPollGps(ST_GAP_MS);

  if (okSd)
    selfTestBuzzOk();
  else
    selfTestBuzzFail();
  delayPollGps(ST_GAP_MS);

  selfTestBuzzTripleDone();

  ws2812FillSolid(0, 0, 0);
}

/* ================================================================== */
/* SD                                                                   */
/* ================================================================== */
static void sdRelease() {
  digitalWrite(PIN_SD_CS, HIGH);
  SPI.beginTransaction(SPISettings(250000, MSBFIRST, SPI_MODE0));
  SPI.transfer(0xFF);
  SPI.endTransaction();
}

static void sdWriteHeader() {
  File f = SD.open(LOG_FILE, FILE_WRITE);
  if (!f) return;
  f.println(F("ms,ax,ay,az,gx,gy,gz,T_C,P_hPa,H_%,CO2,TVOC,"
              "gps_fix,gps_sats,gps_hdop,lat,lon,gps_alt_m,utc_hhmmss,gps_date_ddmmyy,"
              "gps_chars,gps_crc_ok,gps_crc_bad,gps_sents_w_fix"));
  f.close();
  sdRelease();
}

static void sdLogRow() {
  if (!okSd) return;
  digitalWrite(PIN_NRF_CS, HIGH);

  File f = SD.open(LOG_FILE, FILE_WRITE);
  if (!f) {
#if TBOY_SERIAL_DIAG
    // Serial.println(F("SD: open fail"));
#endif
    sdRelease();
    return;
  }

  char buf[12];
  char lbuf[16];
  f.print(millis());
  f.print(',');
  f.print(pkt.ax);
  f.print(',');
  f.print(pkt.ay);
  f.print(',');
  f.print(pkt.az);
  f.print(',');
  f.print(pkt.gx);
  f.print(',');
  f.print(pkt.gy);
  f.print(',');
  f.print(pkt.gz);
  f.print(',');
  dtostrf(pkt.temp_c10 / 10.0f, 1, 1, buf);
  f.print(buf);
  f.print(',');
  dtostrf(pkt.press_hpa10 / 10.0f, 1, 2, buf);
  f.print(buf);
  f.print(',');
  dtostrf(pkt.humid_x2 / 2.0f, 1, 1, buf);
  f.print(buf);
  f.print(',');
  f.print(pkt.co2_ppm);
  f.print(',');
  f.print(pkt.tvoc_ppb);
  f.print(',');
  f.print(gps.location.isValid() ? 1 : 0);
  f.print(',');
  f.print(gps.satellites.isValid() ? (int)gps.satellites.value() : 0);
  f.print(',');
  if (gps.hdop.isValid()) {
    dtostrf(gps.hdop.hdop(), 1, 1, buf);
    f.print(buf);
  } else {
    f.print('0');
  }
  f.print(',');
  if (gps.location.isValid()) {
    dtostrf(gps.location.lat(), 2, 6, lbuf);
    f.print(lbuf);
  } else {
    f.print('0');
  }
  f.print(',');
  if (gps.location.isValid()) {
    dtostrf(gps.location.lng(), 2, 6, lbuf);
    f.print(lbuf);
  } else {
    f.print('0');
  }
  f.print(',');
  if (gps.altitude.isValid()) {
    dtostrf(gps.altitude.meters(), 1, 1, lbuf);
    f.print(lbuf);
  } else {
    f.print('0');
  }
  f.print(',');
  if (gps.time.isValid()) {
    f.print((unsigned long)(gps.time.hour() * 10000UL + gps.time.minute() * 100UL + gps.time.second()));
  } else {
    f.print('0');
  }
  f.print(',');
  if (gps.date.isValid()) {
    f.print((unsigned long)(gps.date.day() * 1000000UL + gps.date.month() * 10000UL + gps.date.year()));
  } else {
    f.print('0');
  }
  f.print(',');
  f.print((unsigned long)gps.charsProcessed());
  f.print(',');
  f.print((unsigned long)gps.passedChecksum());
  f.print(',');
  f.print((unsigned long)gps.failedChecksum());
  f.print(',');
  f.print((unsigned long)gps.sentencesWithFix());
  f.println();

  f.close();
  sdRelease();
}

/* ================================================================== */
/* Эфир TboyAirPkt (32 B)                                               */
/* ================================================================== */
static void fillTboyAirPkt() {
  memset(&air, 0, sizeof(air));
  air.ver = TBOY_AIR_VER;
  air.temp_c10 = pkt.temp_c10;
  air.press_hpa10 = pkt.press_hpa10;

  if (gps.location.isValid()) {
    air.fix = 1;
    double la = gps.location.lat();
    double lo = gps.location.lng();
    air.lat_e7 = (int32_t)(la * 1e7 + (la >= 0 ? 0.5 : -0.5));
    air.lon_e7 = (int32_t)(lo * 1e7 + (lo >= 0 ? 0.5 : -0.5));
  }

  if (gps.satellites.isValid())
    air.sats = (uint8_t)gps.satellites.value();

  if (gps.hdop.isValid()) {
    double h = gps.hdop.hdop();
    if (h > 25.5)
      h = 25.5;
    air.hdop_x10 = (uint8_t)(h * 10.0 + 0.5);
  }

  if (gps.time.isValid())
    air.utc_hhmmss = (uint32_t)gps.time.hour() * 10000UL + (uint32_t)gps.time.minute() * 100UL +
                     (uint32_t)gps.time.second();

  if (air.fix && gps.altitude.isValid()) {
    double m = gps.altitude.meters();
    int32_t im = (int32_t)(m + (m >= 0 ? 0.5 : -0.5));
    if (im > 32767)
      im = 32767;
    if (im < -32768)
      im = -32768;
    air.alt_m = (int16_t)im;
  } else {
    air.alt_m = 0;
  }
}

/* ================================================================== */
/* nRF24                                                                */
/* ================================================================== */
static void nrfSend() {
  if (!okNrf) return;
  digitalWrite(PIN_SD_CS, HIGH);
  radio.write(&air, sizeof(air));
}

/* ================================================================== */
static void printRow() {
#if TBOY_SERIAL_DIAG
  // char buf[10];
  // Serial.print(F("ax="));
  // Serial.print(pkt.ax);
  // Serial.print(F(" ay="));
  // Serial.print(pkt.ay);
  // Serial.print(F(" az="));
  // Serial.print(pkt.az);
  // Serial.print(F(" | T="));
  // Serial.print(dtostrf(pkt.temp_c10 / 10.0f, 1, 1, buf));
  // Serial.print(F(" P="));
  // Serial.print(dtostrf(pkt.press_hpa10 / 10.0f, 1, 1, buf));
  // Serial.print(F(" CO2="));
  // Serial.print(pkt.co2_ppm);
  // Serial.print(F(" | SD="));
  // Serial.print(okSd ? F("OK") : F("--"));
  // Serial.print(F(" nRF="));
  // Serial.print(okNrf ? F("OK") : F("--"));
  // Serial.print(F(" | GPS fix="));
  // Serial.print(gps.location.isValid() ? 1 : 0);
  // Serial.print(F(" sats="));
  // Serial.print(gps.satellites.isValid() ? (int)gps.satellites.value() : 0);
  // if (gps.location.isValid()) {
  //   char la[16], lo[16];
  //   dtostrf(gps.location.lat(), 2, 5, la);
  //   dtostrf(gps.location.lng(), 2, 5, lo);
  //   Serial.print(F(" lat="));
  //   Serial.print(la);
  //   Serial.print(F(" lon="));
  //   Serial.println(lo);
  // } else {
  //   Serial.println();
  // }
#endif
}

static void sampleAllAndLog() {
  mpuRead();
  bmeReadToPacket();
  if (okBme && okCcs)
    ccsSetEnv(pkt.temp_c10 / 10.0f, pkt.humid_x2 / 2.0f);
  ccsReadToPacket();

  sdLogRow();
  fillTboyAirPkt();
  nrfSend();
  printRow();
}

void setup() {
  Serial.begin(9600);
#if TBOY_SERIAL_DIAG
  uint32_t t0 = millis();
  while (!Serial && millis() - t0 < 2000) {}
  // Serial.println(F("\n=== tenderboy full: MPU+BME+CCS+GPS → SD + TboyAirPkt 32B nRF; бип 1/мин; LED sats/HDOP ==="));
#endif

  pinMode(PIN_LED, OUTPUT);
  TCCR2A = 0;
  TCCR2B = 0;
  pinMode(PIN_BUZZ, OUTPUT);
  digitalWrite(PIN_BUZZ, HIGH);
  delayPollGps(50);

  pinMode(PIN_SD_CS, OUTPUT);
  digitalWrite(PIN_SD_CS, HIGH);
  pinMode(PIN_NRF_CS, OUTPUT);
  digitalWrite(PIN_NRF_CS, HIGH);
  SPI.begin();

  Wire.begin();
  Wire.setClock(400000);
  delayPollGps(50);

  okMpu = mpuInit();
#if TBOY_SERIAL_DIAG
  // Serial.print(F("MPU:  "));
  // if (okMpu) {
  //   Serial.print(F("OK WHO=0x"));
  //   Serial.println(mpuWhoAmI, HEX);
  // } else {
  //   Serial.println(F("FAIL"));
  // }
#endif

  okBme = bmeInit();
#if TBOY_SERIAL_DIAG
  // Serial.print(F("BME:  "));
  // Serial.println(okBme ? F("OK") : F("FAIL"));
#endif

  okCcs = ccsInit();
#if TBOY_SERIAL_DIAG
  // Serial.print(F("CCS:  "));
  // Serial.println(okCcs ? F("OK") : F("FAIL"));
#endif

  okSd = SD.begin(PIN_SD_CS);
#if TBOY_SERIAL_DIAG
  // Serial.print(F("SD:   "));
  // Serial.println(okSd ? F("OK") : F("FAIL"));
#endif
  if (okSd) {
    sdRelease();
    if (!SD.exists(LOG_FILE))
      sdWriteHeader();
#if TBOY_SERIAL_DIAG
    // Serial.print(F("Лог:  "));
    // Serial.println(LOG_FILE);
#endif
  }

  okNrf = radio.begin();
#if TBOY_SERIAL_DIAG
  // Serial.print(F("nRF:  "));
  // Serial.println(okNrf ? F("OK") : F("FAIL"));
#endif
  if (okNrf) {
    radio.setPALevel(RF24_PA_LOW);
    radio.setDataRate(RF24_250KBPS);
    radio.setChannel(76);
    radio.setAutoAck(false);
    radio.setPayloadSize(sizeof(TboyAirPkt));
    radio.openWritingPipe(NRF_PIPE);
    radio.stopListening();
  }

  pkt = Packet();
  lastCo2 = 400;
  lastTvoc = 0;

  runPowerOnSelfTest();
  tBeep = millis();
  tLog = millis() - DT_LOG_MS;
  tLed = millis() - DT_LED_MS;
#if TBOY_SERIAL_DIAG
  // Serial.println(F("1 с: датчики → SD → nRF TboyAirPkt; зуммер раз в мин; лента — спутники/HDOP.\n"));
#endif
}

void loop() {
  uint32_t now = millis();

  while (Serial.available())
    gps.encode(Serial.read());

  if (now - tLed >= DT_LED_MS) {
    tLed = now;
    refreshLedStripFromGps();
  }

  if (now - tLog >= DT_LOG_MS) {
    tLog = now;
    sampleAllAndLog();
  }

  if (now - tBeep >= DT_BEEP_MS) {
    tBeep = now;
    beep(80);
  }
}

Скетч объёмный (~860 строк) — но логически делится на четыре куска:

  • инициализация всех модулей (с пропуском не отозвавшихся);

  • runPowerOnSelfTest() — звуковой самотест;

  • основной loop() с тремя независимыми таймерами:

    • DT_LED_MS = 200 мс — обновление LED-ленты;

    • DT_LOG_MS = 1000 мс — чтение всех сенсоров → запись в CSV → отправка пакета по nRF;

    • DT_BEEP_MS = 60000 мс — бип «всё нормально».

Самотест при включении

Сразу после setup() лента WS2812 загорается сплошным белым на всё время самотеста. Зуммер по очереди сообщает результат инициализации:

Подсистема

1 короткий писк (OK)

2 коротких писка (FAIL)

nRF24L01+

модуль ответил

не отвечает / не питание 3.3 В

GPS

есть валидные NMEA-предложения

провод TX не подключён или GPS молчит

SD-карта

инициализирована

не вставлена / не FAT32 / не отвечает

После всех трёх — три коротких писка подряд = «самотест завершён», лента гаснет, начинается основной цикл.

Примечание

GPS на самотесте проверяется только на наличие потока NMEA — наличие fix (реального позиционирования) тут не требуется, иначе на полу в помещении кубсат «никогда не запустится».

Цикл миссии: 1 раз в секунду

Каждую секунду выполняется sampleAllAndLog():

  1. Считать акселерометр и гироскоп (MPU);

  2. Считать BME280 → температура, давление, влажность;

  3. Прокинуть температуру и влажность в CCS811 для коррекции (это улучшает eCO2/TVOC);

  4. Считать CCS811 → eCO2, TVOC;

  5. Записать строку в TBOY.CSV на microSD;

  6. Собрать и отправить TboyAirPkt по nRF.

Параллельно с этим:

  • LED-лента обновляется каждые 200 мс по последним данным GPS;

  • зуммер «пикает» раз в минуту (DT_BEEP_MS).

Бинарный пакет TboyAirPkt (32 байта)

Пакет фиксированно 32 байта (требование nRF24L01+ для надёжной работы). Сначала идут «полезные» 22 байта, потом 10 байт резерва на будущие расширения.

Поле

Размер

Тип

Содержимое

ver

1 Б

uint8

версия протокола (сейчас 2)

fix

1 Б

uint8

есть GPS-fix (0/1)

sats

1 Б

uint8

спутников в решении

hdop_x10

1 Б

uint8

HDOP × 10 (clip 25.5)

lat_e7

4 Б

int32

широта × 10⁷

lon_e7

4 Б

int32

долгота × 10⁷

utc_hhmmss

4 Б

uint32

UTC в формате HHMMSS

temp_c10

2 Б

int16

температура × 10 (°C)

press_hpa10

2 Б

uint16

давление × 10 (гПа)

alt_m

2 Б

int16

высота над уровнем моря (м)

res[10]

10 Б

reserved

нулями, для будущих полей

Чтобы получить градусы из lat_e7, lon_e7 — поделить на 10⁷. Чтобы получить °C из temp_c10 — поделить на 10. И т.д.

CSV-лог TBOY.CSV

Если SD-карта инициализирована, лог пишется в файл TBOY.CSV в корне карты. Если файла нет, скетч сначала пишет шапку:

ms,ax,ay,az,gx,gy,gz,T_C,P_hPa,H_%,CO2,TVOC,
gps_fix,gps_sats,gps_hdop,lat,lon,gps_alt_m,
utc_hhmmss,gps_date_ddmmyy,gps_chars,gps_crc_ok,
gps_crc_bad,gps_sents_w_fix

(в реальности — одна строка без переносов).

Колонки:

Колонка

Единица

Что значит

ms

мс

время с момента включения Nano

ax,ay,az

LSB

сырые значения акселерометра

gx,gy,gz

LSB

сырые значения гироскопа

T_C

°C

температура BME280

P_hPa

гПа

давление BME280

H_%

%

влажность BME280

CO2

ppm

eCO₂ (расчётный) от CCS811

TVOC

ppb

летучие органические от CCS811

gps_fix

0/1

есть позиционирование

gps_sats

шт

спутников в решении

gps_hdop

HDOP (горизонтальная погрешность)

lat, lon

°

широта, долгота (6 знаков)

gps_alt_m

м

высота

utc_hhmmss

UTC время

gps_date_ddmmyy

дата (ddmmYYYY)

gps_chars

шт

сколько байт NMEA получили (растёт всегда)

gps_crc_ok/gps_crc_bad

шт

целостность NMEA

gps_sents_w_fix

шт

предложений NMEA, в которых был fix

Лог можно открыть в Excel/Google Sheets/Python через стандартный импорт CSV.

LED-лента: индикация GPS

Лента из 8 пикселей WS2812 (D6) показывает работу GPS:

  • количество горящих пикселей = min(спутников, 8);

  • цвет всех горящих пикселей зависит от качества фикса по HDOP:

HDOP

Цвет

Качество

нет данных

тускло-синий

GPS не отдал HDOP

≥ 5.0

красный

очень плохо (помехи, мало спутников)

≥ 3.8

оранжево-красный

плохо

≥ 3.0

оранжевый

средне

≥ 2.5

жёлтый

приемлемо

≥ 2.0

жёлто-зелёный

хорошо

< 2.0

зелёный

отлично

«Если все 8 зелёных» = есть восемь+ спутников и точное решение. «Один синий или красный» = GPS только начал ловить.

Зуммер: «я жив»

Раз в минуту короткий писк (~80 мс). Если кубсат вдруг затих — проверьте питание и работу main_full (мог зависнуть).

Прошивка ESP32 (приёмник полной миссии)

examples/main_full/main_esp32_nrf_rx_arduino/main_esp32_nrf_rx_arduino.ino
/**
 * ESP32 + nRF24L01 приёмник + веб: кадр TboyAirPkt 32 B (ver=2) с CubeSat main_full.
 *
 * Пины nRF (как в проекте): CE=GPIO2, CSN=GPIO4, SPI VSPI 18/19/23.
 *
 * Сборка: pio run -e esp32_nrf_rx
 * Заливка: pio run -e esp32_nrf_rx -t upload
 * После подключения к WiFi в Serial будет IP — открой в браузере http://IP/
 */

#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <SPI.h>
#include <RF24.h>

#include <stddef.h>
#include <stdint.h>

#define TBOY_AIR_VER 2
struct __attribute__((packed)) TboyAirPkt {
  uint8_t  ver;
  uint8_t  fix;
  uint8_t  sats;
  uint8_t  hdop_x10;
  int32_t  lat_e7;
  int32_t  lon_e7;
  uint32_t utc_hhmmss;
  int16_t  temp_c10;
  uint16_t press_hpa10;
  int16_t  alt_m;
  uint8_t  res[10];
};
static_assert(sizeof(TboyAirPkt) == 32, "TboyAirPkt");

// ---------- WiFi (замени при необходимости) ----------
static const char *WIFI_SSID = "KazybekAP";
static const char *WIFI_PASS = "kazybeek";

// ---------- nRF24 ----------
static const uint8_t PIN_NRF_CE  = 2;
static const uint8_t PIN_NRF_CS = 4;
/** Канал как на Uno main_full: setChannel(76) */
static const uint8_t NRF_CHANNEL = 76;
static const uint8_t NRF_PIPE[5] = {'T', 'B', 'O', 'Y', '1'};
static const uint32_t RX_STALE_MS = 2500;

static RF24 radio(PIN_NRF_CE, PIN_NRF_CS);
static WebServer server(80);

static TboyAirPkt lastPkt;
static uint32_t rxCount;
static uint32_t lastRxMs;
static bool everRx;

static void handleRoot();
static void handleData();

void setup() {
  Serial.begin(115200);
  delay(300);
  Serial.println(F("\n=== ESP32 nRF RX + Web (TboyAirPkt 32 B) ==="));

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print(F("WiFi "));
  uint8_t n = 0;
  while (WiFi.status() != WL_CONNECTED && n < 60) {
    delay(500);
    Serial.print('.');
    n++;
  }
  Serial.println();
  if (WiFi.status() == WL_CONNECTED) {
    Serial.print(F("OK IP: "));
    Serial.println(WiFi.localIP());
  } else {
    Serial.println(F("WiFi FAIL — проверь SSID/пароль"));
  }

  SPI.begin();
  if (!radio.begin()) {
    Serial.println(F("nRF24: begin FAIL — проводка SPI/питание"));
  } else {
    radio.setPALevel(RF24_PA_LOW);
    radio.setDataRate(RF24_250KBPS);
    radio.setChannel(NRF_CHANNEL);
    radio.setAutoAck(false);
    radio.setPayloadSize(sizeof(TboyAirPkt));
    radio.openReadingPipe(1, NRF_PIPE);
    radio.startListening();
    Serial.printf("nRF24: ch %u, pipe TBOY1, payload %u B (TboyAirPkt ver=%u)\n",
                  (unsigned)NRF_CHANNEL, (unsigned)sizeof(TboyAirPkt), (unsigned)TBOY_AIR_VER);
  }

  server.on("/", handleRoot);
  server.on("/data", handleData);
  server.begin();
  Serial.println(F("HTTP :80"));
}

void loop() {
  server.handleClient();

  if (radio.available()) {
    TboyAirPkt p;
    radio.read(&p, sizeof(p));
    lastPkt = p;
    everRx = true;
    rxCount++;
    lastRxMs = millis();
    Serial.printf("[RX] #%lu ver=%u fix=%u sats=%u lat_e7=%ld lon_e7=%ld T*10=%d\n",
                  (unsigned long)rxCount, (unsigned)p.ver, (unsigned)p.fix, (unsigned)p.sats,
                  (long)p.lat_e7, (long)p.lon_e7, (int)p.temp_c10);
  }
}

static void handleRoot() {
  static const char PAGE[] = R"HTML(<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>tenderboy RX</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
*{box-sizing:border-box}
body{font-family:system-ui,sans-serif;margin:0;padding:16px;background:#f4f4f5;color:#111}
h1{font-size:1.15rem;margin:0 0 8px;font-weight:600}
.sub{font-size:.8rem;color:#555;margin-bottom:12px}
#st{font-size:.9rem;margin-bottom:12px;padding:10px 12px;border-radius:8px;border:1px solid #ccc;background:#fff}
#st.ok{border-color:#2a7;border-left:4px solid #2a7}
#st.bad{border-color:#c33;border-left:4px solid #c33}
.page{max-width:1100px;margin:0 auto}
.grid{display:grid;gap:14px}
@media(min-width:820px){.grid{grid-template-columns:1fr 1fr}}
#map{height:300px;border-radius:8px;border:1px solid #ccc}
.mapHint{font-size:11px;color:#666;margin-top:6px}
table{border-collapse:collapse;width:100%;background:#fff;border:1px solid #ddd;font-size:13px}
td,th{border:1px solid #e0e0e0;padding:8px;text-align:left}
th{background:#eee}
.num{font-variant-numeric:tabular-nums;text-align:right}
.termRow{margin-top:16px}
.termRow h2{font-size:.95rem;margin:0 0 8px}
#term{display:block;width:100%;min-height:200px;height:260px;max-height:40vh;overflow:auto;padding:12px;background:#fff;border:1px solid #ccc;border-radius:8px;font:12px/1.45 ui-monospace,Consolas,monospace;white-space:pre-wrap;word-break:break-all}
</style>
</head>
<body>
<h1>tenderboy — приём nRF24 (CubeSat)</h1>
<p class="sub">Кадр <strong>32 байта</strong>, <code>ver=2</code> (<code>TboyAirPkt</code>), канал <strong>76</strong>, пайп <code>TBOY1</code>, 250 кбит/с, без ACK — как <code>main_full</code> на Uno.</p>
<div class="page">
<div id="st">загрузка…</div>
<div class="grid">
<div>
<table>
<tr><th>Поле</th><th>Значение</th></tr>
<tr><td>Эфир «живой»</td><td class="num" id="live">—</td></tr>
<tr><td>Возраст пакета, с</td><td class="num" id="age">—</td></tr>
<tr><td>Пакетов всего</td><td class="num" id="cnt">0</td></tr>
<tr><td>Версия кадра</td><td class="num" id="ver">—</td></tr>
<tr><td>GPS fix</td><td class="num" id="fix">—</td></tr>
<tr><td>Координаты</td><td class="num" id="ll">—</td></tr>
<tr><td>T °C (BME по эфиру)</td><td class="num" id="t">—</td></tr>
<tr><td>P гПа</td><td class="num" id="p">—</td></tr>
<tr><td>Влажность %</td><td class="num" id="h">—</td></tr>
<tr><td>Высота MSL м</td><td class="num" id="alt">—</td></tr>
<tr><td>Спутники</td><td class="num" id="sats">—</td></tr>
<tr><td>HDOP</td><td class="num" id="hdop">—</td></tr>
<tr><td>UTC (ЧЧММСС)</td><td class="num" id="utc">—</td></tr>
<tr><td>Канал nRF</td><td class="num" id="ch">—</td></tr>
</table>
</div>
<div>
<div id="map"></div>
<p class="mapHint" id="mapHint">Карта: при валидном фиксе и координатах — маркер. Иначе подсказка здесь.</p>
</div>
</div>
<div class="termRow">
<h2>Лог /data</h2>
<pre id="term"></pre>
</div>
</div>
<script>
const term=document.getElementById("term");
const TMAX=280;
function tlog(line){
  term.textContent+=line+"\n";
  const L=term.textContent.split("\n");
  if(L.length>TMAX)term.textContent=L.slice(-TMAX).join("\n");
  term.scrollTop=term.scrollHeight;
}
let map,mk;
function bootMap(lat,lon){
  if(map)return;
  map=L.map("map").setView([lat,lon],14);
  L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{maxZoom:19,attribution:"&copy; OSM"}).addTo(map);
  mk=L.marker([lat,lon]).addTo(map);
}
function fmtUtc(u){
  if(!u||u<100)return "—";
  const h=Math.floor(u/10000),m=Math.floor((u%10000)/100),s=u%100;
  return String(h).padStart(2,"0")+":"+String(m).padStart(2,"0")+":"+String(s).padStart(2,"0");
}
async function tick(){
  try{
    const r=await fetch("/data");
    const j=await r.json();
    const st=document.getElementById("st");
    const live=j.rx_live===true;
    const wifi=j.wifi||"";
    st.textContent=(wifi?("WiFi: "+wifi+" | "):"")+"Эфир: "+(live?("да (свежий пакет < "+j.stale_ms+" мс)"):"нет (нет нового кадра или TX выкл.)");
    st.className=live?"ok":"bad";
    document.getElementById("live").textContent=live?"да":"нет";
    document.getElementById("age").textContent=(j.age_s>=0)?j.age_s:"никогда";
    document.getElementById("cnt").textContent=j.cnt|0;
    document.getElementById("ver").textContent=j.ver;
    document.getElementById("fix").textContent=j.fix?"да":"нет";
    document.getElementById("t").textContent=j.temp_c;
    document.getElementById("p").textContent=j.press_hpa;
    document.getElementById("h").textContent=(j.humid===null||j.humid===undefined)?"— (нет в эфире)":j.humid;
    document.getElementById("alt").textContent=j.alt_m;
    document.getElementById("sats").textContent=j.sats;
    document.getElementById("hdop").textContent=j.hdop;
    document.getElementById("utc").textContent=fmtUtc(j.utc_t|0);
    document.getElementById("ch").textContent="ch="+j.ch+" — "+j.ch_note;
    const lat=j.lat,lng=j.lng;
    const hasFix=j.fix&&typeof lat==="number"&&typeof lng==="number"&&!isNaN(lat)&&!isNaN(lng)&&Math.abs(lat)<=90&&Math.abs(lng)<=180;
    document.getElementById("ll").textContent=hasFix?(lat.toFixed(6)+", "+lng.toFixed(6)):"нет фикса";
    const mh=document.getElementById("mapHint");
    if(hasFix){
      mh.textContent="OSM: маркер по последнему кадру.";
      if(!map)bootMap(lat,lng);
      else{if(!mk)mk=L.marker([lat,lng]).addTo(map);else{mk.setLatLng([lat,lng]);map.panTo([lat,lng]);}}
    }else{
      mh.textContent="Нет фикса или координат — маркер не ставится. Дождитесь GPS на передатчике.";
      if(map&&mk){map.removeLayer(mk);mk=null;}
    }
    tlog(new Date().toISOString()+" "+JSON.stringify(j));
  }catch(e){
    document.getElementById("st").textContent="Ошибка запроса /data";
    document.getElementById("st").className="bad";
    tlog(new Date().toISOString()+" ERROR "+e);
  }
}
setInterval(tick,500);
tick();
</script>
</body>
</html>
)HTML";

  server.send(200, "text/html; charset=utf-8", PAGE);
}

static void handleData() {
  char buf[1024];
  char ipstr[20] = "";
  if (WiFi.status() == WL_CONNECTED) {
    WiFi.localIP().toString().toCharArray(ipstr, sizeof(ipstr));
  }

  const uint32_t now = millis();
  const bool rxLive = everRx && ((now - lastRxMs) <= RX_STALE_MS);
  const float ageS = everRx ? (now - lastRxMs) / 1000.0f : -1.0f;

  const TboyAirPkt &q = lastPkt;
  const double lat = (double)q.lat_e7 / 1e7;
  const double lng = (double)q.lon_e7 / 1e7;
  const float temp_c = q.temp_c10 / 10.0f;
  const float press_hpa = q.press_hpa10 / 10.0f;
  const float hdop = q.hdop_x10 / 10.0f;

  snprintf(buf, sizeof(buf),
           "{"
           "\"rx_live\":%s,"
           "\"age_s\":%.2f,"
           "\"stale_ms\":%u,"
           "\"cnt\":%lu,"
           "\"ch\":%u,"
           "\"ch_note\":\"RX=76, TX Uno main_full TboyAirPkt 32B\","
           "\"ver\":%u,"
           "\"fix\":%u,"
           "\"lat\":%.7f,"
           "\"lng\":%.7f,"
           "\"alt_m\":%d,"
           "\"sats\":%u,"
           "\"hdop\":%.1f,"
           "\"utc_t\":%lu,"
           "\"temp_c\":%.1f,"
           "\"press_hpa\":%.1f,"
           "\"humid\":null,"
           "\"wifi\":\"%s\""
           "}",
           rxLive ? "true" : "false",
           (double)ageS,
           (unsigned)RX_STALE_MS,
           (unsigned long)rxCount,
           (unsigned)NRF_CHANNEL,
           (unsigned)q.ver,
           (unsigned)q.fix,
           lat,
           lng,
           (int)q.alt_m,
           (unsigned)q.sats,
           (double)hdop,
           (unsigned long)q.utc_hhmmss,
           (double)temp_c,
           (double)press_hpa,
           ipstr);

  server.send(200, "application/json; charset=utf-8", buf);
}

Отличие от ESP32 в Базовая станция (упрощённая):

  • здесь ESP32 подключается к существующей WiFi-сети (WIFI_SSID/WIFI_PASS в начале файла) — не поднимает свою;

  • декодирует именно TboyAirPkt 32 байта (с GPS-полями), а не упрощённый 25-байтный пакет;

  • адрес страницы — IP, который выдаст ваш домашний роутер (виден в Serial Monitor 115200 после старта).

Перед заливкой не забудьте поправить SSID/пароль на свой.

Чек-лист запуска

  1. SD-карта вставлена в модуль.

  2. Антенна GPS на свободном небе или у окна.

  3. Антенна nRF на ESP32 направлена в сторону кубсата (или просто близко).

  4. Включаете питание кубсата → лента белая → последовательность писков → лента гаснет, далее работают индикаторы по GPS.

  5. На приёмнике ESP32 ловите телеметрию через WiFi.

Если самотест выдал FAIL

Подробнее — в Возможные ошибки и диагностика.