001// Copyright (c) FIRST and other WPILib contributors.
002// Open Source Software; you can modify and/or share it under the terms of
003// the WPILib BSD license file in the root directory of this project.
004
005package edu.wpi.first.wpilibj;
006
007import edu.wpi.first.hal.FRCNetComm.tInstances;
008import edu.wpi.first.hal.FRCNetComm.tResourceType;
009import edu.wpi.first.hal.HAL;
010import edu.wpi.first.networktables.NetworkTableInstance;
011import edu.wpi.first.util.FileLogger;
012import edu.wpi.first.util.WPIUtilJNI;
013import edu.wpi.first.util.concurrent.Event;
014import edu.wpi.first.util.datalog.DataLog;
015import edu.wpi.first.util.datalog.DataLogBackgroundWriter;
016import edu.wpi.first.util.datalog.IntegerLogEntry;
017import edu.wpi.first.util.datalog.StringLogEntry;
018import java.io.File;
019import java.io.IOException;
020import java.nio.file.Files;
021import java.nio.file.Path;
022import java.nio.file.Paths;
023import java.time.LocalDateTime;
024import java.time.ZoneId;
025import java.time.format.DateTimeFormatter;
026import java.util.Arrays;
027import java.util.Comparator;
028import java.util.Random;
029
030/**
031 * Centralized data log that provides automatic data log file management. It automatically cleans up
032 * old files when disk space is low and renames the file based either on current date/time or (if
033 * available) competition match number. The data file will be saved to a USB flash drive in a folder
034 * named "logs" if one is attached, or to /home/lvuser/logs otherwise.
035 *
036 * <p>Log files are initially named "FRC_TBD_{random}.wpilog" until the DS connects. After the DS
037 * connects, the log file is renamed to "FRC_yyyyMMdd_HHmmss.wpilog" (where the date/time is UTC).
038 * If the FMS is connected and provides a match number, the log file is renamed to
039 * "FRC_yyyyMMdd_HHmmss_{event}_{match}.wpilog".
040 *
041 * <p>On startup, all existing FRC_TBD log files are deleted. If there is less than 50 MB of free
042 * space on the target storage, FRC_ log files are deleted (oldest to newest) until there is 50 MB
043 * free OR there are 10 files remaining.
044 *
045 * <p>By default, all NetworkTables value changes are stored to the data log.
046 */
047public final class DataLogManager {
048  private static DataLogBackgroundWriter m_log;
049  private static boolean m_stopped;
050  private static String m_logDir;
051  private static boolean m_filenameOverride;
052  private static Thread m_thread;
053  private static final ZoneId m_utc = ZoneId.of("UTC");
054  private static final DateTimeFormatter m_timeFormatter =
055      DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").withZone(m_utc);
056  private static boolean m_ntLoggerEnabled = true;
057  private static int m_ntEntryLogger;
058  private static int m_ntConnLogger;
059  private static boolean m_consoleLoggerEnabled = true;
060  private static FileLogger m_consoleLogger;
061  private static StringLogEntry m_messageLog;
062
063  // if less than this much free space, delete log files until there is this much free space
064  // OR there are this many files remaining.
065  private static final long kFreeSpaceThreshold = 50000000L;
066  private static final int kFileCountThreshold = 10;
067
068  private DataLogManager() {}
069
070  /** Start data log manager with default directory location. */
071  public static synchronized void start() {
072    start("", "", 0.25);
073  }
074
075  /**
076   * Start data log manager. The parameters have no effect if the data log manager was already
077   * started (e.g. by calling another static function).
078   *
079   * @param dir if not empty, directory to use for data log storage
080   */
081  public static synchronized void start(String dir) {
082    start(dir, "", 0.25);
083  }
084
085  /**
086   * Start data log manager. The parameters have no effect if the data log manager was already
087   * started (e.g. by calling another static function).
088   *
089   * @param dir if not empty, directory to use for data log storage
090   * @param filename filename to use; if none provided, the filename is automatically generated
091   */
092  public static synchronized void start(String dir, String filename) {
093    start(dir, filename, 0.25);
094  }
095
096  /**
097   * Start data log manager. The parameters have no effect if the data log manager was already
098   * started (e.g. by calling another static function).
099   *
100   * @param dir if not empty, directory to use for data log storage
101   * @param filename filename to use; if none provided, the filename is automatically generated
102   * @param period time between automatic flushes to disk, in seconds; this is a time/storage
103   *     tradeoff
104   */
105  public static synchronized void start(String dir, String filename, double period) {
106    if (m_log == null) {
107      m_logDir = makeLogDir(dir);
108      m_filenameOverride = !filename.isEmpty();
109
110      // Delete all previously existing FRC_TBD_*.wpilog files. These only exist when the robot
111      // never connects to the DS, so they are very unlikely to have useful data and just clutter
112      // the filesystem.
113      File[] files =
114          new File(m_logDir)
115              .listFiles((d, name) -> name.startsWith("FRC_TBD_") && name.endsWith(".wpilog"));
116      if (files != null) {
117        for (File file : files) {
118          if (!file.delete()) {
119            System.err.println("DataLogManager: could not delete " + file);
120          }
121        }
122      }
123      m_log = new DataLogBackgroundWriter(m_logDir, makeLogFilename(filename), period);
124      m_messageLog = new StringLogEntry(m_log, "messages");
125
126      // Log all NT entries and connections
127      if (m_ntLoggerEnabled) {
128        startNtLog();
129      }
130      // Log console output
131      if (m_consoleLoggerEnabled) {
132        startConsoleLog();
133      }
134    } else if (m_stopped) {
135      m_log.setFilename(makeLogFilename(filename));
136      m_log.resume();
137      m_stopped = false;
138    }
139
140    if (m_thread == null) {
141      m_thread = new Thread(DataLogManager::logMain, "DataLogDS");
142      m_thread.setDaemon(true);
143      m_thread.start();
144    }
145  }
146
147  /** Stop data log manager. */
148  public static synchronized void stop() {
149    if (m_thread != null) {
150      m_thread.interrupt();
151      m_thread = null;
152    }
153    if (m_log != null) {
154      m_log.stop();
155      m_stopped = true;
156    }
157  }
158
159  /**
160   * Log a message to the "messages" entry. The message is also printed to standard output (followed
161   * by a newline).
162   *
163   * @param message message
164   */
165  public static synchronized void log(String message) {
166    if (m_messageLog == null) {
167      start();
168    }
169    m_messageLog.append(message);
170    System.out.println(message);
171  }
172
173  /**
174   * Get the managed data log (for custom logging). Starts the data log manager if not already
175   * started.
176   *
177   * @return data log
178   */
179  public static synchronized DataLog getLog() {
180    if (m_log == null) {
181      start();
182    }
183    return m_log;
184  }
185
186  /**
187   * Get the log directory.
188   *
189   * @return log directory, or empty string if logging not yet started
190   */
191  public static synchronized String getLogDir() {
192    if (m_logDir == null) {
193      return "";
194    }
195    return m_logDir;
196  }
197
198  /**
199   * Enable or disable logging of NetworkTables data. Note that unlike the network interface for
200   * NetworkTables, this will capture every value change. Defaults to enabled.
201   *
202   * @param enabled true to enable, false to disable
203   */
204  public static synchronized void logNetworkTables(boolean enabled) {
205    boolean wasEnabled = m_ntLoggerEnabled;
206    m_ntLoggerEnabled = enabled;
207    if (m_log == null) {
208      start();
209      return;
210    }
211    if (enabled && !wasEnabled) {
212      startNtLog();
213    } else if (!enabled && wasEnabled) {
214      stopNtLog();
215    }
216  }
217
218  /**
219   * Enable or disable logging of the console output. Defaults to enabled.
220   *
221   * @param enabled true to enable, false to disable
222   */
223  public static synchronized void logConsoleOutput(boolean enabled) {
224    boolean wasEnabled = m_consoleLoggerEnabled;
225    m_consoleLoggerEnabled = enabled;
226    if (m_log == null) {
227      start();
228      return;
229    }
230    if (enabled && !wasEnabled) {
231      startConsoleLog();
232    } else if (!enabled && wasEnabled) {
233      stopConsoleLog();
234    }
235  }
236
237  private static String makeLogDir(String dir) {
238    if (!dir.isEmpty()) {
239      return dir;
240    }
241
242    if (RobotBase.isReal()) {
243      try {
244        // prefer a mounted USB drive if one is accessible
245        Path usbDir = Paths.get("/u").toRealPath();
246        if (Files.isWritable(usbDir)) {
247          if (!new File("/u/logs").mkdir()) {
248            // ignored
249          }
250          HAL.report(tResourceType.kResourceType_DataLogManager, tInstances.kDataLogLocation_USB);
251          return "/u/logs";
252        }
253      } catch (IOException ex) {
254        // ignored
255      }
256      if (RobotBase.getRuntimeType() == RuntimeType.kRoboRIO) {
257        DriverStation.reportWarning(
258            "DataLogManager: Logging to RoboRIO 1 internal storage is not recommended!"
259                + " Plug in a FAT32 formatted flash drive!",
260            false);
261      }
262      if (!new File("/home/lvuser/logs").mkdir()) {
263        // ignored
264      }
265      HAL.report(tResourceType.kResourceType_DataLogManager, tInstances.kDataLogLocation_Onboard);
266      return "/home/lvuser/logs";
267    }
268    String logDir = Filesystem.getOperatingDirectory().getAbsolutePath() + "/logs";
269    if (!new File(logDir).mkdir()) {
270      // ignored
271    }
272    return logDir;
273  }
274
275  private static String makeLogFilename(String filenameOverride) {
276    if (!filenameOverride.isEmpty()) {
277      return filenameOverride;
278    }
279    Random rnd = new Random();
280    StringBuilder filename = new StringBuilder();
281    filename.append("FRC_TBD_");
282    for (int i = 0; i < 4; i++) {
283      filename.append(String.format("%04x", rnd.nextInt(0x10000)));
284    }
285    filename.append(".wpilog");
286    return filename.toString();
287  }
288
289  private static void startNtLog() {
290    NetworkTableInstance inst = NetworkTableInstance.getDefault();
291    m_ntEntryLogger = inst.startEntryDataLog(m_log, "", "NT:");
292    m_ntConnLogger = inst.startConnectionDataLog(m_log, "NTConnection");
293  }
294
295  private static void stopNtLog() {
296    NetworkTableInstance.stopEntryDataLog(m_ntEntryLogger);
297    NetworkTableInstance.stopConnectionDataLog(m_ntConnLogger);
298  }
299
300  private static void startConsoleLog() {
301    if (RobotBase.isReal()) {
302      m_consoleLogger = new FileLogger("/home/lvuser/FRC_UserProgram.log", m_log, "console");
303    }
304  }
305
306  private static void stopConsoleLog() {
307    if (RobotBase.isReal()) {
308      m_consoleLogger.close();
309    }
310  }
311
312  private static void logMain() {
313    // based on free disk space, scan for "old" FRC_*.wpilog files and remove
314    {
315      File logDir = new File(m_logDir);
316      long freeSpace = logDir.getUsableSpace();
317      if (freeSpace < kFreeSpaceThreshold) {
318        // Delete oldest FRC_*.wpilog files (ignore FRC_TBD_*.wpilog as we just created one)
319        File[] files =
320            logDir.listFiles(
321                (dir, name) ->
322                    name.startsWith("FRC_")
323                        && name.endsWith(".wpilog")
324                        && !name.startsWith("FRC_TBD_"));
325        if (files != null) {
326          Arrays.sort(files, Comparator.comparingLong(File::lastModified));
327          int count = files.length;
328          for (File file : files) {
329            --count;
330            if (count < kFileCountThreshold) {
331              break;
332            }
333            long length = file.length();
334            if (file.delete()) {
335              DriverStation.reportWarning("DataLogManager: Deleted " + file.getName(), false);
336              freeSpace += length;
337              if (freeSpace >= kFreeSpaceThreshold) {
338                break;
339              }
340            } else {
341              System.err.println("DataLogManager: could not delete " + file);
342            }
343          }
344        }
345      } else if (freeSpace < 2 * kFreeSpaceThreshold) {
346        DriverStation.reportWarning(
347            "DataLogManager: Log storage device has "
348                + freeSpace / 1000000
349                + " MB of free space remaining! Logs will get deleted below "
350                + kFreeSpaceThreshold / 1000000
351                + " MB of free space. "
352                + "Consider deleting logs off the storage device.",
353            false);
354      }
355    }
356
357    int timeoutCount = 0;
358    boolean paused = false;
359    int dsAttachCount = 0;
360    int fmsAttachCount = 0;
361    boolean dsRenamed = m_filenameOverride;
362    boolean fmsRenamed = m_filenameOverride;
363    int sysTimeCount = 0;
364    IntegerLogEntry sysTimeEntry =
365        new IntegerLogEntry(
366            m_log, "systemTime", "{\"source\":\"DataLogManager\",\"format\":\"time_t_us\"}");
367
368    Event newDataEvent = new Event();
369    DriverStation.provideRefreshedDataEventHandle(newDataEvent.getHandle());
370    while (!Thread.interrupted()) {
371      boolean timedOut;
372      try {
373        timedOut = WPIUtilJNI.waitForObjectTimeout(newDataEvent.getHandle(), 0.25);
374      } catch (InterruptedException e) {
375        break;
376      }
377      if (Thread.interrupted()) {
378        break;
379      }
380      if (timedOut) {
381        timeoutCount++;
382        // pause logging after being disconnected for 10 seconds
383        if (timeoutCount > 40 && !paused) {
384          timeoutCount = 0;
385          paused = true;
386          m_log.pause();
387        }
388        continue;
389      }
390      // when we connect to the DS, resume logging
391      timeoutCount = 0;
392      if (paused) {
393        paused = false;
394        m_log.resume();
395      }
396
397      if (!dsRenamed) {
398        // track DS attach
399        if (DriverStation.isDSAttached()) {
400          dsAttachCount++;
401        } else {
402          dsAttachCount = 0;
403        }
404        if (dsAttachCount > 50) { // 1 second
405          if (RobotController.isSystemTimeValid()) {
406            LocalDateTime now = LocalDateTime.now(m_utc);
407            m_log.setFilename("FRC_" + m_timeFormatter.format(now) + ".wpilog");
408            dsRenamed = true;
409          } else {
410            dsAttachCount = 0; // wait a bit and try again
411          }
412        }
413      }
414
415      if (!fmsRenamed) {
416        // track FMS attach
417        if (DriverStation.isFMSAttached()) {
418          fmsAttachCount++;
419        } else {
420          fmsAttachCount = 0;
421        }
422        if (fmsAttachCount > 250) { // 5 seconds
423          // match info comes through TCP, so we need to double-check we've
424          // actually received it
425          DriverStation.MatchType matchType = DriverStation.getMatchType();
426          if (matchType != DriverStation.MatchType.None) {
427            // rename per match info
428            char matchTypeChar =
429                switch (matchType) {
430                  case Practice -> 'P';
431                  case Qualification -> 'Q';
432                  case Elimination -> 'E';
433                  default -> '_';
434                };
435            m_log.setFilename(
436                "FRC_"
437                    + m_timeFormatter.format(LocalDateTime.now(m_utc))
438                    + "_"
439                    + DriverStation.getEventName()
440                    + "_"
441                    + matchTypeChar
442                    + DriverStation.getMatchNumber()
443                    + ".wpilog");
444            fmsRenamed = true;
445            dsRenamed = true; // don't override FMS rename
446          }
447        }
448      }
449
450      // Write system time every ~5 seconds
451      sysTimeCount++;
452      if (sysTimeCount >= 250) {
453        sysTimeCount = 0;
454        if (RobotController.isSystemTimeValid()) {
455          sysTimeEntry.append(WPIUtilJNI.getSystemTime(), WPIUtilJNI.now());
456        }
457      }
458    }
459    newDataEvent.close();
460  }
461}