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 edu.wpi.first.apriltag.AprilTagFieldLayout; 021import edu.wpi.first.apriltag.AprilTagFields; 022import edu.wpi.first.cscore.UsbCameraInfo; 023import java.io.File; 024import java.io.IOException; 025import java.io.UncheckedIOException; 026import java.nio.file.Files; 027import java.nio.file.Path; 028import java.sql.*; 029import java.util.ArrayList; 030import java.util.HashMap; 031import java.util.List; 032import java.util.Objects; 033import java.util.stream.Collectors; 034import org.photonvision.common.configuration.CameraConfiguration.LegacyCameraConfigStruct; 035import org.photonvision.common.configuration.DatabaseSchema.Columns; 036import org.photonvision.common.configuration.DatabaseSchema.Tables; 037import org.photonvision.common.logging.LogGroup; 038import org.photonvision.common.logging.Logger; 039import org.photonvision.common.util.file.JacksonUtils; 040import org.photonvision.vision.pipeline.CVPipelineSettings; 041import org.photonvision.vision.pipeline.DriverModePipelineSettings; 042 043/** 044 * Saves settings in a SQLite database file (called photon.sqlite). 045 * 046 * <p>Within this database we have a cameras database, which has one row per camera, and holds: 047 * unique_name, config_json, drivermode_json, pipeline_jsons. 048 * 049 * <p>Global has one row per global config file (like hardware settings and network settings) 050 */ 051public class SqlConfigProvider extends ConfigProvider { 052 private static final Logger logger = new Logger(SqlConfigProvider.class, LogGroup.Config); 053 054 static class GlobalKeys { 055 static final String NETWORK_CONFIG = "networkConfig"; 056 static final String HARDWARE_CONFIG = "hardwareConfig"; 057 static final String HARDWARE_SETTINGS = "hardwareSettings"; 058 static final String ATFL_CONFIG_FILE = "apriltagFieldLayout"; 059 } 060 061 private static final String dbName = "photon.sqlite"; 062 // private final File rootFolder; 063 private final String dbPath; 064 private final String url; 065 066 private final Object m_mutex = new Object(); 067 068 public SqlConfigProvider(Path rootPath) { 069 File rootFolder = rootPath.toFile(); 070 // Make sure root dir exists 071 if (!rootFolder.exists()) { 072 if (rootFolder.mkdirs()) { 073 logger.debug("Root config folder did not exist. Created!"); 074 } else { 075 logger.error("Failed to create root config folder!"); 076 } 077 } 078 dbPath = Path.of(rootFolder.toString(), dbName).toAbsolutePath().toString(); 079 url = "jdbc:sqlite:" + dbPath; 080 logger.debug("Using database " + dbPath); 081 initDatabase(); 082 } 083 084 public PhotonConfiguration getConfig() { 085 if (config == null) { 086 logger.warn("CONFIG IS NULL!"); 087 } 088 return config; 089 } 090 091 private Connection createConn(boolean autoCommit) { 092 Connection conn = null; 093 try { 094 conn = DriverManager.getConnection(url); 095 conn.setAutoCommit(autoCommit); 096 } catch (SQLException e) { 097 logger.error("Error creating connection", e); 098 } 099 return conn; 100 } 101 102 private Connection createConn() { 103 return createConn(false); 104 } 105 106 private void tryCommit(Connection conn) { 107 try { 108 conn.commit(); 109 } catch (SQLException e1) { 110 logger.error("Err committing changes: ", e1); 111 try { 112 conn.rollback(); 113 } catch (SQLException e2) { 114 logger.error("Err rolling back changes: ", e2); 115 } 116 } 117 } 118 119 private int getIntPragma(String pragma) { 120 int retval = 0; 121 try (Connection conn = createConn(true); 122 Statement stmt = conn.createStatement()) { 123 ResultSet rs = stmt.executeQuery("PRAGMA " + pragma + ";"); 124 retval = rs.getInt(1); 125 } catch (SQLException e) { 126 logger.error("Error querying " + pragma, e); 127 } 128 return retval; 129 } 130 131 private int getSchemaVersion() { 132 return getIntPragma("schema_version"); 133 } 134 135 public int getUserVersion() { 136 return getIntPragma("user_version"); 137 } 138 139 private void setUserVersion(Connection conn, int value) { 140 try (Statement stmt = conn.createStatement()) { 141 stmt.execute("PRAGMA user_version = " + value + ";"); 142 } catch (SQLException e) { 143 logger.error("Error setting user_version to ", e); 144 } 145 } 146 147 private void doMigration(int index) throws SQLException { 148 logger.debug("Running migration step " + index); 149 try (Connection conn = createConn(); 150 Statement stmt = conn.createStatement()) { 151 for (String sql : DatabaseSchema.migrations[index].split(";")) { 152 stmt.addBatch(sql); 153 } 154 stmt.executeBatch(); 155 setUserVersion(conn, index + 1); 156 tryCommit(conn); 157 } catch (SQLException e) { 158 logger.error("Error with migration step " + index, e); 159 throw e; 160 } 161 } 162 163 private void initDatabase() { 164 int userVersion = getUserVersion(); 165 int expectedVersion = DatabaseSchema.migrations.length; 166 167 if (userVersion < expectedVersion) { 168 // older database, run migrations 169 170 // first, check to see if this is one of the ones from 2024 beta that need 171 // special handling 172 if (userVersion == 0 && getSchemaVersion() > 0) { 173 String sql = 174 "SELECT COUNT(*) AS CNTREC FROM pragma_table_info('cameras') WHERE name='otherpaths_json';"; 175 try (Connection conn = createConn(true); 176 Statement stmt = conn.createStatement(); 177 ResultSet rs = stmt.executeQuery(sql); ) { 178 if (rs.getInt("CNTREC") == 0) { 179 // need to add otherpaths_json 180 userVersion = 1; 181 } else { 182 // already there, no need to add the column 183 userVersion = 2; 184 } 185 setUserVersion(conn, userVersion); 186 } catch (SQLException e) { 187 logger.error( 188 "Could not determine the version of the database. Try deleting " 189 + dbName 190 + "and restart photonvision.", 191 e); 192 } 193 } 194 195 logger.debug("Older database version. Migrating ... "); 196 try { 197 for (int index = userVersion; index < expectedVersion; index++) { 198 doMigration(index); 199 } 200 logger.debug("Database migration complete"); 201 } catch (SQLException e) { 202 logger.error("Error with database migration", e); 203 } 204 } 205 206 // Warn if the database still isn't at the correct version 207 userVersion = getUserVersion(); 208 if (userVersion > expectedVersion) { 209 // database must be from a newer version, so warn 210 logger.warn( 211 "This database is from a newer version of PhotonVision. Check that you are running the right version of PhotonVision."); 212 } else if (userVersion < expectedVersion) { 213 // migration didn't work, so warn 214 logger.warn( 215 "This database migration failed. Expected version: " 216 + expectedVersion 217 + ", got version: " 218 + userVersion); 219 } else { 220 // migration worked 221 logger.info("Using correct database version: " + userVersion); 222 } 223 } 224 225 @Override 226 public boolean saveToDisk() { 227 logger.debug("Saving to disk"); 228 var conn = createConn(); 229 if (conn == null) return false; 230 231 synchronized (m_mutex) { 232 if (config == null) { 233 logger.error("Config null! Cannot save"); 234 return false; 235 } 236 237 saveCameras(conn); 238 saveGlobal(conn); 239 tryCommit(conn); 240 241 try { 242 conn.close(); 243 } catch (SQLException e) { 244 // TODO, does the file still save if the SQL connection isn't closed correctly? 245 // If so, 246 // return false here. 247 logger.error("SQL Err closing connection while saving to disk: ", e); 248 } 249 } 250 251 logger.info("Settings saved!"); 252 return true; 253 } 254 255 @Override 256 public void load() { 257 logger.debug("Loading config..."); 258 var conn = createConn(); 259 if (conn == null) return; 260 261 synchronized (m_mutex) { 262 HardwareConfig hardwareConfig; 263 HardwareSettings hardwareSettings; 264 NetworkConfig networkConfig; 265 AprilTagFieldLayout atfl; 266 267 try { 268 hardwareConfig = 269 JacksonUtils.deserialize( 270 getOneConfigFile(conn, GlobalKeys.HARDWARE_CONFIG), HardwareConfig.class); 271 } catch (IOException e) { 272 logger.error("Could not deserialize hardware config! Loading defaults", e); 273 hardwareConfig = new HardwareConfig(); 274 } 275 276 try { 277 hardwareSettings = 278 JacksonUtils.deserialize( 279 getOneConfigFile(conn, GlobalKeys.HARDWARE_SETTINGS), HardwareSettings.class); 280 } catch (IOException e) { 281 logger.error("Could not deserialize hardware settings! Loading defaults", e); 282 hardwareSettings = new HardwareSettings(); 283 } 284 285 try { 286 networkConfig = 287 JacksonUtils.deserialize( 288 getOneConfigFile(conn, GlobalKeys.NETWORK_CONFIG), NetworkConfig.class); 289 } catch (IOException e) { 290 logger.error("Could not deserialize network config! Loading defaults", e); 291 networkConfig = new NetworkConfig(); 292 } 293 294 try { 295 atfl = 296 JacksonUtils.deserialize( 297 getOneConfigFile(conn, GlobalKeys.ATFL_CONFIG_FILE), AprilTagFieldLayout.class); 298 } catch (IOException e) { 299 logger.error("Could not deserialize apriltag layout! Loading defaults", e); 300 try { 301 atfl = AprilTagFieldLayout.loadField(AprilTagFields.kDefaultField); 302 } catch (UncheckedIOException e2) { 303 logger.error("Error loading WPILib field", e); 304 atfl = null; 305 } 306 if (atfl == null) { 307 // what do we even do here lmao -- wpilib should always work 308 logger.error("Field layout is *still* null??????"); 309 atfl = new AprilTagFieldLayout(List.of(), 1, 1); 310 } 311 } 312 313 var cams = loadCameraConfigs(conn); 314 315 try { 316 conn.close(); 317 } catch (SQLException e) { 318 logger.error("SQL Err closing connection while loading: ", e); 319 } 320 321 this.config = 322 new PhotonConfiguration(hardwareConfig, hardwareSettings, networkConfig, atfl, cams); 323 } 324 } 325 326 private String getOneConfigFile(Connection conn, String filename) { 327 // Query every single row of the global settings db 328 PreparedStatement query = null; 329 try { 330 query = 331 conn.prepareStatement( 332 String.format( 333 "SELECT %s FROM %s WHERE %s = \"%s\"", 334 Columns.GLB_CONTENTS, Tables.GLOBAL, Columns.GLB_FILENAME, filename)); 335 336 var result = query.executeQuery(); 337 338 while (result.next()) { 339 return result.getString(Columns.GLB_CONTENTS); 340 } 341 } catch (SQLException e) { 342 logger.error("SQL Err getting file " + filename, e); 343 } finally { 344 try { 345 if (query != null) query.close(); 346 } catch (SQLException e) { 347 logger.error("SQL Err closing config file query " + filename, e); 348 } 349 } 350 351 return ""; 352 } 353 354 private void saveCameras(Connection conn) { 355 try { 356 // Delete all cameras we don't need anymore 357 String deleteExtraCamsString = 358 String.format( 359 "DELETE FROM %s WHERE %s not in (%s)", 360 Tables.CAMERAS, 361 Columns.CAM_UNIQUE_NAME, 362 config.getCameraConfigurations().keySet().stream() 363 .map(it -> "\"" + it + "\"") 364 .collect(Collectors.joining(", "))); 365 366 var stmt = conn.createStatement(); 367 stmt.executeUpdate(deleteExtraCamsString); 368 369 // Replace this camera's row with the new settings 370 var sqlString = 371 String.format( 372 "REPLACE INTO %s (%s, %s, %s, %s) VALUES (?,?,?,?);", 373 Tables.CAMERAS, 374 Columns.CAM_UNIQUE_NAME, 375 Columns.CAM_CONFIG_JSON, 376 Columns.CAM_DRIVERMODE_JSON, 377 Columns.CAM_PIPELINE_JSONS); 378 379 for (var c : config.getCameraConfigurations().entrySet()) { 380 PreparedStatement statement = conn.prepareStatement(sqlString); 381 382 var config = c.getValue(); 383 statement.setString(1, c.getKey()); 384 statement.setString(2, JacksonUtils.serializeToString(config)); 385 statement.setString(3, JacksonUtils.serializeToString(config.driveModeSettings)); 386 387 // Serializing a list of abstract classes sucks. Instead, make it into an array 388 // of strings, which we can later unpack back into individual settings 389 List<String> settings = 390 config.pipelineSettings.stream() 391 .map( 392 it -> { 393 try { 394 return JacksonUtils.serializeToString(it); 395 } catch (IOException e) { 396 e.printStackTrace(); 397 return null; 398 } 399 }) 400 .filter(Objects::nonNull) 401 .collect(Collectors.toList()); 402 statement.setString(4, JacksonUtils.serializeToString(settings)); 403 404 statement.executeUpdate(); 405 } 406 407 } catch (SQLException | IOException e) { 408 logger.error("Err saving cameras", e); 409 try { 410 conn.rollback(); 411 } catch (SQLException e1) { 412 logger.error("Err rolling back changes: ", e); 413 } 414 } 415 } 416 417 private void addFile(PreparedStatement ps, String key, String value) throws SQLException { 418 ps.setString(1, key); 419 ps.setString(2, value); 420 } 421 422 // NOTE to Future Developers: 423 // These booleans form a mechanism to prevent saveGlobal() and 424 // saveOneFile() from stepping on each other's toes. Both write 425 // to the database on disk, and both write to the same keys, but 426 // they use different sources. Generally, if the user has done something 427 // to trigger saveOneFile() to get called, it implies they want that 428 // configuration, and not whatever is in RAM right now (which is what 429 // saveGlobal() uses to write). Therefor, once saveOneFile() is invoked, 430 // we record which entry was overwritten in the database and prevent 431 // overwriting it when saveGlobal() is invoked (likely by the shutdown 432 // that should almost almost almost happen right after saveOneFile() is 433 // invoked). 434 // 435 // In the future, this may not be needed. A better architecture would involve 436 // manipulating the RAM representation of configuration when new .json files 437 // are uploaded in the UI, and eliminate all other usages of saveOneFile(). 438 // But, seeing as it's Dec 28 and kickoff is nigh, we put this here and moved 439 // on. 440 // Thank you for coming to my TED talk. 441 private boolean skipSavingHWCfg = false; 442 private boolean skipSavingHWSet = false; 443 private boolean skipSavingNWCfg = false; 444 private boolean skipSavingAPRTG = false; 445 446 private void saveGlobal(Connection conn) { 447 PreparedStatement statement1 = null; 448 PreparedStatement statement2 = null; 449 PreparedStatement statement3 = null; 450 try { 451 // Replace this camera's row with the new settings 452 var sqlString = 453 String.format( 454 "REPLACE INTO %s (%s, %s) VALUES (?,?);", 455 Tables.GLOBAL, Columns.GLB_FILENAME, Columns.GLB_CONTENTS); 456 457 if (!skipSavingHWSet) { 458 statement1 = conn.prepareStatement(sqlString); 459 addFile( 460 statement1, 461 GlobalKeys.HARDWARE_SETTINGS, 462 JacksonUtils.serializeToString(config.getHardwareSettings())); 463 statement1.executeUpdate(); 464 } 465 466 if (!skipSavingNWCfg) { 467 statement2 = conn.prepareStatement(sqlString); 468 addFile( 469 statement2, 470 GlobalKeys.NETWORK_CONFIG, 471 JacksonUtils.serializeToString(config.getNetworkConfig())); 472 statement2.executeUpdate(); 473 statement2.close(); 474 } 475 476 if (!skipSavingHWCfg) { 477 statement3 = conn.prepareStatement(sqlString); 478 addFile( 479 statement3, 480 GlobalKeys.HARDWARE_CONFIG, 481 JacksonUtils.serializeToString(config.getHardwareConfig())); 482 statement3.executeUpdate(); 483 statement3.close(); 484 } 485 486 } catch (SQLException | IOException e) { 487 logger.error("Err saving global", e); 488 try { 489 conn.rollback(); 490 } catch (SQLException e1) { 491 logger.error("Err rolling back changes: ", e); 492 } 493 } finally { 494 try { 495 if (statement1 != null) statement1.close(); 496 if (statement2 != null) statement2.close(); 497 if (statement3 != null) statement3.close(); 498 } catch (SQLException e) { 499 logger.error("SQL Err closing global settings query ", e); 500 } 501 } 502 } 503 504 private boolean saveOneFile(String fname, Path path) { 505 Connection conn = null; 506 PreparedStatement statement1 = null; 507 508 try { 509 conn = createConn(); 510 if (conn == null) { 511 return false; 512 } 513 514 // Replace this camera's row with the new settings 515 var sqlString = 516 String.format( 517 "REPLACE INTO %s (%s, %s) VALUES (?,?);", 518 Tables.GLOBAL, Columns.GLB_FILENAME, Columns.GLB_CONTENTS); 519 520 statement1 = conn.prepareStatement(sqlString); 521 addFile(statement1, fname, Files.readString(path)); 522 statement1.executeUpdate(); 523 524 conn.commit(); 525 return true; 526 } catch (SQLException | IOException e) { 527 logger.error("Error while saving file to global: ", e); 528 try { 529 conn.rollback(); 530 } catch (SQLException e1) { 531 logger.error("Error rolling back changes: ", e); 532 } 533 return false; 534 } finally { 535 try { 536 if (statement1 != null) statement1.close(); 537 conn.close(); 538 } catch (SQLException e) { 539 logger.error("SQL Error saving file " + fname, e); 540 } 541 } 542 } 543 544 @Override 545 public boolean saveUploadedHardwareConfig(Path uploadPath) { 546 skipSavingHWCfg = true; 547 return saveOneFile(GlobalKeys.HARDWARE_CONFIG, uploadPath); 548 } 549 550 @Override 551 public boolean saveUploadedHardwareSettings(Path uploadPath) { 552 skipSavingHWSet = true; 553 return saveOneFile(GlobalKeys.HARDWARE_SETTINGS, uploadPath); 554 } 555 556 @Override 557 public boolean saveUploadedNetworkConfig(Path uploadPath) { 558 skipSavingNWCfg = true; 559 return saveOneFile(GlobalKeys.NETWORK_CONFIG, uploadPath); 560 } 561 562 @Override 563 public boolean saveUploadedAprilTagFieldLayout(Path uploadPath) { 564 skipSavingAPRTG = true; 565 return saveOneFile(GlobalKeys.ATFL_CONFIG_FILE, uploadPath); 566 } 567 568 private HashMap<String, CameraConfiguration> loadCameraConfigs(Connection conn) { 569 HashMap<String, CameraConfiguration> loadedConfigurations = new HashMap<>(); 570 571 // Query every single row of the cameras db 572 PreparedStatement query = null; 573 try { 574 query = 575 conn.prepareStatement( 576 String.format( 577 "SELECT %s, %s, %s, %s, %s FROM %s", 578 Columns.CAM_UNIQUE_NAME, 579 Columns.CAM_CONFIG_JSON, 580 Columns.CAM_DRIVERMODE_JSON, 581 Columns.CAM_OTHERPATHS_JSON, 582 Columns.CAM_PIPELINE_JSONS, 583 Tables.CAMERAS)); 584 585 var result = query.executeQuery(); 586 587 // Iterate over every row/"camera" in the table 588 while (result.next()) { 589 List<String> dummyList = new ArrayList<>(); 590 591 var uniqueName = result.getString(Columns.CAM_UNIQUE_NAME); 592 593 // A horrifying hack to keep backward compat with otherpaths 594 // We -really- need to delete this -stupid- otherpaths column. I hate it. 595 var configStr = result.getString(Columns.CAM_CONFIG_JSON); 596 CameraConfiguration config = JacksonUtils.deserialize(configStr, CameraConfiguration.class); 597 598 if (config.matchedCameraInfo == null) { 599 logger.info("Legacy CameraConfiguration detected - upgrading"); 600 601 // manually create the matchedCameraInfo ourselves. Need to upgrade: 602 // baseName, path, otherPaths, cameraType, usbvid/pid -> matchedCameraInfo 603 config.matchedCameraInfo = 604 JacksonUtils.deserialize(configStr, LegacyCameraConfigStruct.class).matchedCameraInfo; 605 606 // Except that otherPaths used to be its own column. so hack that in here as well 607 var otherPaths = 608 JacksonUtils.deserialize( 609 result.getString(Columns.CAM_OTHERPATHS_JSON), String[].class); 610 if (config.matchedCameraInfo instanceof UsbCameraInfo usbInfo) { 611 usbInfo.otherPaths = otherPaths; 612 } 613 } 614 615 var driverMode = 616 JacksonUtils.deserialize( 617 result.getString(Columns.CAM_DRIVERMODE_JSON), DriverModePipelineSettings.class); 618 List<?> pipelineSettings = 619 JacksonUtils.deserialize( 620 result.getString(Columns.CAM_PIPELINE_JSONS), dummyList.getClass()); 621 622 List<CVPipelineSettings> loadedSettings = new ArrayList<>(); 623 for (var setting : pipelineSettings) { 624 if (setting instanceof String str) { 625 loadedSettings.add(JacksonUtils.deserialize(str, CVPipelineSettings.class)); 626 } 627 } 628 629 config.pipelineSettings = loadedSettings; 630 config.driveModeSettings = driverMode; 631 loadedConfigurations.put(uniqueName, config); 632 } 633 } catch (SQLException | IOException e) { 634 logger.error("Err loading cameras: ", e); 635 } finally { 636 try { 637 if (query != null) query.close(); 638 } catch (SQLException e) { 639 logger.error("SQL Err closing connection while loading cameras ", e); 640 } 641 } 642 return loadedConfigurations; 643 } 644 645 public void setConfig(PhotonConfiguration config) { 646 this.config = config; 647 } 648}