Базовая станция (упрощённая)

«Базовая станция» — это пара скетчей, которая показывает работу радиоканала на максимально простом сценарии:

  • Кубсат (Nano) читает BME280 + CCS811 + MPU9250 и раз в секунду отправляет 25-байтный пакет по nRF24L01+;

  • Наземная станция (ESP32) ловит пакеты и поднимает свою WiFi-точку, к которой можно подключиться телефоном и смотреть телеметрию в браузере.

Это «промежуточный шаг» между тестом радиоканала из Телеметрия и радиосвязь (nRF24L01+) и полной миссией из Полная миссия (main_full). Здесь:

  • нет GPS, SD-карты, LED-ленты и зуммера — ничто не отвлекает от радиоканала и сенсоров;

  • ESP32 поднимает свою WiFi-сеть (tenderboy-base) — не нужен существующий роутер или интернет, годится для класса/полевого выезда;

  • все данные доступны в виде JSON по простому API, удобно для скриптов на ноутбуке.

Когда использовать этот сценарий

  • Демонстрация на занятии/мероприятии, где нет известной WiFi-сети.

  • Отладка — проще, чем main_full, потому что меньше движущихся частей.

  • Введение в идею «запросить данные у спутника по сети», прежде чем подключать GPS-логику.

Параметры радио те же, что в Телеметрия и радиосвязь (nRF24L01+) — канал 76, pipe TBOY1, 250 kbps, AutoAck выкл. Размер пакета здесь — 25 байт (sizeof(Telemetry)), не путать с тестовыми 8 байтами и main_full-ными 32 байтами.

Передатчик: Arduino Nano (на кубсате)

examples/cubesat_base_station/cubesat_nano_tx/cubesat_nano_tx.ino
#include <Wire.h>
#include <SPI.h>
#include <RF24.h>
#include <Adafruit_BME280.h>
#include <Adafruit_CCS811.h>

// nRF24 (как в проекте)
const uint8_t NRF_CE = 9;
const uint8_t NRF_CSN = 10;
const byte NRF_PIPE[6] = "TBOY1";

// I2C адреса
const uint8_t MPU_ADDR = 0x68;

RF24 radio(NRF_CE, NRF_CSN);
Adafruit_BME280 bme;
Adafruit_CCS811 ccs;

struct __attribute__((packed)) Telemetry {
  uint32_t ms;
  int16_t temp_c10;
  uint16_t press_hpa10;
  uint8_t hum_x2;
  uint16_t eco2_ppm;
  uint16_t tvoc_ppb;
  int16_t ax, ay, az;
  int16_t gx, gy, gz;
  uint8_t flags; // bit0=BME ok, bit1=CCS ok, bit2=MPU ok
};

Telemetry pkt;

static uint8_t readReg8(uint8_t dev, uint8_t reg) {
  Wire.beginTransmission(dev);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return 0;
  Wire.requestFrom(dev, (uint8_t)1);
  if (!Wire.available()) return 0;
  return Wire.read();
}

static bool readRegs(uint8_t dev, uint8_t reg, uint8_t* dst, uint8_t len) {
  Wire.beginTransmission(dev);
  Wire.write(reg);
  if (Wire.endTransmission(false) != 0) return false;
  if (Wire.requestFrom(dev, len) != len) return false;
  for (uint8_t i = 0; i < len; i++) dst[i] = Wire.read();
  return true;
}

static void writeReg8(uint8_t dev, uint8_t reg, uint8_t val) {
  Wire.beginTransmission(dev);
  Wire.write(reg);
  Wire.write(val);
  Wire.endTransmission();
}

static bool initMpu() {
  uint8_t who = readReg8(MPU_ADDR, 0x75);
  if (who != 0x71 && who != 0x73) return false;
  writeReg8(MPU_ADDR, 0x6B, 0x00); // wake
  delay(30);
  writeReg8(MPU_ADDR, 0x1B, 0x00); // gyro +-250 dps
  writeReg8(MPU_ADDR, 0x1C, 0x00); // accel +-2g
  return true;
}

