001/* 002 * Copyright (C) Photon Vision. 003 * 004 * This program is free software: you can redistribute it and/or modify 005 * it under the terms of the GNU General Public License as published by 006 * the Free Software Foundation, either version 3 of the License, or 007 * (at your option) any later version. 008 * 009 * This program is distributed in the hope that it will be useful, 010 * but WITHOUT ANY WARRANTY; without even the implied warranty of 011 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 012 * GNU General Public License for more details. 013 * 014 * You should have received a copy of the GNU General Public License 015 * along with this program. If not, see <https://www.gnu.org/licenses/>. 016 */ 017 018package org.photonvision.common.configuration; 019 020import java.io.File; 021import java.io.IOException; 022import java.nio.file.Files; 023import java.nio.file.Path; 024import java.nio.file.StandardCopyOption; 025import java.text.DateFormat; 026import java.text.ParseException; 027import java.text.SimpleDateFormat; 028import java.time.LocalDateTime; 029import java.time.format.DateTimeFormatter; 030import java.time.temporal.TemporalAccessor; 031import java.util.Date; 032import java.util.List; 033import org.opencv.core.Size; 034import org.photonvision.common.logging.LogGroup; 035import org.photonvision.common.logging.Logger; 036import org.photonvision.common.util.file.FileUtils; 037import org.photonvision.vision.processes.VisionSource; 038import org.zeroturnaround.zip.ZipUtil; 039 040public class ConfigManager { 041 private static ConfigManager INSTANCE; 042 043 public static final String HW_CFG_FNAME = "hardwareConfig.json"; 044 public static final String HW_SET_FNAME = "hardwareSettings.json"; 045 public static final String NET_SET_FNAME = "networkSettings.json"; 046 047 final File configDirectoryFile; 048 049 private final ConfigProvider m_provider; 050 051 private final Thread settingsSaveThread; 052 private long saveRequestTimestamp = -1; 053 054 // special case flag to disable flushing settings to disk at shutdown. Avoids the jvm shutdown 055 // hook overwriting the settings we just uploaded 056 private boolean flushOnShutdown = true; 057 private boolean allowWriteTask = true; 058 059 enum ConfigSaveStrategy { 060 SQL, 061 LEGACY, 062 ATOMIC_ZIP 063 } 064 065 // This logic decides which kind of ConfigManager we load as the default. If we want to switch 066 // back to the legacy config manager, change this constant 067 private static final ConfigSaveStrategy m_saveStrat = ConfigSaveStrategy.SQL; 068 069 public static ConfigManager getInstance() { 070 if (INSTANCE == null) { 071 Path rootFolder = PathManager.getInstance().getRootFolder(); 072 switch (m_saveStrat) { 073 case SQL -> INSTANCE = new ConfigManager(rootFolder, new SqlConfigProvider(rootFolder)); 074 case LEGACY -> 075 INSTANCE = new ConfigManager(rootFolder, new LegacyConfigProvider(rootFolder)); 076 case ATOMIC_ZIP -> { 077 // TODO: Not done yet 078 } 079 } 080 } 081 return INSTANCE; 082 } 083 084 private static final Logger logger = new Logger(ConfigManager.class, LogGroup.Config); 085 086 private void translateLegacyIfPresent(Path folderPath) { 087 if (!(m_provider instanceof SqlConfigProvider)) { 088 // Cannot import into SQL if we aren't in SQL mode rn 089 return; 090 } 091 092 var maybeCams = Path.of(folderPath.toAbsolutePath().toString(), "cameras").toFile(); 093 var maybeCamsBak = Path.of(folderPath.toAbsolutePath().toString(), "cameras_backup").toFile(); 094 095 if (maybeCams.exists() && maybeCams.isDirectory()) { 096 logger.info("Translating settings zip!"); 097 var legacy = new LegacyConfigProvider(folderPath); 098 legacy.load(); 099 var loadedConfig = legacy.getConfig(); 100 101 // yeet our current cameras directory, not needed anymore 102 if (maybeCamsBak.exists()) FileUtils.deleteDirectory(maybeCamsBak.toPath()); 103 if (!maybeCams.canWrite()) { 104 maybeCams.setWritable(true); 105 } 106 107 try { 108 Files.move(maybeCams.toPath(), maybeCamsBak.toPath(), StandardCopyOption.REPLACE_EXISTING); 109 } catch (IOException e) { 110 logger.error("Exception moving cameras to cameras_bak!", e); 111 112 // Try to just copy from cams to cams-bak instead of moving? Windows sometimes needs us to 113 // do that 114 try { 115 org.apache.commons.io.FileUtils.copyDirectory(maybeCams, maybeCamsBak); 116 } catch (IOException e1) { 117 // So we can't move to cams_bak, and we can't copy and delete either? We just have to give 118 // up here on preserving the old folder 119 logger.error("Exception while backup-copying cameras to cameras_bak!", e); 120 e1.printStackTrace(); 121 } 122 123 // Delete the directory because we were successfully able to load the config but were unable 124 // to save or copy the folder. 125 if (maybeCams.exists()) FileUtils.deleteDirectory(maybeCams.toPath()); 126 } 127 128 // Save the same config out using SQL loader 129 var sql = new SqlConfigProvider(getRootFolder()); 130 sql.setConfig(loadedConfig); 131 sql.saveToDisk(); 132 } 133 } 134 135 public static boolean nukeConfigDirectory() { 136 return FileUtils.deleteDirectory(getRootFolder()); 137 } 138 139 public static boolean saveUploadedSettingsZip(File uploadPath) { 140 // Unpack to /tmp/something/photonvision 141 var folderPath = Path.of(System.getProperty("java.io.tmpdir"), "photonvision").toFile(); 142 folderPath.mkdirs(); 143 ZipUtil.unpack(uploadPath, folderPath); 144 145 // Nuke the current settings directory 146 if (!nukeConfigDirectory()) { 147 return false; 148 } 149 150 // If there's a cameras folder in the upload, we know we need to import from the 151 // old style 152 var maybeCams = Path.of(folderPath.getAbsolutePath(), "cameras").toFile(); 153 if (maybeCams.exists() && maybeCams.isDirectory()) { 154 var legacy = new LegacyConfigProvider(folderPath.toPath()); 155 legacy.load(); 156 var loadedConfig = legacy.getConfig(); 157 158 var sql = new SqlConfigProvider(getRootFolder()); 159 sql.setConfig(loadedConfig); 160 return sql.saveToDisk(); 161 } else { 162 // new structure -- just copy and save like we used to 163 try { 164 org.apache.commons.io.FileUtils.copyDirectory(folderPath, getRootFolder().toFile()); 165 logger.info("Copied settings successfully!"); 166 return true; 167 } catch (IOException e) { 168 logger.error("Exception copying uploaded settings!", e); 169 return false; 170 } 171 } 172 } 173 174 public PhotonConfiguration getConfig() { 175 return m_provider.getConfig(); 176 } 177 178 private static Path getRootFolder() { 179 return PathManager.getInstance().getRootFolder(); 180 } 181 182 ConfigManager(Path configDirectory, ConfigProvider provider) { 183 this.configDirectoryFile = new File(configDirectory.toUri()); 184 m_provider = provider; 185 186 settingsSaveThread = new Thread(this::saveAndWriteTask); 187 settingsSaveThread.start(); 188 } 189 190 public void load() { 191 translateLegacyIfPresent(this.configDirectoryFile.toPath()); 192 m_provider.load(); 193 } 194 195 public void addCameraConfigurations(List<VisionSource> sources) { 196 getConfig().addCameraConfigs(sources); 197 requestSave(); 198 } 199 200 public void addCameraConfiguration(CameraConfiguration config) { 201 getConfig().addCameraConfig(config); 202 requestSave(); 203 } 204 205 public void saveModule(CameraConfiguration config, String uniqueName) { 206 getConfig().addCameraConfig(uniqueName, config); 207 requestSave(); 208 } 209 210 public File getSettingsFolderAsZip() { 211 File out = Path.of(System.getProperty("java.io.tmpdir"), "photonvision-settings.zip").toFile(); 212 try { 213 ZipUtil.pack(configDirectoryFile, out); 214 } catch (Exception e) { 215 e.printStackTrace(); 216 } 217 return out; 218 } 219 220 public void setNetworkSettings(NetworkConfig networkConfig) { 221 getConfig().setNetworkConfig(networkConfig); 222 requestSave(); 223 } 224 225 public Path getLogsDir() { 226 return Path.of(configDirectoryFile.toString(), "logs"); 227 } 228 229 public Path getCalibDir() { 230 return Path.of(configDirectoryFile.toString(), "calibImgs"); 231 } 232 233 public static final String LOG_PREFIX = "photonvision-"; 234 public static final String LOG_EXT = ".log"; 235 public static final String LOG_DATE_TIME_FORMAT = "yyyy-M-d_hh-mm-ss"; 236 237 public String taToLogFname(TemporalAccessor date) { 238 var dateString = DateTimeFormatter.ofPattern(LOG_DATE_TIME_FORMAT).format(date); 239 return LOG_PREFIX + dateString + LOG_EXT; 240 } 241 242 public Date logFnameToDate(String fname) throws ParseException { 243 // Strip away known unneeded portions of the log file name 244 fname = fname.replace(LOG_PREFIX, "").replace(LOG_EXT, ""); 245 DateFormat format = new SimpleDateFormat(LOG_DATE_TIME_FORMAT); 246 return format.parse(fname); 247 } 248 249 public Path getLogPath() { 250 var logFile = Path.of(this.getLogsDir().toString(), taToLogFname(LocalDateTime.now())).toFile(); 251 if (!logFile.getParentFile().exists()) logFile.getParentFile().mkdirs(); 252 return logFile.toPath(); 253 } 254 255 public Path getImageSavePath() { 256 var imgFilePath = Path.of(configDirectoryFile.toString(), "imgSaves").toFile(); 257 if (!imgFilePath.exists()) imgFilePath.mkdirs(); 258 return imgFilePath.toPath(); 259 } 260 261 public Path getCalibrationImageSavePath(String uniqueCameraName) { 262 var imgFilePath = 263 Path.of(configDirectoryFile.toString(), "calibration", uniqueCameraName).toFile(); 264 if (!imgFilePath.exists()) imgFilePath.mkdirs(); 265 return imgFilePath.toPath(); 266 } 267 268 public Path getCalibrationImageSavePathWithRes(Size frameSize, String uniqueCameraName) { 269 var imgFilePath = 270 Path.of( 271 configDirectoryFile.toString(), 272 "calibration", 273 uniqueCameraName, 274 "imgs", 275 frameSize.toString()) 276 .toFile(); 277 if (!imgFilePath.exists()) imgFilePath.mkdirs(); 278 return imgFilePath.toPath(); 279 } 280 281 public boolean saveUploadedHardwareConfig(Path uploadPath) { 282 return m_provider.saveUploadedHardwareConfig(uploadPath); 283 } 284 285 public boolean saveUploadedHardwareSettings(Path uploadPath) { 286 return m_provider.saveUploadedHardwareSettings(uploadPath); 287 } 288 289 public boolean saveUploadedNetworkConfig(Path uploadPath) { 290 return m_provider.saveUploadedNetworkConfig(uploadPath); 291 } 292 293 public boolean saveUploadedAprilTagFieldLayout(Path uploadPath) { 294 return m_provider.saveUploadedAprilTagFieldLayout(uploadPath); 295 } 296 297 public void requestSave() { 298 logger.trace("Requesting save..."); 299 saveRequestTimestamp = System.currentTimeMillis(); 300 } 301 302 public void unloadCameraConfigs() { 303 this.getConfig().getCameraConfigurations().clear(); 304 } 305 306 public void clearConfig() { 307 logger.info("Clearing configuration!"); 308 m_provider.clearConfig(); 309 m_provider.saveToDisk(); 310 } 311 312 public void saveToDisk() { 313 m_provider.saveToDisk(); 314 } 315 316 private void saveAndWriteTask() { 317 // Only save if 1 second has past since the request was made 318 while (!Thread.currentThread().isInterrupted()) { 319 if (saveRequestTimestamp > 0 320 && (System.currentTimeMillis() - saveRequestTimestamp) > 1000L 321 && allowWriteTask) { 322 saveRequestTimestamp = -1; 323 logger.debug("Saving to disk..."); 324 saveToDisk(); 325 } 326 327 try { 328 Thread.sleep(1000); 329 } catch (InterruptedException e) { 330 logger.error("Exception waiting for settings semaphore", e); 331 } 332 } 333 } 334 335 /** Get (and create if not present) the subfolder where ML models are stored */ 336 public File getModelsDirectory() { 337 var ret = new File(configDirectoryFile, "models"); 338 if (!ret.exists()) ret.mkdirs(); 339 return ret; 340 } 341 342 /** 343 * Disable flushing settings to disk as part of our JVM exit hook. Used to prevent uploading all 344 * settings from getting its new configs overwritten at program exit and before they're all 345 * loaded. 346 */ 347 public void disableFlushOnShutdown() { 348 this.flushOnShutdown = false; 349 } 350 351 /** Prevent pending automatic saves */ 352 public void setWriteTaskEnabled(boolean enabled) { 353 this.allowWriteTask = enabled; 354 } 355 356 public void onJvmExit() { 357 if (flushOnShutdown) { 358 logger.info("Force-flushing settings..."); 359 saveToDisk(); 360 } 361 } 362}