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