static bool readMpuRaw(int16_t &ax, int16_t &ay, int16_t &az, int16_t &gx, int16_t &gy, int16_t &gz) {
  uint8_t b[14];
  if (!readRegs(MPU_ADDR, 0x3B, b, 14)) return false;
  ax = (int16_t)((b[0] << 8) | b[1]);
  ay = (int16_t)((b[2] << 8) | b[3]);
  az = (int16_t)((b[4] << 8) | b[5]);
  gx = (int16_t)((b[8] << 8) | b[9]);
  gy = (int16_t)((b[10] << 8) | b[11]);
  gz = (int16_t)((b[12] << 8) | b[13]);
  return true;
}

bool okBme = false;
bool okCcs = false;
bool okMpu = false;

void setup() {
  Wire.begin();
  delay(20);

  okBme = bme.begin(0x76) || bme.begin(0x77);
  okCcs = ccs.begin(0x5A);
  okMpu = initMpu();

  radio.begin();
  radio.setPALevel(RF24_PA_LOW);
  radio.setDataRate(RF24_250KBPS);
  radio.setChannel(76);
  radio.setAutoAck(false);
  radio.setPayloadSize(sizeof(Telemetry));
  radio.openWritingPipe(NRF_PIPE);
  radio.stopListening();
}

void loop() {
  memset(&pkt, 0, sizeof(pkt));
  pkt.ms = millis();

  if (okBme) {
    pkt.flags |= 0x01;
    pkt.temp_c10 = (int16_t)(bme.readTemperature() * 10.0f);
    float p = bme.readPressure() / 100.0f;
    if (p < 0) p = 0;
    if (p > 6553.5f) p = 6553.5f;
    pkt.press_hpa10 = (uint16_t)(p * 10.0f);
    float h = bme.readHumidity();
    if (h < 0) h = 0;
    if (h > 100) h = 100;
    pkt.hum_x2 = (uint8_t)(h * 2.0f);
  }

  if (okCcs && ccs.available() && !ccs.readData()) {
    pkt.flags |= 0x02;
    pkt.eco2_ppm = ccs.geteCO2();
    pkt.tvoc_ppb = ccs.getTVOC();
  }

  int16_t ax, ay, az, gx, gy, gz;
  if (okMpu && readMpuRaw(ax, ay, az, gx, gy, gz)) {
    pkt.flags |= 0x04;
    pkt.ax = ax; pkt.ay = ay; pkt.az = az;
    pkt.gx = gx; pkt.gy = gy; pkt.gz = gz;
  }

  radio.write(&pkt, sizeof(pkt));
  delay(1000);
}

Что делает:

  • инициализирует BME280, CCS811, MPU9250 (с выставленным флагом flags в пакете — какие модули на самом деле ответили);

  • читает значения раз в секунду, отправляет 25-байтный Telemetry-пакет;

  • в Serial Monitor ничего не пишет — пара написана так, что в отладке по проводу нет необходимости. Если модуль не отвечает, это видно по соответствующему биту flags на стороне ESP32.

Поля пакета:

Поле

Размер

Содержимое

ms

4 Б

millis() Nano-передатчика

temp_c10

2 Б

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

press_hpa10

2 Б

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

hum_x2

1 Б

влажность × 2 (%)

eco2_ppm

2 Б

расчётный CO₂ (ppm)

tvoc_ppb

2 Б

летучие органические (ppb)

ax,ay,az

6 Б

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

gx,gy,gz

6 Б

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

flags

1 Б

bit0=BME, bit1=CCS, bit2=MPU

Приёмник: ESP32 + WiFi AP + Web

examples/cubesat_base_station/base_station_esp32_web/base_station_esp32_web.ino
#include <WiFi.h>
#include <WebServer.h>
#include <SPI.h>
#include <RF24.h>

