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