Skip to main content

Логирование и телеметрия. Чёрный ящик для робота

После соревнований робот проиграл. Почему? Никто не знает — он же не рассказывает. Но если бы робот записывал всё, что делал, вы бы точно нашли причину. Это и есть логирование — “чёрный ящик” для робота.


Зачем нужно логирование?

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);
  }
}

Проверь себя

Смогу ли я:

  1. Настроить запись логов на SD-карту?
  2. Выбрать, что логировать для своего робота?
  3. Проанализировать CSV-файл в Excel или Python?
  4. Найти причину ошибки по логам?

Что дальше?