// nRF24 (как в main_esp32_nrf_rx): CE=2, CSN=4, VSPI 18/19/23
const uint8_t NRF_CE = 2;
const uint8_t NRF_CSN = 4;
const byte NRF_PIPE[6] = "TBOY1";

RF24 radio(NRF_CE, NRF_CSN);
WebServer server(80);

struct __attribute__((packed)) Telemetry {
  uint32_t ms;
  int16_t temp_c10;
  uint16_t press_hpa10;
  uint8_t hum_x2;
  uint16_t eco2_ppm;
  uint16_t tvoc_ppb;
  int16_t ax, ay, az;
  int16_t gx, gy, gz;
  uint8_t flags; // bit0=BME, bit1=CCS, bit2=MPU
};

Telemetry lastPkt = {};
bool hasPacket = false;
uint32_t rxCount = 0;
uint32_t lastRxMillis = 0;

static void handleRoot() {
  String html =
    "<!doctype html><html><head><meta charset='utf-8'/>"
    "<meta name='viewport' content='width=device-width,initial-scale=1'/>"
    "<title>tenderboy base station</title>"
    "<style>body{font-family:Arial,sans-serif;margin:20px;background:#f6f8fa;color:#1f2328}"
    ".card{background:#fff;border:1px solid #d0d7de;border-radius:10px;padding:14px;max-width:700px}"
    "h1{margin:0 0 10px}.muted{color:#59636e}pre{background:#f6f8fa;padding:10px;border-radius:8px;border:1px solid #d0d7de}"
    "</style></head><body><div class='card'>"
    "<h1>Base station ESP32</h1>"
    "<div class='muted'>nRF24 ch=76, pipe=TBOY1</div>"
    "<pre id='out'>waiting...</pre>"
    "</div><script>"
    "async function tick(){"
    "try{const r=await fetch('/api'); const j=await r.json();"
    "document.getElementById('out').textContent=JSON.stringify(j,null,2);}catch(e){"
    "document.getElementById('out').textContent='api error';}"
    "setTimeout(tick,1000);}"
    "tick();"
    "</script></body></html>";
  server.send(200, "text/html; charset=utf-8", html);
}

static void handleApi() {
  String json = "{";
  json += "\"rx_count\":" + String(rxCount) + ",";
  json += "\"last_rx_ms_ago\":" + String(hasPacket ? (millis() - lastRxMillis) : 0) + ",";
  json += "\"has_packet\":" + String(hasPacket ? "true" : "false") + ",";

  json += "\"bme_ok\":" + String((lastPkt.flags & 0x01) ? "true" : "false") + ",";
  json += "\"ccs_ok\":" + String((lastPkt.flags & 0x02) ? "true" : "false") + ",";
  json += "\"mpu_ok\":" + String((lastPkt.flags & 0x04) ? "true" : "false") + ",";

  json += "\"temp_c\":" + String(lastPkt.temp_c10 / 10.0f, 1) + ",";
  json += "\"press_hpa\":" + String(lastPkt.press_hpa10 / 10.0f, 1) + ",";
  json += "\"hum_pct\":" + String(lastPkt.hum_x2 / 2.0f, 1) + ",";
  json += "\"eco2_ppm\":" + String(lastPkt.eco2_ppm) + ",";
  json += "\"tvoc_ppb\":" + String(lastPkt.tvoc_ppb) + ",";

  json += "\"ax\":" + String(lastPkt.ax) + ",";
  json += "\"ay\":" + String(lastPkt.ay) + ",";
  json += "\"az\":" + String(lastPkt.az) + ",";
  json += "\"gx\":" + String(lastPkt.gx) + ",";
  json += "\"gy\":" + String(lastPkt.gy) + ",";
  json += "\"gz\":" + String(lastPkt.gz);
  json += "}";

  server.send(200, "application/json", json);
}

