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}