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}