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.DriverStation; 024import org.wpilib.framework.RobotBase; 025import org.wpilib.hardware.hal.HAL; 026import org.wpilib.networktables.NetworkTableInstance; 027import org.wpilib.util.WPIUtilJNI; 028import org.wpilib.util.concurrent.Event; 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/systemcore/logs otherwise. 035 * 036 * <p>Log files are initially named "WPILIB_TBD_{random}.wpilog" until the DS connects. After the DS 037 * connects, the log file is renamed to "WPILIB_yyyyMMdd_HHmmss.wpilog" (where the date/time is 038 * UTC). If the FMS is connected and provides a match number, the log file is renamed to 039 * "WPILIB_yyyyMMdd_HHmmss_{event}_{match}.wpilog". 040 * 041 * <p>On startup, all existing WPILIB_TBD log files are deleted. If there is less than 50 MB of free 042 * space on the target storage, WPILIB_ log files are deleted (oldest to newest) until there is 50 043 * MB 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 WPILIB_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("WPILIB_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.reportUsage("DataLogManager", "USB"); 251 return "/u/logs"; 252 } 253 } catch (IOException ex) { 254 // ignored 255 } 256 if (!new File("/home/systemcore/logs").mkdir()) { 257 // ignored 258 } 259 HAL.reportUsage("DataLogManager", "Onboard"); 260 return "/home/systemcore/logs"; 261 } 262 String logDir = Filesystem.getOperatingDirectory().getAbsolutePath() + "/logs"; 263 if (!new File(logDir).mkdir()) { 264 // ignored 265 } 266 return logDir; 267 } 268 269 private static String makeLogFilename(String filenameOverride) { 270 if (!filenameOverride.isEmpty()) { 271 return filenameOverride; 272 } 273 Random rnd = new Random(); 274 StringBuilder filename = new StringBuilder(18); 275 filename.append("WPILIB_TBD_"); 276 for (int i = 0; i < 4; i++) { 277 filename.append(String.format("%04x", rnd.nextInt(0x10000))); 278 } 279 filename.append(".wpilog"); 280 return filename.toString(); 281 } 282 283 private static void startNtLog() { 284 NetworkTableInstance inst = NetworkTableInstance.getDefault(); 285 m_ntEntryLogger = inst.startEntryDataLog(m_log, "", "NT:"); 286 m_ntConnLogger = inst.startConnectionDataLog(m_log, "NTConnection"); 287 } 288 289 private static void stopNtLog() { 290 NetworkTableInstance.stopEntryDataLog(m_ntEntryLogger); 291 NetworkTableInstance.stopConnectionDataLog(m_ntConnLogger); 292 } 293 294 private static void startConsoleLog() { 295 if (RobotBase.isReal()) { 296 m_consoleLogger = new FileLogger("/home/systemcore/WPILIB_UserProgram.log", m_log, "console"); 297 } 298 } 299 300 private static void stopConsoleLog() { 301 if (RobotBase.isReal()) { 302 m_consoleLogger.close(); 303 } 304 } 305 306 private static void logMain() { 307 // based on free disk space, scan for "old" WPILIB_*.wpilog files and remove 308 { 309 File logDir = new File(m_logDir); 310 long freeSpace = logDir.getUsableSpace(); 311 if (freeSpace < kFreeSpaceThreshold) { 312 // Delete oldest WPILIB_*.wpilog files (ignore WPILIB_TBD_*.wpilog as we just created one) 313 File[] files = 314 logDir.listFiles( 315 (dir, name) -> 316 name.startsWith("WPILIB_") 317 && name.endsWith(".wpilog") 318 && !name.startsWith("WPILIB_TBD_")); 319 if (files != null) { 320 Arrays.sort(files, Comparator.comparingLong(File::lastModified)); 321 int count = files.length; 322 for (File file : files) { 323 --count; 324 if (count < kFileCountThreshold) { 325 break; 326 } 327 long length = file.length(); 328 if (file.delete()) { 329 DriverStation.reportWarning("DataLogManager: Deleted " + file.getName(), false); 330 freeSpace += length; 331 if (freeSpace >= kFreeSpaceThreshold) { 332 break; 333 } 334 } else { 335 System.err.println("DataLogManager: could not delete " + file); 336 } 337 } 338 } 339 } else if (freeSpace < 2 * kFreeSpaceThreshold) { 340 DriverStation.reportWarning( 341 "DataLogManager: Log storage device has " 342 + freeSpace / 1000000 343 + " MB of free space remaining! Logs will get deleted below " 344 + kFreeSpaceThreshold / 1000000 345 + " MB of free space. " 346 + "Consider deleting logs off the storage device.", 347 false); 348 } 349 } 350 351 int timeoutCount = 0; 352 boolean paused = false; 353 int dsAttachCount = 0; 354 int fmsAttachCount = 0; 355 boolean dsRenamed = m_filenameOverride; 356 boolean fmsRenamed = m_filenameOverride; 357 int sysTimeCount = 0; 358 IntegerLogEntry sysTimeEntry = 359 new IntegerLogEntry( 360 m_log, "systemTime", "{\"source\":\"DataLogManager\",\"format\":\"time_t_us\"}"); 361 362 Event newDataEvent = new Event(); 363 DriverStation.provideRefreshedDataEventHandle(newDataEvent.getHandle()); 364 while (!Thread.interrupted()) { 365 boolean timedOut; 366 try { 367 timedOut = WPIUtilJNI.waitForObjectTimeout(newDataEvent.getHandle(), 0.25); 368 } catch (InterruptedException e) { 369 break; 370 } 371 if (Thread.interrupted()) { 372 break; 373 } 374 if (timedOut) { 375 timeoutCount++; 376 // pause logging after being disconnected for 10 seconds 377 if (timeoutCount > 40 && !paused) { 378 timeoutCount = 0; 379 paused = true; 380 m_log.pause(); 381 } 382 continue; 383 } 384 // when we connect to the DS, resume logging 385 timeoutCount = 0; 386 if (paused) { 387 paused = false; 388 m_log.resume(); 389 } 390 391 if (!dsRenamed) { 392 // track DS attach 393 if (DriverStation.isDSAttached()) { 394 dsAttachCount++; 395 } else { 396 dsAttachCount = 0; 397 } 398 if (dsAttachCount > 50) { // 1 second 399 if (RobotController.isSystemTimeValid()) { 400 LocalDateTime now = LocalDateTime.now(m_utc); 401 m_log.setFilename("WPILIB_" + m_timeFormatter.format(now) + ".wpilog"); 402 dsRenamed = true; 403 } else { 404 dsAttachCount = 0; // wait a bit and try again 405 } 406 } 407 } 408 409 if (!fmsRenamed) { 410 // track FMS attach 411 if (DriverStation.isFMSAttached()) { 412 fmsAttachCount++; 413 } else { 414 fmsAttachCount = 0; 415 } 416 if (fmsAttachCount > 250) { // 5 seconds 417 // match info comes through TCP, so we need to double-check we've 418 // actually received it 419 DriverStation.MatchType matchType = DriverStation.getMatchType(); 420 if (matchType != DriverStation.MatchType.None) { 421 // rename per match info 422 char matchTypeChar = 423 switch (matchType) { 424 case Practice -> 'P'; 425 case Qualification -> 'Q'; 426 case Elimination -> 'E'; 427 default -> '_'; 428 }; 429 m_log.setFilename( 430 "WPILIB_" 431 + m_timeFormatter.format(LocalDateTime.now(m_utc)) 432 + "_" 433 + DriverStation.getEventName() 434 + "_" 435 + matchTypeChar 436 + DriverStation.getMatchNumber() 437 + ".wpilog"); 438 fmsRenamed = true; 439 dsRenamed = true; // don't override FMS rename 440 } 441 } 442 } 443 444 // Write system time every ~5 seconds 445 sysTimeCount++; 446 if (sysTimeCount >= 250) { 447 sysTimeCount = 0; 448 if (RobotController.isSystemTimeValid()) { 449 sysTimeEntry.append(WPIUtilJNI.getSystemTime(), WPIUtilJNI.now()); 450 } 451 } 452 } 453 newDataEvent.close(); 454 } 455}