Логирование и телеметрия. Чёрный ящик для робота
После соревнований робот проиграл. Почему? Никто не знает — он же не рассказывает. Но если бы робот записывал всё, что делал, вы бы точно нашли причину. Это и есть логирование — “чёрный ящик” для робота.
Зачем нужно логирование?
1. Анализ после события
Ситуация: Робот съехал с трассы на 15-й секунде
Без логов: "Наверное, датчик глючит... или код... или мотор..."
С логами: "В 14.8с датчик 3 показал 1023 (максимум),
в 14.9с робот повернул вправо на 90°.
Вывод: датчик 3 сломался или засветился"
2. Оптимизация
Лог показывает:
• Средняя скорость: 0.5 м/с
• На поворотах: 0.2 м/с
• Потребление на прямой: 500 мА
• Потребление на повороте: 1200 мА
Вывод: Нужно оптимизировать повороты!
3. Доказательство для судей
Судья: "Ваш робот коснулся стены!"
Вы: "Вот лог: минимальное расстояние до стены — 3 см.
Касания не было, это тень от другого робота."
Уровни логирования
enum LogLevel {
LOG_DEBUG, // Всё подряд (для разработки)
LOG_INFO, // Важные события
LOG_WARNING, // Подозрительное поведение
LOG_ERROR, // Ошибки (но робот работает)
LOG_CRITICAL // Критические ошибки (робот останавливается)
};
LogLevel currentLevel = LOG_INFO; // Что записывать
void log(LogLevel level, String message) {
if (level >= currentLevel) {
String prefix;
switch(level) {
case LOG_DEBUG: prefix = "[DBG] "; break;
case LOG_INFO: prefix = "[INF] "; break;
case LOG_WARNING: prefix = "[WRN] "; break;
case LOG_ERROR: prefix = "[ERR] "; break;
case LOG_CRITICAL: prefix = "[!!!] "; break;
}
String fullMessage = String(millis()) + " " + prefix + message;
Serial.println(fullMessage);
writeToSD(fullMessage); // Записать на SD-карту
}
}
// Использование:
log(LOG_INFO, "Робот запущен");
log(LOG_WARNING, "Батарея ниже 20%");
log(LOG_ERROR, "Датчик 3 не отвечает");
Способы хранения логов
1. SD-карта — классика
Подключение:
Arduino Uno: SD-карта:
PIN 10 (CS) ───── CS
PIN 11 (MOSI) ──── MOSI (DI)
PIN 12 (MISO) ──── MISO (DO)
PIN 13 (SCK) ───── SCK (CLK)
5V ─────────────── VCC
GND ────────────── GND
Код:
#include <SD.h>
#include <SPI.h>
File logFile;
const int CS_PIN = 10;
void setupSD() {
if (!SD.begin(CS_PIN)) {
Serial.println("SD карта не найдена!");
return;
}
// Создаём новый файл с уникальным именем
String filename = "log_" + String(millis()) + ".txt";
logFile = SD.open(filename, FILE_WRITE);
if (logFile) {
logFile.println("=== Начало записи ===");
logFile.println("Время (мс), Событие, Значение");
}
}
void writeLog(String event, float value) {
if (logFile) {
logFile.print(millis());
logFile.print(",");
logFile.print(event);
logFile.print(",");
logFile.println(value);
logFile.flush(); // Важно! Иначе данные потеряются при выключении
}
}
void closeLog() {
if (logFile) {
logFile.println("=== Конец записи ===");
logFile.close();
}
}
2. EEPROM — для малых объёмов
Когда использовать: Нужно сохранить несколько важных значений между перезагрузками.
#include <EEPROM.h>
struct RobotStats {
uint32_t totalRuntime; // Общее время работы
uint16_t bootCount; // Количество запусков
uint16_t errorCount; // Количество ошибок
float maxSpeed; // Максимальная скорость
float minBattery; // Минимальное напряжение батареи
};
RobotStats stats;
void loadStats() {
EEPROM.get(0, stats);
// Проверка на первый запуск (EEPROM пустой)
if (stats.bootCount == 0xFFFF) {
stats = {0, 0, 0, 0.0, 99.0};
}
}
void saveStats() {
EEPROM.put(0, stats);
}
void setup() {
loadStats();
stats.bootCount++;
saveStats();
Serial.print("Запуск №");
Serial.println(stats.bootCount);
}
3. Передача по Wi-Fi (ESP32)
Для анализа в реальном времени:
#include <WiFi.h>
#include <WebSocketsServer.h>
WebSocketsServer webSocket = WebSocketsServer(81);
void broadcastLog(String message) {
webSocket.broadcastTXT(message);
}
void setup() {
WiFi.begin("ssid", "password");
webSocket.begin();
// Теперь можно подключиться браузером к ws://ip:81
// и видеть логи в реальном времени!
}
void loop() {
webSocket.loop();
// Пример отправки телеметрии
String json = "{\"temp\":" + String(readTemp()) +
",\"battery\":" + String(readBattery()) +
",\"speed\":" + String(getSpeed()) + "}";
broadcastLog(json);
delay(100);
}
4. MQTT — для облачного логирования
#include <PubSubClient.h>
WiFiClient espClient;
PubSubClient mqtt(espClient);
void setup() {
mqtt.setServer("broker.hivemq.com", 1883);
mqtt.connect("robot_001");
}
void logToCloud(String topic, String message) {
String fullTopic = "school/robots/robot001/" + topic;
mqtt.publish(fullTopic.c_str(), message.c_str());
}
// Использование:
logToCloud("sensors/temperature", "25.5");
logToCloud("errors", "Motor 2 overcurrent");
logToCloud("position", "{\"x\":10,\"y\":20}");
Что логировать?
Обязательно:
struct TelemetryPacket {
uint32_t timestamp; // Время события
// Датчики
float distance[4]; // Расстояния (УЗ/ИК)
int lineSensors[5]; // Датчики линии
float imuAccel[3]; // Акселерометр X,Y,Z
float imuGyro[3]; // Гироскоп X,Y,Z
// Состояние
float batteryVoltage; // Напряжение батареи
float motorCurrent[2]; // Ток моторов
int motorSpeed[2]; // Скорость моторов (ШИМ)
// Решения
int currentState; // Состояние FSM
float targetSpeed; // Целевая скорость
float targetAngle; // Целевой угол
};
По желанию (для глубокого анализа):
// PID-коэффициенты в реальном времени
float pidP, pidI, pidD, pidOutput;
// Время выполнения функций
unsigned long sensorReadTime;
unsigned long calculationTime;
unsigned long motorControlTime;
// Качество связи (для беспроводных роботов)
int rssi; // Уровень сигнала Wi-Fi
int packetLoss; // Потерянные пакеты
Формат данных
CSV — простой и универсальный
timestamp,distance1,distance2,battery,motor1,motor2,state
0,45,52,7.4,128,128,FORWARD
100,43,50,7.4,130,126,FORWARD
200,15,48,7.3,64,180,TURN_LEFT
300,42,12,7.3,180,64,TURN_RIGHT
Плюсы: Открывается в Excel, легко парсить.
JSON — для сложных данных
{
"t": 1500,
"sensors": {"d1": 45, "d2": 52, "line": [100, 500, 900, 500, 100]},
"motors": {"l": 128, "r": 130},
"battery": 7.4,
"state": "FOLLOWING_LINE",
"pid": {"p": 0.5, "i": 0.01, "d": 0.1, "out": 15}
}
Плюсы: Структурированно, легко расширять.
Бинарный — для экономии места
// Структура записывается напрямую в файл
struct LogEntry {
uint32_t timestamp;
uint16_t distance1;
uint16_t distance2;
uint16_t battery; // В милливольтах
int8_t motor1;
int8_t motor2;
uint8_t state;
} __attribute__((packed)); // 13 байт вместо ~50 в CSV
Плюсы: В 3-5 раз меньше места, быстрее записывать.
Анализ логов
Python-скрипт для визуализации:
import pandas as pd
import matplotlib.pyplot as plt
# Загрузка данных
data = pd.read_csv('robot_log.csv')
# График расстояния и скорости
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
ax1.plot(data['timestamp'], data['distance1'], label='Датчик 1')
ax1.plot(data['timestamp'], data['distance2'], label='Датчик 2')
ax1.set_ylabel('Расстояние (см)')
ax1.legend()
ax1.set_title('Показания датчиков')
ax2.plot(data['timestamp'], data['motor1'], label='Левый мотор')
ax2.plot(data['timestamp'], data['motor2'], label='Правый мотор')
ax2.set_ylabel('ШИМ')
ax2.set_xlabel('Время (мс)')
ax2.legend()
ax2.set_title('Скорость моторов')
plt.tight_layout()
plt.savefig('analysis.png')
plt.show()
Поиск аномалий:
# Найти моменты, когда батарея резко просела
battery_drops = data[data['battery'].diff() < -0.1]
print("Просадки напряжения:")
print(battery_drops[['timestamp', 'battery', 'motor1', 'motor2']])
# Найти моменты максимальной нагрузки
max_current = data['current'].max()
max_current_time = data[data['current'] == max_current]['timestamp'].values[0]
print(f"Максимальный ток {max_current}A в момент {max_current_time}мс")
Практический проект: Телеметрия для робота-сумоиста
#include <SD.h>
// Структура телеметрии
struct SumoTelemetry {
unsigned long time;
int enemyDistance; // Расстояние до противника
int edgeSensors[2]; // Датчики края (левый, правый)
int motorPower[2]; // Мощность моторов
char state[16]; // Текущее состояние
};
File logFile;
void logTelemetry(SumoTelemetry& t) {
// Формат: время,противник,край_л,край_п,мотор_л,мотор_п,состояние
logFile.print(t.time);
logFile.print(",");
logFile.print(t.enemyDistance);
logFile.print(",");
logFile.print(t.edgeSensors[0]);
logFile.print(",");
logFile.print(t.edgeSensors[1]);
logFile.print(",");
logFile.print(t.motorPower[0]);
logFile.print(",");
logFile.print(t.motorPower[1]);
logFile.print(",");
logFile.println(t.state);
}
void loop() {
SumoTelemetry t;
t.time = millis();
t.enemyDistance = readUltrasonic();
t.edgeSensors[0] = analogRead(A0);
t.edgeSensors[1] = analogRead(A1);
t.motorPower[0] = currentLeftPower;
t.motorPower[1] = currentRightPower;
strcpy(t.state, getCurrentStateName());
logTelemetry(t);
// Основная логика робота...
}
Советы по логированию
1. Не логируйте слишком часто
// Плохо: 1000 записей в секунду
void loop() {
logData(); // Каждый цикл
}
// Хорошо: 10-50 записей в секунду
unsigned long lastLog = 0;
void loop() {
if (millis() - lastLog > 20) { // Каждые 20 мс
logData();
lastLog = millis();
}
}
2. Используйте буфер
// Записывать на SD каждый раз — медленно!
// Лучше накопить и записать пачкой
char buffer[512];
int bufferPos = 0;
void addToBuffer(String data) {
int len = data.length();
if (bufferPos + len > 500) {
flushBuffer();
}
data.toCharArray(buffer + bufferPos, len + 1);
bufferPos += len;
}
void flushBuffer() {
if (bufferPos > 0) {
logFile.write(buffer, bufferPos);
bufferPos = 0;
}
}
3. Ротация логов
// Не дайте SD-карте переполниться!
void checkLogSize() {
if (logFile.size() > 1000000) { // 1 МБ
logFile.close();
// Удалить старый файл, создать новый
SD.remove("log_old.txt");
SD.rename("log.txt", "log_old.txt");
logFile = SD.open("log.txt", FILE_WRITE);
}
}
Проверь себя
✅ Смогу ли я:
- Настроить запись логов на SD-карту?
- Выбрать, что логировать для своего робота?
- Проанализировать CSV-файл в Excel или Python?
- Найти причину ошибки по логам?
Что дальше?
- Отладка — как искать ошибки в реальном времени
- Паттерны проектирования — как структурировать код для удобного логирования
- Мониторинг питания — что именно логировать про батарею
