Базовая станция (упрощённая)
«Базовая станция» — это пара скетчей, которая показывает работу радиоканала на максимально простом сценарии:
Кубсат (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 (на кубсате)
#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.
Поля пакета:
Поле |
Размер |
Содержимое |
|---|---|---|
|
4 Б |
|
|
2 Б |
температура × 10 (°C) |
|
2 Б |
давление × 10 (гПа) |
|
1 Б |
влажность × 2 (%) |
|
2 Б |
расчётный CO₂ (ppm) |
|
2 Б |
летучие органические (ppb) |
|
6 Б |
сырые значения акселерометра |
|
6 Б |
сырые значения гироскопа |
|
1 Б |
bit0=BME, bit1=CCS, bit2=MPU |
Приёмник: ESP32 + WiFi AP + Web
#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-овые.
Как запустить весь сценарий
Залейте
cubesat_nano_tx.inoна Nano кубсата. В Serial Monitor (115200) — тишина (так и задумано), но Nano уже передаёт пакеты.Залейте
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
На телефоне/ноутбуке выберите WiFi-сеть
tenderboy-base, пароль12345678.В браузере откройте http://192.168.4.1/.
Через секунду в окне
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_ok—trueдля всего, что реально ответило при инициализации.При покачивании кубсата
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 и модульные тесты сенсоров.