/* * Copyright (C) Photon Vision. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.photonvision.server; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.javalin.http.Context; import io.javalin.http.UploadedFile; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; import org.apache.commons.io.FileUtils; import org.photonvision.common.configuration.ConfigManager; import org.photonvision.common.configuration.NetworkConfig; import org.photonvision.common.dataflow.DataChangeDestination; import org.photonvision.common.dataflow.DataChangeService; import org.photonvision.common.dataflow.events.IncomingWebSocketEvent; import org.photonvision.common.dataflow.networktables.NetworkTablesManager; import org.photonvision.common.hardware.HardwareManager; import org.photonvision.common.hardware.Platform; import org.photonvision.common.logging.LogGroup; import org.photonvision.common.logging.Logger; import org.photonvision.common.networking.NetworkManager; import org.photonvision.common.util.ShellExec; import org.photonvision.common.util.TimedTaskManager; import org.photonvision.common.util.file.ProgramDirectoryUtilities; import org.photonvision.vision.calibration.CameraCalibrationCoefficients; import org.photonvision.vision.processes.VisionModuleManager; public class RequestHandler { // Treat all 2XX calls as "INFO" // Treat all 4XX calls as "ERROR" // Treat all 5XX calls as "ERROR" private static final Logger logger = new Logger(RequestHandler.class, LogGroup.WebServer); private static final ObjectMapper kObjectMapper = new ObjectMapper(); public static void onSettingsImportRequest(Context ctx) { var file = ctx.uploadedFile("data"); if (file == null) { ctx.status(400); ctx.result( "No File was sent with the request. Make sure that the settings zip is sent at the key 'data'"); logger.error( "No File was sent with the request. Make sure that the settings zip is sent at the key 'data'"); return; } if (!file.getExtension().contains("zip")) { ctx.status(400); ctx.result( "The uploaded file was not of type 'zip'. The uploaded file should be a .zip file."); logger.error( "The uploaded file was not of type 'zip'. The uploaded file should be a .zip file."); return; } // Create a temp file var tempFilePath = handleTempFileCreation(file); if (tempFilePath.isEmpty()) { ctx.status(500); ctx.result("There was an error while creating a temporary copy of the file"); logger.error("There was an error while creating a temporary copy of the file"); return; } if (ConfigManager.saveUploadedSettingsZip(tempFilePath.get())) { ctx.status(200); ctx.result("Successfully saved the uploaded settings zip"); logger.info("Successfully saved the uploaded settings zip"); } else { ctx.status(500); ctx.result("There was an error while saving the uploaded zip file"); logger.error("There was an error while saving the uploaded zip file"); } } public static void onSettingsExportRequest(Context ctx) { logger.info("Exporting Settings to ZIP Archive"); try { var zip = ConfigManager.getInstance().getSettingsFolderAsZip(); var stream = new FileInputStream(zip); logger.info("Uploading settings with size " + stream.available()); ctx.contentType("application/zip"); ctx.header( "Content-Disposition", "attachment; filename=\"photonvision-settings-export.zip\""); ctx.result(stream); ctx.status(200); } catch (IOException e) { logger.error("Unable to export settings archive, bad recode from zip to byte"); ctx.status(500); ctx.result("There was an error while exporting the settings archive"); } } public static void onHardwareConfigRequest(Context ctx) { var file = ctx.uploadedFile("data"); if (file == null) { ctx.status(400); ctx.result( "No File was sent with the request. Make sure that the hardware config json is sent at the key 'data'"); logger.error( "No File was sent with the request. Make sure that the hardware config json is sent at the key 'data'"); return; } if (!file.getExtension().contains("json")) { ctx.status(400); ctx.result( "The uploaded file was not of type 'json'. The uploaded file should be a .json file."); logger.error( "The uploaded file was not of type 'json'. The uploaded file should be a .json file."); return; } // Create a temp file var tempFilePath = handleTempFileCreation(file); if (tempFilePath.isEmpty()) { ctx.status(500); ctx.result("There was an error while creating a temporary copy of the file"); logger.error("There was an error while creating a temporary copy of the file"); return; } if (ConfigManager.getInstance().saveUploadedHardwareConfig(tempFilePath.get().toPath())) { ctx.status(200); ctx.result("Successfully saved the uploaded hardware config"); logger.info("Successfully saved the uploaded hardware config"); } else { ctx.status(500); ctx.result("There was an error while saving the uploaded hardware config"); logger.error("There was an error while saving the uploaded hardware config"); } } public static void onHardwareSettingsRequest(Context ctx) { var file = ctx.uploadedFile("data"); if (file == null) { ctx.status(400); ctx.result( "No File was sent with the request. Make sure that the hardware settings json is sent at the key 'data'"); logger.error( "No File was sent with the request. Make sure that the hardware settings json is sent at the key 'data'"); return; } if (!file.getExtension().contains("json")) { ctx.status(400); ctx.result( "The uploaded file was not of type 'json'. The uploaded file should be a .json file."); logger.error( "The uploaded file was not of type 'json'. The uploaded file should be a .json file."); return; } // Create a temp file var tempFilePath = handleTempFileCreation(file); if (tempFilePath.isEmpty()) { ctx.status(500); ctx.result("There was an error while creating a temporary copy of the file"); logger.error("There was an error while creating a temporary copy of the file"); return; } if (ConfigManager.getInstance().saveUploadedHardwareSettings(tempFilePath.get().toPath())) { ctx.status(200); ctx.result("Successfully saved the uploaded hardware settings"); logger.info("Successfully saved the uploaded hardware settings"); } else { ctx.status(500); ctx.result("There was an error while saving the uploaded hardware settings"); logger.error("There was an error while saving the uploaded hardware settings"); } } public static void onNetworkConfigRequest(Context ctx) { var file = ctx.uploadedFile("data"); if (file == null) { ctx.status(400); ctx.result( "No File was sent with the request. Make sure that the network config json is sent at the key 'data'"); logger.error( "No File was sent with the request. Make sure that the network config json is sent at the key 'data'"); return; } if (!file.getExtension().contains("json")) { ctx.status(400); ctx.result( "The uploaded file was not of type 'json'. The uploaded file should be a .json file."); logger.error( "The uploaded file was not of type 'json'. The uploaded file should be a .json file."); return; } // Create a temp file var tempFilePath = handleTempFileCreation(file); if (tempFilePath.isEmpty()) { ctx.status(500); ctx.result("There was an error while creating a temporary copy of the file"); logger.error("There was an error while creating a temporary copy of the file"); return; } if (ConfigManager.getInstance().saveUploadedNetworkConfig(tempFilePath.get().toPath())) { ctx.status(200); ctx.result("Successfully saved the uploaded network config"); logger.info("Successfully saved the uploaded network config"); } else { ctx.status(500); ctx.result("There was an error while saving the uploaded network config"); logger.error("There was an error while saving the uploaded network config"); } } public static void onOfflineUpdateRequest(Context ctx) { var file = ctx.uploadedFile("jarData"); if (file == null) { ctx.status(400); ctx.result( "No File was sent with the request. Make sure that the new jar is sent at the key 'jarData'"); logger.error( "No File was sent with the request. Make sure that the new jar is sent at the key 'jarData'"); return; } if (!file.getExtension().contains("jar")) { ctx.status(400); ctx.result( "The uploaded file was not of type 'jar'. The uploaded file should be a .jar file."); logger.error( "The uploaded file was not of type 'jar'. The uploaded file should be a .jar file."); return; } try { Path filePath = Paths.get(ProgramDirectoryUtilities.getProgramDirectory(), "photonvision.jar"); File targetFile = new File(filePath.toString()); var stream = new FileOutputStream(targetFile); file.getContent().transferTo(stream); stream.close(); ctx.status(200); ctx.result( "Offline update successfully complete. PhotonVision will restart in the background."); logger.info( "Offline update successfully complete. PhotonVision will restart in the background."); restartProgram(); } catch (FileNotFoundException e) { ctx.result("The current program jar file couldn't be found."); ctx.status(500); logger.error("The current program jar file couldn't be found.", e); } catch (IOException e) { ctx.result("Unable to overwrite the existing program with the new program."); ctx.status(500); logger.error("Unable to overwrite the existing program with the new program.", e); } } public static void onGeneralSettingsRequest(Context ctx) { NetworkConfig config; try { config = kObjectMapper.readValue(ctx.body(), NetworkConfig.class); ctx.status(200); ctx.result("Successfully saved general settings"); logger.info("Successfully saved general settings"); } catch (JsonProcessingException e) { // If the settings can't be parsed, use the default network settings config = new NetworkConfig(); ctx.status(400); ctx.result("The provided general settings were malformed"); logger.error("The provided general settings were malformed", e); } ConfigManager.getInstance().setNetworkSettings(config); ConfigManager.getInstance().requestSave(); NetworkManager.getInstance().reinitialize(); NetworkTablesManager.getInstance().setConfig(config); } public static void onCameraSettingsRequest(Context ctx) { try { var data = kObjectMapper.readTree(ctx.body()); int index = data.get("index").asInt(); double fov = kObjectMapper.readTree(data.get("settings").asText()).get("fov").asDouble(); var module = VisionModuleManager.getInstance().getModule(index); module.setFov(fov); module.saveModule(); ctx.status(200); ctx.result("Successfully saved camera settings"); logger.info("Successfully saved camera settings"); } catch (JsonProcessingException e) { ctx.status(400); ctx.result("The provided camera settings were malformed"); logger.error("The provided camera settings were malformed", e); } } public static void onLogExportRequest(Context ctx) { if (!Platform.isLinux()) { ctx.status(405); ctx.result("Logs can only be exported on a Linux platform"); // INFO only log because this isn't ERROR worthy logger.info("Logs can only be exported on a Linux platform"); return; } try { ShellExec shell = new ShellExec(); var tempPath = Files.createTempFile("photonvision-journalctl", ".txt"); shell.executeBashCommand("journalctl -u photonvision.service > " + tempPath.toAbsolutePath()); while (!shell.isOutputCompleted()) { // TODO: add timeout } if (shell.getExitCode() == 0) { // Wrote to the temp file! Add it to the ctx var stream = new FileInputStream(tempPath.toFile()); ctx.contentType("text/plain"); ctx.header("Content-Disposition", "attachment; filename=\"photonvision-journalctl.txt\""); ctx.status(200); ctx.result(stream); logger.info("Uploading settings with size " + stream.available()); } else { ctx.status(500); ctx.result("The journalctl service was unable to export logs"); logger.error("The journalctl service was unable to export logs"); } } catch (IOException e) { ctx.status(500); ctx.result("There was an error while exporting journactl logs"); logger.error("There was an error while exporting journactl logs", e); } } public static void onCalibrationEndRequest(Context ctx) { logger.info("Calibrating camera! This will take a long time..."); int index; try { index = kObjectMapper.readTree(ctx.body()).get("index").asInt(); var calData = VisionModuleManager.getInstance().getModule(index).endCalibration(); if (calData == null) { ctx.result("The calibration process failed"); ctx.status(500); logger.error( "The calibration process failed. Calibration data for module at index (" + index + ") was null"); return; } ctx.result("Camera calibration successfully completed!"); ctx.status(200); logger.info("Camera calibration successfully completed!"); } catch (JsonProcessingException e) { ctx.status(400); ctx.result( "The 'index' field was not found in the request. Please make sure the index of the vision module is specified with the 'index' key."); logger.error( "The 'index' field was not found in the request. Please make sure the index of the vision module is specified with the 'index' key.", e); } catch (Exception e) { ctx.status(500); ctx.result("There was an error while ending calibration"); logger.error("There was an error while ending calibration", e); } } public static void onCalibrationImportRequest(Context ctx) { var data = ctx.body(); try { var actualObj = kObjectMapper.readTree(data); int cameraIndex = actualObj.get("cameraIndex").asInt(); var payload = kObjectMapper.readTree(actualObj.get("payload").asText()); var coeffs = CameraCalibrationCoefficients.parseFromCalibdbJson(payload); var uploadCalibrationEvent = new IncomingWebSocketEvent<>( DataChangeDestination.DCD_ACTIVEMODULE, "calibrationUploaded", coeffs, cameraIndex, null); DataChangeService.getInstance().publishEvent(uploadCalibrationEvent); ctx.status(200); ctx.result("Calibration imported successfully from CalibDB data!"); logger.info("Calibration imported successfully from CalibDB data!"); } catch (JsonProcessingException e) { ctx.status(400); ctx.result( "The Provided CalibDB data is malformed and cannot be parsed for the required fields."); logger.error( "The Provided CalibDB data is malformed and cannot be parsed for the required fields.", e); } } public static void onProgramRestartRequest(Context ctx) { // TODO, check if this was successful or not ctx.status(204); restartProgram(); } public static void onDeviceRestartRequest(Context ctx) { ctx.status(HardwareManager.getInstance().restartDevice() ? 204 : 500); } public static void onCameraNicknameChangeRequest(Context ctx) { try { var data = kObjectMapper.readTree(ctx.body()); String name = data.get("name").asText(); int idx = data.get("cameraIndex").asInt(); VisionModuleManager.getInstance().getModule(idx).setCameraNickname(name); ctx.status(200); ctx.result("Successfully changed the camera name to: " + name); logger.info("Successfully changed the camera name to: " + name); } catch (JsonProcessingException e) { ctx.status(400); ctx.result("The provided nickname data was malformed"); logger.error("The provided nickname data was malformed", e); } catch (Exception e) { ctx.status(500); ctx.result("An error occurred while changing the camera's nickname"); logger.error("An error occurred while changing the camera's nickname", e); } } public static void onMetricsPublishRequest(Context ctx) { HardwareManager.getInstance().publishMetrics(); ctx.status(204); } /** * Create a temporary file using the UploadedFile from Javalin. * * @param file the uploaded file. * @return Temporary file. Empty if the temporary file was unable to be created. */ private static Optional handleTempFileCreation(UploadedFile file) { var tempFilePath = new File(Path.of(System.getProperty("java.io.tmpdir"), file.getFilename()).toString()); tempFilePath.getParentFile().mkdirs(); try { FileUtils.copyInputStreamToFile(file.getContent(), tempFilePath); } catch (IOException e) { logger.error( "There was an error while uploading " + file.getFilename() + " to the temp folder!"); return Optional.empty(); } return Optional.of(tempFilePath); } /** * Restart the running program. Note that this doesn't actually restart the program itself, * instead, it relies on systemd or an equivalent. */ private static void restartProgram() { TimedTaskManager.getInstance() .addOneShotTask( () -> { if (Platform.isLinux()) { try { new ShellExec().executeBashCommand("systemctl restart photonvision.service"); } catch (IOException e) { logger.error("Could not restart device!", e); System.exit(0); } } else { System.exit(0); } }, 0); } }