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.server;
019
020import com.fasterxml.jackson.annotation.JsonProperty;
021import com.fasterxml.jackson.core.JsonProcessingException;
022import com.fasterxml.jackson.databind.ObjectMapper;
023import io.javalin.http.Context;
024import io.javalin.http.UploadedFile;
025import java.io.*;
026import java.nio.file.Files;
027import java.nio.file.Path;
028import java.nio.file.Paths;
029import java.util.ArrayList;
030import java.util.HashMap;
031import java.util.Optional;
032import javax.imageio.ImageIO;
033import org.apache.commons.io.FileUtils;
034import org.opencv.core.Mat;
035import org.opencv.core.MatOfByte;
036import org.opencv.core.MatOfInt;
037import org.opencv.imgcodecs.Imgcodecs;
038import org.photonvision.common.configuration.ConfigManager;
039import org.photonvision.common.configuration.NetworkConfig;
040import org.photonvision.common.configuration.NeuralNetworkModelManager;
041import org.photonvision.common.dataflow.DataChangeDestination;
042import org.photonvision.common.dataflow.DataChangeService;
043import org.photonvision.common.dataflow.events.IncomingWebSocketEvent;
044import org.photonvision.common.dataflow.events.OutgoingUIEvent;
045import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
046import org.photonvision.common.dataflow.websocket.UIPhotonConfiguration;
047import org.photonvision.common.hardware.HardwareManager;
048import org.photonvision.common.hardware.Platform;
049import org.photonvision.common.logging.LogGroup;
050import org.photonvision.common.logging.Logger;
051import org.photonvision.common.networking.NetworkManager;
052import org.photonvision.common.util.ShellExec;
053import org.photonvision.common.util.TimedTaskManager;
054import org.photonvision.common.util.file.JacksonUtils;
055import org.photonvision.common.util.file.ProgramDirectoryUtilities;
056import org.photonvision.vision.calibration.CameraCalibrationCoefficients;
057import org.photonvision.vision.camera.CameraQuirk;
058import org.photonvision.vision.camera.PVCameraInfo;
059import org.photonvision.vision.processes.VisionSourceManager;
060import org.zeroturnaround.zip.ZipUtil;
061
062public class RequestHandler {
063    // Treat all 2XX calls as "INFO"
064    // Treat all 4XX calls as "ERROR"
065    // Treat all 5XX calls as "ERROR"
066
067    private static final Logger logger = new Logger(RequestHandler.class, LogGroup.WebServer);
068
069    private static final ObjectMapper kObjectMapper = new ObjectMapper();
070
071    public static void onSettingsImportRequest(Context ctx) {
072        var file = ctx.uploadedFile("data");
073
074        if (file == null) {
075            ctx.status(400);
076            ctx.result(
077                    "No File was sent with the request. Make sure that the settings zip is sent at the key 'data'");
078            logger.error(
079                    "No File was sent with the request. Make sure that the settings zip is sent at the key 'data'");
080            return;
081        }
082
083        if (!file.extension().contains("zip")) {
084            ctx.status(400);
085            ctx.result(
086                    "The uploaded file was not of type 'zip'. The uploaded file should be a .zip file.");
087            logger.error(
088                    "The uploaded file was not of type 'zip'. The uploaded file should be a .zip file.");
089            return;
090        }
091
092        // Create a temp file
093        var tempFilePath = handleTempFileCreation(file);
094
095        if (tempFilePath.isEmpty()) {
096            ctx.status(500);
097            ctx.result("There was an error while creating a temporary copy of the file");
098            logger.error("There was an error while creating a temporary copy of the file");
099            return;
100        }
101
102        ConfigManager.getInstance().setWriteTaskEnabled(false);
103        ConfigManager.getInstance().disableFlushOnShutdown();
104        // We want to delete the -whole- zip file, so we need to teardown loggers for
105        // now
106        logger.info("Writing new settings zip (logs may be truncated)...");
107        Logger.closeAllLoggers();
108        if (ConfigManager.saveUploadedSettingsZip(tempFilePath.get())) {
109            ctx.status(200);
110            ctx.result("Successfully saved the uploaded settings zip, rebooting...");
111            restartProgram();
112        } else {
113            ctx.status(500);
114            ctx.result("There was an error while saving the uploaded zip file");
115        }
116    }
117
118    public static void onSettingsExportRequest(Context ctx) {
119        logger.info("Exporting Settings to ZIP Archive");
120
121        try {
122            var zip = ConfigManager.getInstance().getSettingsFolderAsZip();
123            var stream = new FileInputStream(zip);
124            logger.info("Uploading settings with size " + stream.available());
125
126            ctx.contentType("application/zip");
127            ctx.header(
128                    "Content-Disposition", "attachment; filename=\"photonvision-settings-export.zip\"");
129
130            ctx.result(stream);
131            ctx.status(200);
132        } catch (IOException e) {
133            logger.error("Unable to export settings archive, bad recode from zip to byte");
134            ctx.status(500);
135            ctx.result("There was an error while exporting the settings archive");
136        }
137    }
138
139    public static void onHardwareConfigRequest(Context ctx) {
140        var file = ctx.uploadedFile("data");
141
142        if (file == null) {
143            ctx.status(400);
144            ctx.result(
145                    "No File was sent with the request. Make sure that the hardware config json is sent at the key 'data'");
146            logger.error(
147                    "No File was sent with the request. Make sure that the hardware config json is sent at the key 'data'");
148            return;
149        }
150
151        if (!file.extension().contains("json")) {
152            ctx.status(400);
153            ctx.result(
154                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
155            logger.error(
156                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
157            return;
158        }
159
160        // Create a temp file
161        var tempFilePath = handleTempFileCreation(file);
162
163        if (tempFilePath.isEmpty()) {
164            ctx.status(500);
165            ctx.result("There was an error while creating a temporary copy of the file");
166            logger.error("There was an error while creating a temporary copy of the file");
167            return;
168        }
169
170        if (ConfigManager.getInstance().saveUploadedHardwareConfig(tempFilePath.get().toPath())) {
171            ctx.status(200);
172            ctx.result("Successfully saved the uploaded hardware config, rebooting...");
173            logger.info("Successfully saved the uploaded hardware config, rebooting...");
174            restartProgram();
175        } else {
176            ctx.status(500);
177            ctx.result("There was an error while saving the uploaded hardware config");
178            logger.error("There was an error while saving the uploaded hardware config");
179        }
180    }
181
182    public static void onHardwareSettingsRequest(Context ctx) {
183        var file = ctx.uploadedFile("data");
184
185        if (file == null) {
186            ctx.status(400);
187            ctx.result(
188                    "No File was sent with the request. Make sure that the hardware settings json is sent at the key 'data'");
189            logger.error(
190                    "No File was sent with the request. Make sure that the hardware settings json is sent at the key 'data'");
191            return;
192        }
193
194        if (!file.extension().contains("json")) {
195            ctx.status(400);
196            ctx.result(
197                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
198            logger.error(
199                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
200            return;
201        }
202
203        // Create a temp file
204        var tempFilePath = handleTempFileCreation(file);
205
206        if (tempFilePath.isEmpty()) {
207            ctx.status(500);
208            ctx.result("There was an error while creating a temporary copy of the file");
209            logger.error("There was an error while creating a temporary copy of the file");
210            return;
211        }
212
213        if (ConfigManager.getInstance().saveUploadedHardwareSettings(tempFilePath.get().toPath())) {
214            ctx.status(200);
215            ctx.result("Successfully saved the uploaded hardware settings, rebooting...");
216            logger.info("Successfully saved the uploaded hardware settings, rebooting...");
217            restartProgram();
218        } else {
219            ctx.status(500);
220            ctx.result("There was an error while saving the uploaded hardware settings");
221            logger.error("There was an error while saving the uploaded hardware settings");
222        }
223    }
224
225    public static void onNetworkConfigRequest(Context ctx) {
226        var file = ctx.uploadedFile("data");
227
228        if (file == null) {
229            ctx.status(400);
230            ctx.result(
231                    "No File was sent with the request. Make sure that the network config json is sent at the key 'data'");
232            logger.error(
233                    "No File was sent with the request. Make sure that the network config json is sent at the key 'data'");
234            return;
235        }
236
237        if (!file.extension().contains("json")) {
238            ctx.status(400);
239            ctx.result(
240                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
241            logger.error(
242                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
243            return;
244        }
245
246        // Create a temp file
247        var tempFilePath = handleTempFileCreation(file);
248
249        if (tempFilePath.isEmpty()) {
250            ctx.status(500);
251            ctx.result("There was an error while creating a temporary copy of the file");
252            logger.error("There was an error while creating a temporary copy of the file");
253            return;
254        }
255
256        if (ConfigManager.getInstance().saveUploadedNetworkConfig(tempFilePath.get().toPath())) {
257            ctx.status(200);
258            ctx.result("Successfully saved the uploaded network config, rebooting...");
259            logger.info("Successfully saved the uploaded network config, rebooting...");
260            restartProgram();
261        } else {
262            ctx.status(500);
263            ctx.result("There was an error while saving the uploaded network config");
264            logger.error("There was an error while saving the uploaded network config");
265        }
266    }
267
268    public static void onAprilTagFieldLayoutRequest(Context ctx) {
269        var file = ctx.uploadedFile("data");
270
271        if (file == null) {
272            ctx.status(400);
273            ctx.result(
274                    "No File was sent with the request. Make sure that the field layout json is sent at the key 'data'");
275            logger.error(
276                    "No File was sent with the request. Make sure that the field layout json is sent at the key 'data'");
277            return;
278        }
279
280        if (!file.extension().contains("json")) {
281            ctx.status(400);
282            ctx.result(
283                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
284            logger.error(
285                    "The uploaded file was not of type 'json'. The uploaded file should be a .json file.");
286            return;
287        }
288
289        // Create a temp file
290        var tempFilePath = handleTempFileCreation(file);
291
292        if (tempFilePath.isEmpty()) {
293            ctx.status(500);
294            ctx.result("There was an error while creating a temporary copy of the file");
295            logger.error("There was an error while creating a temporary copy of the file");
296            return;
297        }
298
299        if (ConfigManager.getInstance().saveUploadedAprilTagFieldLayout(tempFilePath.get().toPath())) {
300            ctx.status(200);
301            ctx.result("Successfully saved the uploaded AprilTagFieldLayout, rebooting...");
302            logger.info("Successfully saved the uploaded AprilTagFieldLayout, rebooting...");
303            restartProgram();
304        } else {
305            ctx.status(500);
306            ctx.result("There was an error while saving the uploaded AprilTagFieldLayout");
307            logger.error("There was an error while saving the uploaded AprilTagFieldLayout");
308        }
309    }
310
311    public static void onOfflineUpdateRequest(Context ctx) {
312        var file = ctx.uploadedFile("jarData");
313
314        if (file == null) {
315            ctx.status(400);
316            ctx.result(
317                    "No File was sent with the request. Make sure that the new jar is sent at the key 'jarData'");
318            logger.error(
319                    "No File was sent with the request. Make sure that the new jar is sent at the key 'jarData'");
320            return;
321        }
322
323        if (!file.extension().contains("jar")) {
324            ctx.status(400);
325            ctx.result(
326                    "The uploaded file was not of type 'jar'. The uploaded file should be a .jar file.");
327            logger.error(
328                    "The uploaded file was not of type 'jar'. The uploaded file should be a .jar file.");
329            return;
330        }
331
332        try {
333            Path filePath =
334                    Paths.get(ProgramDirectoryUtilities.getProgramDirectory(), "photonvision.jar");
335            File targetFile = new File(filePath.toString());
336            var stream = new FileOutputStream(targetFile);
337
338            file.content().transferTo(stream);
339            stream.close();
340
341            ctx.status(200);
342            ctx.result(
343                    "Offline update successfully complete. PhotonVision will restart in the background.");
344            logger.info(
345                    "Offline update successfully complete. PhotonVision will restart in the background.");
346            restartProgram();
347        } catch (FileNotFoundException e) {
348            ctx.result("The current program jar file couldn't be found.");
349            ctx.status(500);
350            logger.error("The current program jar file couldn't be found.", e);
351        } catch (IOException e) {
352            ctx.result("Unable to overwrite the existing program with the new program.");
353            ctx.status(500);
354            logger.error("Unable to overwrite the existing program with the new program.", e);
355        }
356    }
357
358    public static void onGeneralSettingsRequest(Context ctx) {
359        NetworkConfig config;
360        try {
361            config = kObjectMapper.readValue(ctx.bodyInputStream(), NetworkConfig.class);
362
363            ctx.status(200);
364            ctx.result("Successfully saved general settings");
365            logger.info("Successfully saved general settings");
366        } catch (IOException e) {
367            // If the settings can't be parsed, use the default network settings
368            config = new NetworkConfig();
369
370            ctx.status(400);
371            ctx.result("The provided general settings were malformed");
372            logger.error("The provided general settings were malformed", e);
373        }
374
375        ConfigManager.getInstance().setNetworkSettings(config);
376        ConfigManager.getInstance().requestSave();
377
378        NetworkManager.getInstance().reinitialize();
379
380        NetworkTablesManager.getInstance().setConfig(config);
381    }
382
383    public static class UICameraSettingsRequest {
384        @JsonProperty("fov")
385        double fov;
386
387        @JsonProperty("quirksToChange")
388        HashMap<CameraQuirk, Boolean> quirksToChange;
389    }
390
391    public static void onCameraSettingsRequest(Context ctx) {
392        try {
393            var data = kObjectMapper.readTree(ctx.bodyInputStream());
394
395            String cameraUniqueName = data.get("cameraUniqueName").asText();
396            var settings =
397                    JacksonUtils.deserialize(data.get("settings").toString(), UICameraSettingsRequest.class);
398            var fov = settings.fov;
399
400            logger.info("Changing camera FOV to: " + fov);
401            logger.info("Changing quirks to: " + settings.quirksToChange.toString());
402
403            var module = VisionSourceManager.getInstance().vmm.getModule(cameraUniqueName);
404            module.setFov(fov);
405            module.changeCameraQuirks(settings.quirksToChange);
406
407            module.saveModule();
408
409            ctx.status(200);
410            ctx.result("Successfully saved camera settings");
411            logger.info("Successfully saved camera settings");
412        } catch (NullPointerException | IOException e) {
413            ctx.status(400);
414            ctx.result("The provided camera settings were malformed");
415            logger.error("The provided camera settings were malformed", e);
416        }
417    }
418
419    public static void onLogExportRequest(Context ctx) {
420        if (!Platform.isLinux()) {
421            ctx.status(405);
422            ctx.result("Logs can only be exported on a Linux platform");
423            // INFO only log because this isn't ERROR worthy
424            logger.info("Logs can only be exported on a Linux platform");
425            return;
426        }
427
428        try {
429            ShellExec shell = new ShellExec();
430            var tempPath = Files.createTempFile("photonvision-journalctl", ".txt");
431            var tempPath2 = Files.createTempFile("photonvision-kernelogs", ".txt");
432            // In the command below:
433            // dmesg = output all kernel logs since current boot
434            // cat /var/log/kern.log = output all kernel logs since first boot
435            shell.executeBashCommand(
436                    "journalctl -u photonvision.service > "
437                            + tempPath.toAbsolutePath()
438                            + " && dmesg > "
439                            + tempPath2.toAbsolutePath());
440
441            while (!shell.isOutputCompleted()) {
442                // TODO: add timeout
443            }
444
445            if (shell.getExitCode() == 0) {
446                // Wrote to the temp file! Zip and yeet it to the client
447
448                var out = Files.createTempFile("photonvision-logs", "zip").toFile();
449
450                try {
451                    ZipUtil.packEntries(new File[] {tempPath.toFile(), tempPath2.toFile()}, out);
452                } catch (Exception e) {
453                    e.printStackTrace();
454                }
455
456                var stream = new FileInputStream(out);
457                ctx.contentType("application/zip");
458                ctx.header("Content-Disposition", "attachment; filename=\"photonvision-logs.zip\"");
459                ctx.result(stream);
460                ctx.status(200);
461                logger.info("Outputting log ZIP with size " + stream.available());
462            } else {
463                ctx.status(500);
464                ctx.result("The journalctl service was unable to export logs");
465                logger.error("The journalctl service was unable to export logs");
466            }
467        } catch (IOException e) {
468            ctx.status(500);
469            ctx.result("There was an error while exporting journalctl logs");
470            logger.error("There was an error while exporting journalctl logs", e);
471        }
472    }
473
474    public static void onCalibrationEndRequest(Context ctx) {
475        logger.info("Calibrating camera! This will take a long time...");
476
477        String cameraUniqueName;
478
479        try {
480            cameraUniqueName =
481                    kObjectMapper.readTree(ctx.bodyInputStream()).get("cameraUniqueName").asText();
482
483            var calData =
484                    VisionSourceManager.getInstance().vmm.getModule(cameraUniqueName).endCalibration();
485            if (calData == null) {
486                ctx.result("The calibration process failed");
487                ctx.status(500);
488                logger.error(
489                        "The calibration process failed. Calibration data for module at cameraUniqueName ("
490                                + cameraUniqueName
491                                + ") was null");
492                return;
493            }
494
495            ctx.result("Camera calibration successfully completed!");
496            ctx.status(200);
497            logger.info("Camera calibration successfully completed!");
498        } catch (JsonProcessingException e) {
499            ctx.status(400);
500            ctx.result(
501                    "The 'cameraUniqueName' field was not found in the request. Please make sure the cameraUniqueName of the vision module is specified with the 'cameraUniqueName' key.");
502            logger.error(
503                    "The 'cameraUniqueName' field was not found in the request. Please make sure the cameraUniqueName of the vision module is specified with the 'cameraUniqueName' key.",
504                    e);
505        } catch (Exception e) {
506            ctx.status(500);
507            ctx.result("There was an error while ending calibration");
508            logger.error("There was an error while ending calibration", e);
509        }
510    }
511
512    public static void onDataCalibrationImportRequest(Context ctx) {
513        try {
514            var data = kObjectMapper.readTree(ctx.bodyInputStream());
515
516            String cameraUniqueName = data.get("cameraUniqueName").asText();
517            var coeffs =
518                    kObjectMapper.convertValue(data.get("calibration"), CameraCalibrationCoefficients.class);
519
520            var uploadCalibrationEvent =
521                    new IncomingWebSocketEvent<>(
522                            DataChangeDestination.DCD_ACTIVEMODULE,
523                            "calibrationUploaded",
524                            coeffs,
525                            cameraUniqueName,
526                            null);
527            DataChangeService.getInstance().publishEvent(uploadCalibrationEvent);
528
529            ctx.status(200);
530            ctx.result("Calibration imported successfully from imported data!");
531            logger.info("Calibration imported successfully from imported data!");
532        } catch (JsonProcessingException e) {
533            ctx.status(400);
534            ctx.result("The provided calibration data was malformed");
535            logger.error("The provided calibration data was malformed", e);
536
537        } catch (Exception e) {
538            ctx.status(500);
539            ctx.result("An error occurred while uploading calibration data");
540            logger.error("An error occurred while uploading calibration data", e);
541        }
542    }
543
544    public static void onProgramRestartRequest(Context ctx) {
545        // TODO, check if this was successful or not
546        ctx.status(204);
547        restartProgram();
548    }
549
550    public static void onImportObjectDetectionModelRequest(Context ctx) {
551        try {
552            // Retrieve the uploaded files
553            var modelFile = ctx.uploadedFile("rknn");
554            var labelsFile = ctx.uploadedFile("labels");
555
556            if (modelFile == null || labelsFile == null) {
557                ctx.status(400);
558                ctx.result(
559                        "No File was sent with the request. Make sure that the model and labels files are sent at the keys 'rknn' and 'labels'");
560                logger.error(
561                        "No File was sent with the request. Make sure that the model and labels files are sent at the keys 'rknn' and 'labels'");
562                return;
563            }
564
565            if (!modelFile.extension().contains("rknn") || !labelsFile.extension().contains("txt")) {
566                ctx.status(400);
567                ctx.result(
568                        "The uploaded files were not of type 'rknn' and 'txt'. The uploaded files should be a .rknn and .txt file.");
569                logger.error(
570                        "The uploaded files were not of type 'rknn' and 'txt'. The uploaded files should be a .rknn and .txt file.");
571                return;
572            }
573
574            // verify naming convention
575
576            // throws IllegalArgumentException if the model name is invalid
577            NeuralNetworkModelManager.verifyRKNNNames(modelFile.filename(), labelsFile.filename());
578
579            // TODO move into neural network manager
580
581            var modelPath =
582                    Paths.get(
583                            ConfigManager.getInstance().getModelsDirectory().toString(), modelFile.filename());
584            var labelsPath =
585                    Paths.get(
586                            ConfigManager.getInstance().getModelsDirectory().toString(), labelsFile.filename());
587
588            try (FileOutputStream out = new FileOutputStream(modelPath.toFile())) {
589                modelFile.content().transferTo(out);
590            }
591
592            try (FileOutputStream out = new FileOutputStream(labelsPath.toFile())) {
593                labelsFile.content().transferTo(out);
594            }
595
596            NeuralNetworkModelManager.getInstance()
597                    .discoverModels(ConfigManager.getInstance().getModelsDirectory());
598
599            ctx.status(200).result("Successfully uploaded object detection model");
600        } catch (Exception e) {
601            ctx.status(500).result("Error processing files: " + e.getMessage());
602        }
603
604        DataChangeService.getInstance()
605                .publishEvent(
606                        new OutgoingUIEvent<>(
607                                "fullsettings",
608                                UIPhotonConfiguration.programStateToUi(ConfigManager.getInstance().getConfig())));
609    }
610
611    public static void onDeviceRestartRequest(Context ctx) {
612        ctx.status(HardwareManager.getInstance().restartDevice() ? 204 : 500);
613    }
614
615    public static void onCameraNicknameChangeRequest(Context ctx) {
616        try {
617            var data = kObjectMapper.readTree(ctx.bodyInputStream());
618
619            String name = data.get("name").asText();
620            String cameraUniqueName = data.get("cameraUniqueName").asText();
621
622            VisionSourceManager.getInstance().vmm.getModule(cameraUniqueName).setCameraNickname(name);
623            ctx.status(200);
624            ctx.result("Successfully changed the camera name to: " + name);
625            logger.info("Successfully changed the camera name to: " + name);
626        } catch (JsonProcessingException e) {
627            ctx.status(400);
628            ctx.result("The provided nickname data was malformed");
629            logger.error("The provided nickname data was malformed", e);
630
631        } catch (Exception e) {
632            ctx.status(500);
633            ctx.result("An error occurred while changing the camera's nickname");
634            logger.error("An error occurred while changing the camera's nickname", e);
635        }
636    }
637
638    public static void onMetricsPublishRequest(Context ctx) {
639        HardwareManager.getInstance().publishMetrics();
640        ctx.status(204);
641    }
642
643    public static void onCalibrationSnapshotRequest(Context ctx) {
644        logger.info(ctx.queryString().toString());
645
646        String cameraUniqueName = ctx.queryParam("cameraUniqueName");
647        var width = Integer.parseInt(ctx.queryParam("width"));
648        var height = Integer.parseInt(ctx.queryParam("height"));
649        var observationIdx = Integer.parseInt(ctx.queryParam("snapshotIdx"));
650
651        CameraCalibrationCoefficients calList =
652                VisionSourceManager.getInstance()
653                        .vmm
654                        .getModule(cameraUniqueName)
655                        .getStateAsCameraConfig()
656                        .calibrations
657                        .stream()
658                        .filter(
659                                it ->
660                                        Math.abs(it.unrotatedImageSize.width - width) < 1e-4
661                                                && Math.abs(it.unrotatedImageSize.height - height) < 1e-4)
662                        .findFirst()
663                        .orElse(null);
664
665        if (calList == null || calList.observations.size() < observationIdx) {
666            ctx.status(404);
667            return;
668        }
669
670        // encode as jpeg to save even more space. reduces size of a 1280p image from
671        // 300k to 25k
672        var jpegBytes = new MatOfByte();
673        Mat img = null;
674        try {
675            img =
676                    Imgcodecs.imread(
677                            calList.observations.get(observationIdx).snapshotDataLocation.toString());
678        } catch (Exception e) {
679            ctx.status(500);
680            ctx.result("Unable to read calibration image");
681            return;
682        }
683        if (img == null || img.empty()) {
684            ctx.status(500);
685            ctx.result("Unable to read calibration image");
686            return;
687        }
688
689        Imgcodecs.imencode(".jpg", img, jpegBytes, new MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, 60));
690
691        ctx.result(jpegBytes.toArray());
692        jpegBytes.release();
693
694        ctx.status(200);
695    }
696
697    public static void onCalibrationExportRequest(Context ctx) {
698        logger.info(ctx.queryString().toString());
699
700        String cameraUniqueName = ctx.queryParam("cameraUniqueName");
701        var width = Integer.parseInt(ctx.queryParam("width"));
702        var height = Integer.parseInt(ctx.queryParam("height"));
703
704        var cc =
705                VisionSourceManager.getInstance().vmm.getModule(cameraUniqueName).getStateAsCameraConfig();
706
707        CameraCalibrationCoefficients calList =
708                cc.calibrations.stream()
709                        .filter(
710                                it ->
711                                        Math.abs(it.unrotatedImageSize.width - width) < 1e-4
712                                                && Math.abs(it.unrotatedImageSize.height - height) < 1e-4)
713                        .findFirst()
714                        .orElse(null);
715
716        if (calList == null) {
717            ctx.status(404);
718            return;
719        }
720
721        var filename = "photon_calibration_" + cc.uniqueName + "_" + width + "x" + height + ".json";
722        ctx.contentType("application/zip");
723        ctx.header("Content-Disposition", "attachment; filename=\"" + filename + "\"");
724        ctx.json(calList);
725
726        ctx.status(200);
727    }
728
729    public static void onImageSnapshotsRequest(Context ctx) {
730        var snapshots = new ArrayList<HashMap<String, Object>>();
731        var cameraDirs = ConfigManager.getInstance().getImageSavePath().toFile().listFiles();
732
733        if (cameraDirs != null) {
734            try {
735                for (File cameraDir : cameraDirs) {
736                    var cameraSnapshots = cameraDir.listFiles();
737                    if (cameraSnapshots == null) continue;
738
739                    String cameraUniqueName = cameraDir.getName();
740
741                    for (File snapshot : cameraSnapshots) {
742                        var snapshotData = new HashMap<String, Object>();
743
744                        var bufferedImage = ImageIO.read(snapshot);
745                        var buffer = new ByteArrayOutputStream();
746                        ImageIO.write(bufferedImage, "jpg", buffer);
747                        byte[] data = buffer.toByteArray();
748
749                        snapshotData.put("snapshotName", snapshot.getName());
750                        snapshotData.put("cameraUniqueName", cameraUniqueName);
751                        snapshotData.put("snapshotData", data);
752
753                        snapshots.add(snapshotData);
754                    }
755                }
756            } catch (IOException e) {
757                ctx.status(500);
758                ctx.result("Unable to read saved images");
759            }
760        }
761
762        ctx.status(200);
763        ctx.json(snapshots);
764    }
765
766    public static void onCameraCalibImagesRequest(Context ctx) {
767        try {
768            HashMap<String, HashMap<String, ArrayList<HashMap<String, Object>>>> snapshots =
769                    new HashMap<>();
770
771            var cameraDirs = ConfigManager.getInstance().getCalibDir().toFile().listFiles();
772            if (cameraDirs != null) {
773                var camData = new HashMap<String, ArrayList<HashMap<String, Object>>>();
774                for (var cameraDir : cameraDirs) {
775                    var resolutionDirs = cameraDir.listFiles();
776                    if (resolutionDirs == null) continue;
777                    for (var resolutionDir : resolutionDirs) {
778                        var calibImages = resolutionDir.listFiles();
779                        if (calibImages == null) continue;
780                        var resolutionImages = new ArrayList<HashMap<String, Object>>();
781                        for (var calibImg : calibImages) {
782                            var snapshotData = new HashMap<String, Object>();
783
784                            var bufferedImage = ImageIO.read(calibImg);
785                            var buffer = new ByteArrayOutputStream();
786                            ImageIO.write(bufferedImage, "png", buffer);
787                            byte[] data = buffer.toByteArray();
788
789                            snapshotData.put("snapshotData", data);
790                            snapshotData.put("snapshotFilename", calibImg.getName());
791
792                            resolutionImages.add(snapshotData);
793                        }
794                        camData.put(resolutionDir.getName(), resolutionImages);
795                    }
796
797                    var cameraName = cameraDir.getName();
798                    snapshots.put(cameraName, camData);
799                }
800            }
801
802            ctx.json(snapshots);
803        } catch (Exception e) {
804            ctx.status(500);
805            ctx.result("An error occurred while getting calib data");
806            logger.error("An error occurred while getting calib data", e);
807        }
808    }
809
810    /**
811     * Create a temporary file using the UploadedFile from Javalin.
812     *
813     * @param file the uploaded file.
814     * @return Temporary file. Empty if the temporary file was unable to be created.
815     */
816    private static Optional<File> handleTempFileCreation(UploadedFile file) {
817        var tempFilePath =
818                new File(Path.of(System.getProperty("java.io.tmpdir"), file.filename()).toString());
819        boolean makeDirsRes = tempFilePath.getParentFile().mkdirs();
820
821        if (!makeDirsRes && !(tempFilePath.getParentFile().exists())) {
822            logger.error(
823                    "There was an error while creating "
824                            + tempFilePath.getAbsolutePath()
825                            + "! Exists: "
826                            + tempFilePath.getParentFile().exists());
827            return Optional.empty();
828        }
829
830        try {
831            FileUtils.copyInputStreamToFile(file.content(), tempFilePath);
832        } catch (IOException e) {
833            logger.error(
834                    "There was an error while copying "
835                            + file.filename()
836                            + " to the temp file "
837                            + tempFilePath.getAbsolutePath());
838            return Optional.empty();
839        }
840
841        return Optional.of(tempFilePath);
842    }
843
844    /**
845     * Restart the running program. Note that this doesn't actually restart the program itself,
846     * instead, it relies on systemd or an equivalent.
847     */
848    private static void restartProgram() {
849        TimedTaskManager.getInstance()
850                .addOneShotTask(
851                        () -> {
852                            if (Platform.isLinux()) {
853                                try {
854                                    new ShellExec().executeBashCommand("systemctl restart photonvision.service");
855                                } catch (IOException e) {
856                                    logger.error("Could not restart device!", e);
857                                    System.exit(0);
858                                }
859                            } else {
860                                System.exit(0);
861                            }
862                        },
863                        0);
864    }
865
866    public static void onNukeConfigDirectory(Context ctx) {
867        ConfigManager.getInstance().setWriteTaskEnabled(false);
868        ConfigManager.getInstance().disableFlushOnShutdown();
869
870        Logger.closeAllLoggers();
871        if (ConfigManager.nukeConfigDirectory()) {
872            ctx.status(200);
873            ctx.result("Successfully nuked config dir");
874            restartProgram();
875        } else {
876            ctx.status(500);
877            ctx.result("There was an error while nuking the config directory");
878        }
879    }
880
881    public static void onNukeOneCamera(Context ctx) {
882        try {
883            var payload = kObjectMapper.readTree(ctx.bodyInputStream());
884            var name = payload.get("cameraUniqueName").asText();
885            logger.warn("Deleting camera name " + name);
886
887            var cameraDir = ConfigManager.getInstance().getCalibrationImageSavePath(name).toFile();
888            if (cameraDir.exists()) {
889                FileUtils.deleteDirectory(cameraDir);
890            }
891
892            VisionSourceManager.getInstance().deleteVisionSource(name);
893
894            ctx.status(200);
895        } catch (IOException e) {
896            // todo
897            logger.error("asdf", e);
898            ctx.status(500);
899        }
900    }
901
902    public static void onActivateMatchedCameraRequest(Context ctx) {
903        logger.info(ctx.queryString().toString());
904
905        String cameraUniqueName = ctx.queryParam("cameraUniqueName");
906
907        if (VisionSourceManager.getInstance().reactivateDisabledCameraConfig(cameraUniqueName)) {
908            ctx.status(200);
909        } else {
910            ctx.status(403);
911        }
912
913        ctx.result("Successfully assigned camera with unique name: " + cameraUniqueName);
914    }
915
916    public static void onAssignUnmatchedCameraRequest(Context ctx) {
917        logger.info(ctx.queryString().toString());
918
919        PVCameraInfo camera;
920        try {
921            camera = JacksonUtils.deserialize(ctx.queryParam("cameraInfo"), PVCameraInfo.class);
922        } catch (IOException e) {
923            ctx.status(401);
924            return;
925        }
926
927        if (VisionSourceManager.getInstance().assignUnmatchedCamera(camera)) {
928            ctx.status(200);
929        } else {
930            ctx.status(404);
931        }
932
933        ctx.result("Successfully assigned camera: " + camera);
934    }
935
936    public static void onUnassignCameraRequest(Context ctx) {
937        logger.info(ctx.queryString().toString());
938
939        String cameraUniqueName = ctx.queryParam("cameraUniqueName");
940
941        if (VisionSourceManager.getInstance().deactivateVisionSource(cameraUniqueName)) {
942            ctx.status(200);
943        } else {
944            ctx.status(403);
945        }
946
947        ctx.status(200);
948
949        ctx.result("Successfully assigned camera with unique name: " + cameraUniqueName);
950    }
951}