void setup() {
  Serial.begin(115200);
  delay(300);

  WiFi.mode(WIFI_AP);
  WiFi.softAP("tenderboy-base", "12345678");
  Serial.print("AP IP: ");
  Serial.println(WiFi.softAPIP());

  if (!radio.begin()) {
    Serial.println("nRF24 begin FAILED");
  } else {
    radio.setPALevel(RF24_PA_LOW);
    radio.setDataRate(RF24_250KBPS);
    radio.setChannel(76);
    radio.setAutoAck(false);
    radio.setPayloadSize(sizeof(Telemetry));
    radio.openReadingPipe(1, NRF_PIPE);
    radio.startListening();
    Serial.println("nRF24 RX ready");
  }

  server.on("/", handleRoot);
  server.on("/api", handleApi);
  server.begin();
  Serial.println("Web server ready");
}

void loop() {
  server.handleClient();

  while (radio.available()) {
    radio.read(&lastPkt, sizeof(lastPkt));
    hasPacket = true;
    rxCount++;
    lastRxMillis = millis();
  }
}

Что делает:

  • поднимает WiFi-точку доступа tenderboy-base с паролем 12345678;

  • стартует HTTP-сервер на порту 80;

  • ловит пакеты с Nano и хранит последний;

  • отдаёт GET /api — JSON с последней телеметрией;

  • GET / — простая HTML-страница, которая раз в секунду опрашивает /api и обновляет вывод.

Распиновка ESP32 — другая, чем у Nano: CE=GPIO2, CSN=GPIO4, VSPI 18/19/23. Это связано с разводкой ESP32 DevKit и конкретно для этого сценария — в кубсате-передатчике пины Nano-овые.

Как запустить весь сценарий

  1. Залейте cubesat_nano_tx.ino на Nano кубсата. В Serial Monitor (115200) — тишина (так и задумано), но Nano уже передаёт пакеты.

  2. Залейте base_station_esp32_web.ino на отдельную плату ESP32 (Wemos / NodeMCU-ESP32 / DevKit C). В Serial Monitor (115200) ESP32 напишет:

    AP IP: 192.168.4.1
    nRF24 RX ready
    Web server ready
    
  3. На телефоне/ноутбуке выберите WiFi-сеть tenderboy-base, пароль 12345678.

  4. В браузере откройте http://192.168.4.1/.

  5. Через секунду в окне waiting... появится JSON примерно такой:

    {
      "rx_count": 12,
      "last_rx_ms_ago": 480,
      "has_packet": true,
      "bme_ok": true,
      "ccs_ok": true,
      "mpu_ok": true,
      "temp_c": 23.4,
      "press_hpa": 1013.2,
      "hum_pct": 38.5,
      "eco2_ppm": 412,
      "tvoc_ppb": 8,
      "ax": -110, "ay": 80, "az": 16380,
      "gx": 12, "gy": -5, "gz": 3
    }
    

Что должны видеть студенты

  • rx_count растёт — пакеты приходят.

  • last_rx_ms_ago колеблется около 1000 ± 200 — стандартный ритм передачи.

  • bme_ok/ccs_ok/mpu_oktrue для всего, что реально ответило при инициализации.

  • При покачивании кубсата ax/ay/az заметно прыгают.

Если has_packet=false и rx_count=0

  • Nano не отправляет (см. Телеметрия и радиосвязь (nRF24L01+) — диагностика TX);

  • сторона ESP32 не приняла:

    • неправильно подключён nRF к ESP32 (CE/CSN перепутаны);

    • Wi-Fi-точка ESP32 поднялась, но nRF не инициализировался — в Serial Monitor должно быть nRF24 begin FAILED.

Если JSON приходит, но все поля по нулям

  • На стороне Nano не сработали инициализации сенсоров — флаги в пакете нулевые. Сначала прогоните Проверка шины I²C и модульные тесты сенсоров.