Полная миссия (main_full)
«Полная миссия» — финальная прошивка кубсата. Она объединяет всё, что проверялось по отдельности на предыдущих страницах, и добавляет несколько служебных вещей, которых не было в учебных скетчах:
самотест при включении со звуковыми сигналами OK/FAIL для каждой подсистемы;
CSV-лог на microSD с 24 колонками — это «чёрный ящик» миссии;
бинарный пакет 32 байта
TboyAirPkt— компактный, чтобы пройти через ограничение nRF24L01+;WS2812 LED-лента показывает количество спутников и качество GPS-фикса цветом;
зуммер раз в минуту бипает «я жив».
Примечание
Перед запуском полной миссии стоит пройти все модульные тесты (от Первичная проверка платы до Телеметрия и радиосвязь (nRF24L01+)). Полная прошивка не сообщает причину ошибки в виде текста — только звуковыми сигналами. Если что-то «молчит», возвращайтесь к модульным тестам.
Прошивка кубсата
/**
* 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():
Считать акселерометр и гироскоп (MPU);
Считать BME280 → температура, давление, влажность;
Прокинуть температуру и влажность в CCS811 для коррекции (это улучшает eCO2/TVOC);
Считать CCS811 → eCO2, TVOC;
Записать строку в
TBOY.CSVна microSD;Собрать и отправить
TboyAirPktпо nRF.
Параллельно с этим:
LED-лента обновляется каждые 200 мс по последним данным GPS;
зуммер «пикает» раз в минуту (
DT_BEEP_MS).
Бинарный пакет TboyAirPkt (32 байта)
Пакет фиксированно 32 байта (требование nRF24L01+ для надёжной работы). Сначала идут «полезные» 22 байта, потом 10 байт резерва на будущие расширения.
Поле |
Размер |
Тип |
Содержимое |
|---|---|---|---|
|
1 Б |
|
версия протокола (сейчас |
|
1 Б |
|
есть GPS-fix ( |
|
1 Б |
|
спутников в решении |
|
1 Б |
|
HDOP × 10 (clip |
|
4 Б |
|
широта × 10⁷ |
|
4 Б |
|
долгота × 10⁷ |
|
4 Б |
|
UTC в формате |
|
2 Б |
|
температура × 10 (°C) |
|
2 Б |
|
давление × 10 (гПа) |
|
2 Б |
|
высота над уровнем моря (м) |
|
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
(в реальности — одна строка без переносов).
Колонки:
Колонка |
Единица |
Что значит |
|---|---|---|
|
мс |
время с момента включения Nano |
|
LSB |
сырые значения акселерометра |
|
LSB |
сырые значения гироскопа |
|
°C |
температура BME280 |
|
гПа |
давление BME280 |
|
% |
влажность BME280 |
|
ppm |
eCO₂ (расчётный) от CCS811 |
|
ppb |
летучие органические от CCS811 |
|
0/1 |
есть позиционирование |
|
шт |
спутников в решении |
|
— |
HDOP (горизонтальная погрешность) |
|
° |
широта, долгота (6 знаков) |
|
м |
высота |
|
— |
UTC время |
|
— |
дата (ddmmYYYY) |
|
шт |
сколько байт NMEA получили (растёт всегда) |
|
шт |
целостность NMEA |
|
шт |
предложений 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 (приёмник полной миссии)
/**
* 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:"© 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в начале файла) — не поднимает свою;декодирует именно
TboyAirPkt32 байта (с GPS-полями), а не упрощённый 25-байтный пакет;адрес страницы — IP, который выдаст ваш домашний роутер (виден в Serial Monitor 115200 после старта).
Перед заливкой не забудьте поправить SSID/пароль на свой.
Чек-лист запуска
SD-карта вставлена в модуль.
Антенна GPS на свободном небе или у окна.
Антенна nRF на ESP32 направлена в сторону кубсата (или просто близко).
Включаете питание кубсата → лента белая → последовательность писков → лента гаснет, далее работают индикаторы по GPS.
На приёмнике ESP32 ловите телеметрию через WiFi.
Если самотест выдал FAIL
На каком этапе двойной писк — там и проблема:
nRF FAIL— Телеметрия и радиосвязь (nRF24L01+) (диагностика TX, питание 3.3 В);GPS FAIL— Получение данных с GPS (GY-NEO-6M) (проводка, отключали ли TX при заливке, виден ли индикатор моргания);SD FAIL— Работа с памятью (microSD) (формат FAT32, контакт SPI).
Подробнее — в Возможные ошибки и диагностика.