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}