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