diff --git a/.github/workflows/web-server-tests.yml b/.github/workflows/web-server-tests.yml index 2ff37d11..c8e1ab55 100644 --- a/.github/workflows/web-server-tests.yml +++ b/.github/workflows/web-server-tests.yml @@ -40,6 +40,19 @@ jobs: pip install -r requirements.txt pip install -r requirements-test.txt + - name: Set up Node + if: matrix.python-version == '3.12' + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Lint JavaScript + if: matrix.python-version == '3.12' + working-directory: Software/web-server + run: | + npm ci + npx eslint 'static/**/*.js' + - name: Run tests with coverage working-directory: Software/web-server run: | diff --git a/.gitignore b/.gitignore index 6cf86bb1..dc9d5717 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,5 @@ packaging/pitrac *.FCBak # Node.js (root-level tooling) -node_modules/ \ No newline at end of file +node_modules/ +!Software/web-server/static/vendor/ diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj index c83ab061..b872b283 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj @@ -131,9 +131,9 @@ Level3 true - _CRT_SECURE_NO_WARNINGS;BOOST_BIND_GLOBAL_PLACEHOLDERS;BOOST_ALL_DYN_LINK;BOOST_USE_WINAPI_VERSION=0x0A00;YAML_CPP_STATIC_DEFINE;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) + _CRT_SECURE_NO_WARNINGS;BOOST_BIND_GLOBAL_PLACEHOLDERS;BOOST_ALL_DYN_LINK;BOOST_USE_WINAPI_VERSION=0x0A00;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\yaml-cpp\include;E:\Dev_Libs\boost_1_90_0\boost;../packages/Microsoft.ML.OnnxRuntime.1.23.2/build/native/include + E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_90_0\boost;../packages/Microsoft.ML.OnnxRuntime.1.23.2/build/native/include stdcpp20 OldStyle @@ -141,7 +141,7 @@ Console true E:\Dev_Libs\opencv\build\x64\vc16\lib;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 - E:\Dev_Libs\opencv\build\x64\vc16\lib\opencv_world4120d.lib;E:\dev_libs\yaml-cpp\build\Debug\yaml-cppd.lib;../packages/Microsoft.ML.OnnxRuntime.1.23.2/runtimes/win-x64/native/onnxruntime.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) + E:\Dev_Libs\opencv\build\x64\vc16\lib\opencv_world4120d.lib;../packages/Microsoft.ML.OnnxRuntime.1.23.2/runtimes/win-x64/native/onnxruntime.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) @@ -213,7 +213,6 @@ - true @@ -253,22 +252,12 @@ - - - - - - - - - - @@ -378,7 +367,6 @@ - true @@ -415,23 +403,15 @@ - - - - - - - - @@ -477,7 +457,6 @@ - @@ -503,8 +482,6 @@ - - diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP deleted file mode 100644 index 3c8437cc..00000000 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.BACKUP +++ /dev/null @@ -1,544 +0,0 @@ - - - - - - Debug - ARM - - - Debug - Win32 - - - Release - ARM - - - Release - Win32 - - - Debug - x64 - - - Release - x64 - - - - 16.0 - Win32Proj - {3aa11590-5347-47e5-837c-1d38ea9a56f3} - ImageProcessing - 10.0 - - - - Application - true - v145 - Unicode - - - Application - false - v145 - true - Unicode - - - Application - true - v145 - Unicode - - - Application - true - v145 - Unicode - - - Application - false - v145 - true - Unicode - - - Application - false - v145 - true - Unicode - - - - - - - - - - - - - - - - - - - - - - - - - - - - Level3 - true - WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - - - Console - true - - - - - Level3 - true - true - true - WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - - - Console - true - true - true - true - - - - - Level3 - true - _CRT_SECURE_NO_WARNINGS;BOOST_BIND_GLOBAL_PLACEHOLDERS;BOOST_ALL_DYN_LINK;BOOST_USE_WINAPI_VERSION=0x0A00;YAML_CPP_STATIC_DEFINE;_DEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\yaml-cpp\include;..\packages\Microsoft.ML.OnnxRuntime.1.23.2\build\native\include;E:\Dev_Libs\boost_1_90_0\boost - stdcpp20 - OldStyle - - - Console - true - E:\Dev_Libs\opencv\build\x64\vc16\lib;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 - E:\Dev_Libs\opencv\build\x64\vc16\lib\opencv_world4120d.lib;E:\dev_libs\yaml-cpp\build\Debug\yaml-cppd.lib;..\packages\Microsoft.ML.OnnxRuntime.1.23.2\runtimes\win-x64\native\onnxruntime.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) - - - - - Level3 - true - _DEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_81_0\;E:\Dev_Libs\NumCpp-master\include; - stdcpp20 - OldStyle - - - Console - true - E:\Dev_Libs\opencv\build\x64\vc16\lib;E:\Dev_Libs\boost_1_81_0\stage\lib - opencv_world490d.lib;$(CoreLibraryDependencies);%(AdditionalDependencies) - - - - - Level3 - true - true - true - NDEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_81_0\;E:\Dev_Libs\boost_1_81_0\boost;E:\Dev_Libs\NumCpp-master\include - stdcpp20 - OldStyle - - - Console - true - true - true - E:\Dev_Libs\opencv\build\x64\vc16\lib\;E:\Dev_Libs\boost_1_81_0\stage\lib - opencv_world490.lib;%(AdditionalDependencies) - true - true - - - - - Level3 - true - true - true - NDEBUG;_CONSOLE;%(PreprocessorDefinitions) - true - E:\Dev_Libs\opencv\build\include;E:\Dev_Libs\boost_1_81_0\;E:\Dev_Libs\boost_1_81_0\boost;E:\Dev_Libs\NumCpp-master\include - stdcpp20 - OldStyle - - - Console - true - true - true - E:\Dev_Libs\opencv\build\x64\vc16\lib\;E:\Dev_Libs\boost_1_81_0\stage\lib - opencv_world490.lib;%(AdditionalDependencies) - true - true - - - - - - - - - - - true - - - true - - - true - - - true - - - - - - - - - true - - - true - - - true - - - true - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - true - - - true - - - true - - - true - - - - - stdcpp20 - stdcpp20 - - - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - true - - - - - - - - - - - - - - true - - - - - - - - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - - - - \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters index 1433ac6f..b58e6a0f 100644 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.filters @@ -90,21 +90,6 @@ Source Files - - Source Files - - - Source Files - - - Source Files - - - Source Files - - - Source Files - Source Files @@ -117,18 +102,6 @@ Source Files - - Source Files - - - Source Files - - - Source Files - - - Source Files - Resource Files @@ -249,18 +222,12 @@ Source Files - - Source Files - Source Files Source Files - - Source Files - Source Files @@ -335,21 +302,6 @@ Header Files - - Header Files - - - Header Files - - - Header Files - - - Header Files - - - Header Files - Header Files @@ -368,15 +320,6 @@ Header Files - - Header Files - - - Header Files - - - Header Files - Header Files @@ -479,9 +422,6 @@ Header Files - - Header Files - Header Files @@ -559,12 +499,9 @@ - - - Resource Files diff --git a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user index 8d4fe8b0..a1b0dcba 100755 --- a/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user +++ b/Software/LMSourceCode/ImageProcessing/ImageProcessing.vcxproj.user @@ -1,17 +1,11 @@  - --show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550 + --base_image_logging_dir L:\ --lm_comparison_mode=0 --system_mode camera1 PITRAC_BASE_IMAGE_LOGGING_DIR=L:\ PATH=%PATH%;E:\Dev_Libs\opencv\build\x64\vc16\bin;E:\Dev_Libs\boost_1_90_0\lib64-msvc-14.3 -PITRAC_SLOT1_CAMERA_TYPE=5 -PITRAC_SLOT2_CAMERA_TYPE=5 -PITRAC_SLOT1_LENS_TYPE=1 -PITRAC_SLOT2_LENS_TYPE=1 -PITRAC_SLOT1_CAMERA_ORIENTATION =1 -PITRAC_SLOT2_CAMERA_ORIENTATION =1 HOME=. WindowsLocalDebugger - --show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=1 --logging_level info --artifact_save_level=all --wait_keys 0 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=1 --logging_level info --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level info --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 0 --system_mode camera1 --search_center_x 800 --search_center_y 550|--show_images 1 --base_image_logging_dir L:\ --lm_comparison_mode=0 --logging_level trace --artifact_save_level=all --wait_keys 1 --system_mode camera1 --search_center_x 800 --search_center_y 550| + --base_image_logging_dir L:\ --lm_comparison_mode=1 --system_mode camera1|--base_image_logging_dir L:\ --lm_comparison_mode=0 --system_mode camera1| \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp index 15373cc3..568dbbd0 100644 --- a/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp +++ b/Software/LMSourceCode/ImageProcessing/ball_image_proc.cpp @@ -71,39 +71,39 @@ namespace golf_sim { static const double kEllipseColorMaskWideningAmount = 35; static const bool kSerializeOpsForDebug = false; - int BallImageProc::kCoarseXRotationDegreesIncrement = 6; - int BallImageProc::kCoarseXRotationDegreesStart = -42; - int BallImageProc::kCoarseXRotationDegreesEnd = 42; + int BallImageProc::kCoarseXRotationDegreesIncrement = 4; + int BallImageProc::kCoarseXRotationDegreesStart = -36; + int BallImageProc::kCoarseXRotationDegreesEnd = 36; int BallImageProc::kCoarseYRotationDegreesIncrement = 5; - int BallImageProc::kCoarseYRotationDegreesStart = -30; - int BallImageProc::kCoarseYRotationDegreesEnd = 30; - int BallImageProc::kCoarseZRotationDegreesIncrement = 6; - int BallImageProc::kCoarseZRotationDegreesStart = -50; - int BallImageProc::kCoarseZRotationDegreesEnd = 60; + int BallImageProc::kCoarseYRotationDegreesStart = -15; + int BallImageProc::kCoarseYRotationDegreesEnd = 15; + int BallImageProc::kCoarseZRotationDegreesIncrement = 4; + int BallImageProc::kCoarseZRotationDegreesStart = -10; + int BallImageProc::kCoarseZRotationDegreesEnd = 110; int BallImageProc::kCoarseSearchResolution = 90; double BallImageProc::kPlacedBallCannyLower; double BallImageProc::kPlacedBallCannyUpper; - double BallImageProc::kPlacedBallStartingParam2 = 40; - double BallImageProc::kPlacedBallMinParam2 = 30; - double BallImageProc::kPlacedBallMaxParam2 = 60; - double BallImageProc::kPlacedBallCurrentParam1 = 120.0; - double BallImageProc::kPlacedBallParam2Increment = 4; + double BallImageProc::kPlacedBallStartingParam2 = 0.9; + double BallImageProc::kPlacedBallMinParam2 = 0.8; + double BallImageProc::kPlacedBallMaxParam2 = 1.0; + double BallImageProc::kPlacedBallCurrentParam1 = 130.0; + double BallImageProc::kPlacedBallParam2Increment = 0.03; int BallImageProc::kPlacedMinHoughReturnCircles = 1; int BallImageProc::kPlacedMaxHoughReturnCircles = 4; - double BallImageProc::kStrobedBallsCannyLower = 50; - double BallImageProc::kStrobedBallsCannyUpper = 110; + double BallImageProc::kStrobedBallsCannyLower = 33; + double BallImageProc::kStrobedBallsCannyUpper = 90; - int BallImageProc::kStrobedBallsMaxHoughReturnCircles = 12; - int BallImageProc::kStrobedBallsMinHoughReturnCircles = 1; + int BallImageProc::kStrobedBallsMaxHoughReturnCircles = 20; + int BallImageProc::kStrobedBallsMinHoughReturnCircles = 6; - int BallImageProc::kStrobedBallsPreCannyBlurSize = 5; + int BallImageProc::kStrobedBallsPreCannyBlurSize = 3; int BallImageProc::kStrobedBallsPreHoughBlurSize = 13; - double BallImageProc::kStrobedBallsStartingParam2 = 40; - double BallImageProc::kStrobedBallsMinParam2 = 30; - double BallImageProc::kStrobedBallsMaxParam2 = 60; + double BallImageProc::kStrobedBallsStartingParam2 = 60; + double BallImageProc::kStrobedBallsMinParam2 = 18; + double BallImageProc::kStrobedBallsMaxParam2 = 140; double BallImageProc::kStrobedBallsCurrentParam1 = 120.0; double BallImageProc::kStrobedBallsHoughDpParam1 = 1.5; double BallImageProc::kStrobedBallsParam2Increment = 4; @@ -111,26 +111,26 @@ namespace golf_sim { bool BallImageProc::kStrobedBallsUseAltHoughAlgorithm = true; double BallImageProc::kStrobedBallsAltCannyLower = 35; double BallImageProc::kStrobedBallsAltCannyUpper = 70; - int BallImageProc::kStrobedBallsAltPreCannyBlurSize = 11; - int BallImageProc::kStrobedBallsAltPreHoughBlurSize = 16; - double BallImageProc::kStrobedBallsAltStartingParam2 = 0.95; - double BallImageProc::kStrobedBallsAltMinParam2 = 0.6; + int BallImageProc::kStrobedBallsAltPreCannyBlurSize = 9; + int BallImageProc::kStrobedBallsAltPreHoughBlurSize = 15; + double BallImageProc::kStrobedBallsAltStartingParam2 = 0.65; + double BallImageProc::kStrobedBallsAltMinParam2 = 0.5; double BallImageProc::kStrobedBallsAltMaxParam2 = 1.0; double BallImageProc::kStrobedBallsAltCurrentParam1 = 130.0; - double BallImageProc::kStrobedBallsAltHoughDpParam1 = 1.5; + double BallImageProc::kStrobedBallsAltHoughDpParam1 = 0.8; double BallImageProc::kStrobedBallsAltParam2Increment = 0.05; bool BallImageProc::kUseCLAHEProcessing; int BallImageProc::kCLAHEClipLimit; int BallImageProc::kCLAHETilesGridSize; - double BallImageProc::kPuttingBallStartingParam2 = 40; - double BallImageProc::kPuttingBallMinParam2 = 30; - double BallImageProc::kPuttingBallMaxParam2 = 60; - double BallImageProc::kPuttingBallCurrentParam1 = 120.0; - double BallImageProc::kPuttingBallParam2Increment = 4; - int BallImageProc::kPuttingMaxHoughReturnCircles = 12; - int BallImageProc::kPuttingMinHoughReturnCircles = 1; + double BallImageProc::kPuttingBallStartingParam2 = 0.9; + double BallImageProc::kPuttingBallMinParam2 = 0.8; + double BallImageProc::kPuttingBallMaxParam2 = 1.0; + double BallImageProc::kPuttingBallCurrentParam1 = 300.0; + double BallImageProc::kPuttingBallParam2Increment = 0.03; + int BallImageProc::kPuttingMaxHoughReturnCircles = 25; + int BallImageProc::kPuttingMinHoughReturnCircles = 6; double BallImageProc::kPuttingHoughDpParam1 = 1.5; double BallImageProc::kExternallyStrobedEnvCannyLower = 35; @@ -148,27 +148,25 @@ namespace golf_sim { int BallImageProc::kExternallyStrobedEnvPreHoughBlurSize = 11; int BallImageProc::kExternallyStrobedEnvPreCannyBlurSize = 3; double BallImageProc::kExternallyStrobedEnvHoughDpParam1 = 1.0; - int BallImageProc::kExternallyStrobedEnvNarrowingPreCannyBlurSize = 3; - int BallImageProc::kExternallyStrobedEnvNarrowingPreHoughBlurSize = 9; int BallImageProc::kExternallyStrobedEnvMinimumSearchRadius = 60; int BallImageProc::kExternallyStrobedEnvMaximumSearchRadius = 80; - bool BallImageProc::kUseDynamicRadiiAdjustment = true; - int BallImageProc::kNumberRadiiToAverageForDynamicAdjustment = 3; - double BallImageProc::kStrobedNarrowingRadiiMinRatio = 0.8; - double BallImageProc::kStrobedNarrowingRadiiMaxRatio = 1.2; - double BallImageProc::kStrobedNarrowingRadiiDpParam = 1.8; - double BallImageProc::kStrobedNarrowingRadiiParam2 = 100.0; + bool BallImageProc::kUseDynamicRadiiAdjustment = false; + int BallImageProc::kNumberRadiiToAverageForDynamicAdjustment = 2; + double BallImageProc::kStrobedNarrowingRadiiMinRatio = 0.7; + double BallImageProc::kStrobedNarrowingRadiiMaxRatio = 1.6; + double BallImageProc::kStrobedNarrowingRadiiDpParam = 1.5; + double BallImageProc::kStrobedNarrowingRadiiParam2 = 0.8; double BallImageProc::kPlacedNarrowingRadiiMinRatio = 0.9; double BallImageProc::kPlacedNarrowingRadiiMaxRatio = 1.1; - double BallImageProc::kPlacedNarrowingStartingParam2 = 80.0; - double BallImageProc::kPlacedNarrowingRadiiDpParam = 2.0; + double BallImageProc::kPlacedNarrowingStartingParam2 = 0.9; + double BallImageProc::kPlacedNarrowingRadiiDpParam = 1.5; double BallImageProc::kPlacedNarrowingParam1 = 130.0; - int BallImageProc::kPlacedPreCannyBlurSize = 5; - int BallImageProc::kPlacedPreHoughBlurSize = 11; + int BallImageProc::kPlacedPreCannyBlurSize = 11; + int BallImageProc::kPlacedPreHoughBlurSize = 13; int BallImageProc::kPuttingPreHoughBlurSize = 9; @@ -182,13 +180,10 @@ namespace golf_sim { double BallImageProc::kBestCircleCannyUpper = 110; int BallImageProc::kBestCirclePreCannyBlurSize = 5; int BallImageProc::kBestCirclePreHoughBlurSize = 13; - double BallImageProc::kBestCircleParam1 = 120.; - double BallImageProc::kBestCircleParam2 = 35.; + double BallImageProc::kBestCircleParam1 = 300.; + double BallImageProc::kBestCircleParam2 = 0.65; double BallImageProc::kBestCircleHoughDpParam1 = 1.5; - double BallImageProc::kExternallyStrobedBestCircleCannyLower = 55; - double BallImageProc::kExternallyStrobedBestCircleCannyUpper = 110; - int BallImageProc::kExternallyStrobedBestCirclePreCannyBlurSize = 5; int BallImageProc::kExternallyStrobedBestCirclePreHoughBlurSize = 13; double BallImageProc::kExternallyStrobedBestCircleParam1 = 120.; double BallImageProc::kExternallyStrobedBestCircleParam2 = 35.; @@ -198,11 +193,11 @@ namespace golf_sim { int BallImageProc::kExternallyStrobedCLAHEClipLimit = 6; int BallImageProc::kExternallyStrobedCLAHETilesGridSize = 6; - double BallImageProc::kBestCircleIdentificationMinRadiusRatio = 0.85; - double BallImageProc::kBestCircleIdentificationMaxRadiusRatio = 1.10; + double BallImageProc::kBestCircleIdentificationMinRadiusRatio = 0.9; + double BallImageProc::kBestCircleIdentificationMaxRadiusRatio = 1.2; - int BallImageProc::kGaborMaxWhitePercent = 44; // Nominal 46; - int BallImageProc::kGaborMinWhitePercent = 38; // Nominal 40; + int BallImageProc::kGaborMaxWhitePercent = 45; // Nominal 46; + int BallImageProc::kGaborMinWhitePercent = 39; // Nominal 40; std::string BallImageProc::kSpinDetectionMethod = "ml"; // Model Detection Configuration @@ -211,7 +206,7 @@ namespace golf_sim { #ifdef _WIN32 std::string BallImageProc::kModelPath = "../../Software/LMSourceCode/ml_assets/yolo26-ball-detector"; #else - std::string BallImageProc::kModelPath = "../ml_assets/yolo26-ball-detector"; + std::string BallImageProc::kModelPath = "/etc/pitrac/models/yolo26-ball-detector"; #endif float BallImageProc::kModelConfidenceThreshold = 0.5f; float BallImageProc::kModelNMSThreshold = 0.4f; @@ -244,145 +239,6 @@ namespace golf_sim { min_ball_radius_ = -1; max_ball_radius_ = -1; - // The following constants are only used internal to the GolfSimCamera class, and so can be initialized in the constructor - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesIncrement", kCoarseXRotationDegreesIncrement); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesStart", kCoarseXRotationDegreesStart); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesEnd", kCoarseXRotationDegreesEnd); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesIncrement", kCoarseYRotationDegreesIncrement); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesStart", kCoarseYRotationDegreesStart); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesEnd", kCoarseYRotationDegreesEnd); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesIncrement", kCoarseZRotationDegreesIncrement); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesStart", kCoarseZRotationDegreesStart); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesEnd", kCoarseZRotationDegreesEnd); - - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseSearchResolution", kCoarseSearchResolution); - - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMinWhitePercent", kGaborMinWhitePercent); - GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMaxWhitePercent", kGaborMaxWhitePercent); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyLower", kPlacedBallCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyUpper", kPlacedBallCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallStartingParam2", kPlacedBallStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMinParam2", kPlacedBallMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMaxParam2", kPlacedBallMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCurrentParam1", kPlacedBallCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallParam2Increment", kPlacedBallParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMinHoughReturnCircles", kPlacedMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMaxHoughReturnCircles", kPlacedMaxHoughReturnCircles); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyLower", kStrobedBallsCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyUpper", kStrobedBallsCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreCannyBlurSize", kStrobedBallsPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreHoughBlurSize", kStrobedBallsPreHoughBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsStartingParam2", kStrobedBallsStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinParam2", kStrobedBallsMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxParam2", kStrobedBallsMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCurrentParam1", kStrobedBallsCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsParam2Increment", kStrobedBallsParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinHoughReturnCircles", kStrobedBallsMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxHoughReturnCircles", kStrobedBallsMaxHoughReturnCircles); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsUseAltHoughAlgorithm", kStrobedBallsUseAltHoughAlgorithm); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyLower", kStrobedBallsAltCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyUpper", kStrobedBallsAltCannyUpper); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreCannyBlurSize", kStrobedBallsAltPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize", kStrobedBallsAltPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltStartingParam2", kStrobedBallsAltStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMinParam2", kStrobedBallsAltMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMaxParam2", kStrobedBallsAltMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCurrentParam1", kStrobedBallsAltCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltHoughDpParam1", kStrobedBallsAltHoughDpParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltParam2Increment", kStrobedBallsAltParam2Increment); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseCLAHEProcessing", kUseCLAHEProcessing); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHEClipLimit", kCLAHEClipLimit); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHETilesGridSize", kCLAHETilesGridSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallStartingParam2", kPuttingBallStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMinParam2", kPuttingBallMinParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMaxParam2", kPuttingBallMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallCurrentParam1", kPuttingBallCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallParam2Increment", kPuttingBallParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMinHoughReturnCircles", kPuttingMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMaxHoughReturnCircles", kPuttingMaxHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingHoughDpParam1", kPuttingHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCurrentParam1", kExternallyStrobedEnvCurrentParam1); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxParam2", kExternallyStrobedEnvMaxParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvStartingParam2", kExternallyStrobedEnvStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingParam2", kExternallyStrobedEnvNarrowingParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingDpParam", kExternallyStrobedEnvNarrowingDpParam); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingPreCannyBlurSize", kExternallyStrobedEnvNarrowingPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingPreHoughBlurSize", kExternallyStrobedEnvNarrowingPreHoughBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvParam2Increment", kExternallyStrobedEnvParam2Increment); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles", kExternallyStrobedEnvMinHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxHoughReturnCircles", kExternallyStrobedEnvMaxHoughReturnCircles); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleCannyLower", kExternallyStrobedBestCircleCannyLower); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleCannyUpper", kExternallyStrobedBestCircleCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreCannyBlurSize", kExternallyStrobedBestCirclePreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize", kExternallyStrobedBestCirclePreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam1", kExternallyStrobedBestCircleParam1); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam2", kExternallyStrobedBestCircleParam2); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1", kExternallyStrobedBestCircleHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedUseCLAHEProcessing", kExternallyStrobedUseCLAHEProcessing); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHEClipLimit", kExternallyStrobedCLAHEClipLimit); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHETilesGridSize", kExternallyStrobedCLAHETilesGridSize); - - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvHoughDpParam1", kExternallyStrobedEnvHoughDpParam1); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius", kExternallyStrobedEnvMaximumSearchRadius); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumSearchRadius", kExternallyStrobedEnvMinimumSearchRadius); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreHoughBlurSize", kPlacedPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreCannyBlurSize", kPlacedPreCannyBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreHoughBlurSize", kStrobedBallsPreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingPreHoughBlurSize", kPuttingPreHoughBlurSize); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallHoughDpParam1", kPlacedBallHoughDpParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsHoughDpParam1", kStrobedBallsHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleRefinement", kUseBestCircleRefinement); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleLargestCircle", kUseBestCircleLargestCircle); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyLower", kBestCircleCannyLower); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyUpper", kBestCircleCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreCannyBlurSize", kBestCirclePreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreHoughBlurSize", kBestCirclePreHoughBlurSize); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam1", kBestCircleParam1); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam2", kBestCircleParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleHoughDpParam1", kBestCircleHoughDpParam1); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMinRadiusRatio", kBestCircleIdentificationMinRadiusRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMaxRadiusRatio", kBestCircleIdentificationMaxRadiusRatio); - - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseDynamicRadiiAdjustment", kUseDynamicRadiiAdjustment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kNumberRadiiToAverageForDynamicAdjustment", kNumberRadiiToAverageForDynamicAdjustment); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMinRatio", kStrobedNarrowingRadiiMinRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMaxRatio", kStrobedNarrowingRadiiMaxRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiDpParam", kStrobedNarrowingRadiiDpParam); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiParam2", kStrobedNarrowingRadiiParam2); - - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMinRatio", kPlacedNarrowingRadiiMinRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMaxRatio", kPlacedNarrowingRadiiMaxRatio); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingStartingParam2", kPlacedNarrowingStartingParam2); - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiDpParam", kPlacedNarrowingRadiiDpParam); - - int kImageTypeToProcessWithYOLOAsInt; - GolfSimConfiguration::SetConstant("gs_config.ball_identification.kImageTypeToProcessWithYOLO", kImageTypeToProcessWithYOLOAsInt); - kImageTypeToProcessWithYOLO= static_cast(kImageTypeToProcessWithYOLOAsInt); - - - GolfSimConfiguration::SetConstant("gs_config.logging.kLogIntermediateSpinImagesToFile", kLogIntermediateSpinImagesToFile); - // Preload model at startup if using experimental detection for either ball placement or flight if (kStrobedBallDetectionMethod == "experimental" || kBallPlacementDetectionMethod == "experimental") { @@ -1714,9 +1570,6 @@ namespace golf_sim { cv::GaussianBlur(cannyOutput_for_balls, finalChoiceSubImg, cv::Size(kBestCirclePreHoughBlurSize, kBestCirclePreHoughBlurSize), 0); // Nominal is 7x7 } else { - // cv::GaussianBlur(finalChoiceSubImg, finalChoiceSubImg, cv::Size(kExternallyStrobedBestCirclePreCannyBlurSize, kExternallyStrobedBestCirclePreCannyBlurSize), 0); - - // cv::Canny(finalChoiceSubImg, cannyOutput_for_balls, kExternallyStrobedBestCircleCannyLower, kExternallyStrobedBestCircleCannyUpper); cannyOutput_for_balls = finalChoiceSubImg.clone(); LoggingTools::DebugShowImage("Best Circle (externally-strobed)" + std::to_string(expandedRadiusForHough) + " cannyOutput for best ball", cannyOutput_for_balls); @@ -4394,6 +4247,136 @@ namespace golf_sim { kSpinDetectionMethod = "ml"; } + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesIncrement", kCoarseXRotationDegreesIncrement); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesStart", kCoarseXRotationDegreesStart); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseXRotationDegreesEnd", kCoarseXRotationDegreesEnd); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesIncrement", kCoarseYRotationDegreesIncrement); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesStart", kCoarseYRotationDegreesStart); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseYRotationDegreesEnd", kCoarseYRotationDegreesEnd); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesIncrement", kCoarseZRotationDegreesIncrement); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesStart", kCoarseZRotationDegreesStart); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseZRotationDegreesEnd", kCoarseZRotationDegreesEnd); + + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kCoarseSearchResolution", kCoarseSearchResolution); + + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMinWhitePercent", kGaborMinWhitePercent); + GolfSimConfiguration::SetConstant("gs_config.spin_analysis.kGaborMaxWhitePercent", kGaborMaxWhitePercent); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyLower", kPlacedBallCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCannyUpper", kPlacedBallCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallStartingParam2", kPlacedBallStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMinParam2", kPlacedBallMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallMaxParam2", kPlacedBallMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallCurrentParam1", kPlacedBallCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallParam2Increment", kPlacedBallParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMinHoughReturnCircles", kPlacedMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedMaxHoughReturnCircles", kPlacedMaxHoughReturnCircles); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyLower", kStrobedBallsCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCannyUpper", kStrobedBallsCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreCannyBlurSize", kStrobedBallsPreCannyBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsPreHoughBlurSize", kStrobedBallsPreHoughBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsStartingParam2", kStrobedBallsStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinParam2", kStrobedBallsMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxParam2", kStrobedBallsMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsCurrentParam1", kStrobedBallsCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsParam2Increment", kStrobedBallsParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMinHoughReturnCircles", kStrobedBallsMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsMaxHoughReturnCircles", kStrobedBallsMaxHoughReturnCircles); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsUseAltHoughAlgorithm", kStrobedBallsUseAltHoughAlgorithm); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyLower", kStrobedBallsAltCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCannyUpper", kStrobedBallsAltCannyUpper); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreCannyBlurSize", kStrobedBallsAltPreCannyBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize", kStrobedBallsAltPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltStartingParam2", kStrobedBallsAltStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMinParam2", kStrobedBallsAltMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltMaxParam2", kStrobedBallsAltMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltCurrentParam1", kStrobedBallsAltCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltHoughDpParam1", kStrobedBallsAltHoughDpParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsAltParam2Increment", kStrobedBallsAltParam2Increment); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseCLAHEProcessing", kUseCLAHEProcessing); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHEClipLimit", kCLAHEClipLimit); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kCLAHETilesGridSize", kCLAHETilesGridSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallStartingParam2", kPuttingBallStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMinParam2", kPuttingBallMinParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallMaxParam2", kPuttingBallMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallCurrentParam1", kPuttingBallCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingBallParam2Increment", kPuttingBallParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMinHoughReturnCircles", kPuttingMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingMaxHoughReturnCircles", kPuttingMaxHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingHoughDpParam1", kPuttingHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCurrentParam1", kExternallyStrobedEnvCurrentParam1); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxParam2", kExternallyStrobedEnvMaxParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvStartingParam2", kExternallyStrobedEnvStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingParam2", kExternallyStrobedEnvNarrowingParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvNarrowingDpParam", kExternallyStrobedEnvNarrowingDpParam); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvParam2Increment", kExternallyStrobedEnvParam2Increment); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles", kExternallyStrobedEnvMinHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaxHoughReturnCircles", kExternallyStrobedEnvMaxHoughReturnCircles); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize", kExternallyStrobedBestCirclePreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam1", kExternallyStrobedBestCircleParam1); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleParam2", kExternallyStrobedBestCircleParam2); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1", kExternallyStrobedBestCircleHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedUseCLAHEProcessing", kExternallyStrobedUseCLAHEProcessing); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHEClipLimit", kExternallyStrobedCLAHEClipLimit); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedCLAHETilesGridSize", kExternallyStrobedCLAHETilesGridSize); + + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvHoughDpParam1", kExternallyStrobedEnvHoughDpParam1); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius", kExternallyStrobedEnvMaximumSearchRadius); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumSearchRadius", kExternallyStrobedEnvMinimumSearchRadius); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreHoughBlurSize", kPlacedPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedPreCannyBlurSize", kPlacedPreCannyBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPuttingPreHoughBlurSize", kPuttingPreHoughBlurSize); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallHoughDpParam1", kPlacedBallHoughDpParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedBallsHoughDpParam1", kStrobedBallsHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleRefinement", kUseBestCircleRefinement); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseBestCircleLargestCircle", kUseBestCircleLargestCircle); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyLower", kBestCircleCannyLower); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleCannyUpper", kBestCircleCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreCannyBlurSize", kBestCirclePreCannyBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCirclePreHoughBlurSize", kBestCirclePreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam1", kBestCircleParam1); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleParam2", kBestCircleParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleHoughDpParam1", kBestCircleHoughDpParam1); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMinRadiusRatio", kBestCircleIdentificationMinRadiusRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kBestCircleIdentificationMaxRadiusRatio", kBestCircleIdentificationMaxRadiusRatio); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kUseDynamicRadiiAdjustment", kUseDynamicRadiiAdjustment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kNumberRadiiToAverageForDynamicAdjustment", kNumberRadiiToAverageForDynamicAdjustment); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMinRatio", kStrobedNarrowingRadiiMinRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiMaxRatio", kStrobedNarrowingRadiiMaxRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiDpParam", kStrobedNarrowingRadiiDpParam); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kStrobedNarrowingRadiiParam2", kStrobedNarrowingRadiiParam2); + + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMinRatio", kPlacedNarrowingRadiiMinRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiMaxRatio", kPlacedNarrowingRadiiMaxRatio); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingStartingParam2", kPlacedNarrowingStartingParam2); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedNarrowingRadiiDpParam", kPlacedNarrowingRadiiDpParam); + + int kImageTypeToProcessWithYOLOAsInt = static_cast(kImageTypeToProcessWithYOLO); + GolfSimConfiguration::SetConstant("gs_config.ball_identification.kImageTypeToProcessWithYOLO", kImageTypeToProcessWithYOLOAsInt); + kImageTypeToProcessWithYOLO = static_cast(kImageTypeToProcessWithYOLOAsInt); + + GolfSimConfiguration::SetConstant("gs_config.logging.kLogIntermediateSpinImagesToFile", kLogIntermediateSpinImagesToFile); + GS_LOG_MSG(info, "Model directory: " + kModelPath); GS_LOG_MSG(info, "Detection method: " + kStrobedBallDetectionMethod); GS_LOG_MSG(info, "Spin detection: " + kSpinDetectionMethod); diff --git a/Software/LMSourceCode/ImageProcessing/ball_image_proc.h b/Software/LMSourceCode/ImageProcessing/ball_image_proc.h index c420235b..71c396d7 100644 --- a/Software/LMSourceCode/ImageProcessing/ball_image_proc.h +++ b/Software/LMSourceCode/ImageProcessing/ball_image_proc.h @@ -140,8 +140,6 @@ class BallImageProc static int kExternallyStrobedEnvMaximumSearchRadius; static double kStrobedNarrowingRadiiDpParam; static double kStrobedNarrowingRadiiParam2; - static int kExternallyStrobedEnvNarrowingPreCannyBlurSize; - static int kExternallyStrobedEnvNarrowingPreHoughBlurSize; static bool kExternallyStrobedUseCLAHEProcessing; static int kExternallyStrobedCLAHEClipLimit; @@ -173,9 +171,6 @@ class BallImageProc static double kBestCircleParam2; static double kBestCircleHoughDpParam1; - static double kExternallyStrobedBestCircleCannyLower; - static double kExternallyStrobedBestCircleCannyUpper; - static int kExternallyStrobedBestCirclePreCannyBlurSize; static int kExternallyStrobedBestCirclePreHoughBlurSize; static double kExternallyStrobedBestCircleParam1; static double kExternallyStrobedBestCircleParam2; diff --git a/Software/LMSourceCode/ImageProcessing/build.output.unix.txt b/Software/LMSourceCode/ImageProcessing/build.output.unix.txt deleted file mode 100644 index ec5c1121..00000000 --- a/Software/LMSourceCode/ImageProcessing/build.output.unix.txt +++ /dev/null @@ -1,95 +0,0 @@ -ninja: Entering directory `build' -[1/82] Generating core/version.cpp with a custom command -[2/82] Generating copy_closed_source_object with a custom command -Inserting Closed Source Object Files... -[3/82] Compiling C++ object pitrac_lm.p/meson-generated_.._core_version.cpp.o -[4/82] Compiling C++ object pitrac_lm.p/gs_globals.cpp.o -[5/82] Compiling C++ object pitrac_lm.p/ED.cpp.o -[6/82] Compiling C++ object pitrac_lm.p/EDPF.cpp.o -[7/82] Compiling C++ object pitrac_lm.p/EDColor.cpp.o -[8/82] Compiling C++ object pitrac_lm.p/ball_watcher_image_buffer.cpp.o -[9/82] Compiling C++ object pitrac_lm.p/ball_watcher.cpp.o -[10/82] Compiling C++ object pitrac_lm.p/libcamera_jpeg.cpp.o -[11/82] Compiling C++ object pitrac_lm.p/colorsys.cpp.o -[12/82] Compiling C++ object pitrac_lm.p/gs_test.cpp.o -[13/82] Compiling C++ object pitrac_lm.p/gs_fsm.cpp.o -[14/82] Compiling C++ object pitrac_lm.p/libcamera_interface.cpp.o -[15/82] Compiling C++ object pitrac_lm.p/pulse_strobe.cpp.o -[16/82] Compiling C++ object pitrac_lm.p/ball_image_proc.cpp.o -[17/82] Compiling C++ object pitrac_lm.p/cv_utils.cpp.o -[18/82] Compiling C++ object pitrac_lm.p/EllipseDetectorCommon.cpp.o -[19/82] Compiling C++ object pitrac_lm.p/golf_ball.cpp.o -[20/82] Compiling C++ object pitrac_lm.p/EllipseDetectorYaed.cpp.o -In file included from ../EllipseDetectorYaed.cpp:15: -../EllipseDetectorYaed.h: In constructor ‘golf_sim::CEllipseDetectorYaed::CEllipseDetectorYaed()’: -../EllipseDetectorYaed.h:105:24: warning: ‘golf_sim::CEllipseDetectorYaed::_times’ will be initialized after [-Wreorder] - 105 | vector _times; // _times is a vector containing the execution time of each step. - | ^~~~~~ -../EllipseDetectorYaed.h:104:24: warning: ‘std::vector > golf_sim::CEllipseDetectorYaed::_timesHelper’ [-Wreorder] - 104 | vector _timesHelper; - | ^~~~~~~~~~~~ -../EllipseDetectorYaed.cpp:20:1: warning: when initialized here [-Wreorder] - 20 | CEllipseDetectorYaed::CEllipseDetectorYaed(void) : _times(6, 0.0), _timesHelper(6, 0.0) - | ^~~~~~~~~~~~~~~~~~~~ -[21/82] Compiling C++ object pitrac_lm.p/gs_options.cpp.o -[22/82] Compiling C++ object pitrac_lm.p/gs_clubs.cpp.o -[23/82] Compiling C++ object pitrac_lm.p/gs_camera.cpp.o -[24/82] Compiling C++ object pitrac_lm.p/gs_gspro_response.cpp.o -[25/82] Compiling C++ object pitrac_lm.p/gs_config.cpp.o -[26/82] Compiling C++ object pitrac_lm.p/gs_sim_interface.cpp.o -[27/82] Compiling C++ object pitrac_lm.p/gs_gspro_interface.cpp.o -[28/82] Compiling C++ object pitrac_lm.p/gs_gspro_test_server.cpp.o -[29/82] Compiling C++ object pitrac_lm.p/gs_e6_results.cpp.o -[30/82] Compiling C++ object pitrac_lm.p/gs_e6_interface.cpp.o -[31/82] Compiling C++ object pitrac_lm.p/logging_tools.cpp.o -[32/82] Compiling C++ object pitrac_lm.p/gs_sim_socket_interface.cpp.o -[33/82] Compiling C++ object pitrac_lm.p/worker_thread.cpp.o -[34/82] Compiling C++ object pitrac_lm.p/gs_events.cpp.o -[35/82] Compiling C++ object pitrac_lm.p/camera_hardware.cpp.o -[36/82] Compiling C++ object pitrac_lm.p/gs_gspro_results.cpp.o -[37/82] Compiling C++ object pitrac_lm.p/gs_ipc_message.cpp.o -[38/82] Compiling C++ object pitrac_lm.p/gs_ipc_control_msg.cpp.o -[39/82] Compiling C++ object pitrac_lm.p/gs_results.cpp.o -[40/82] Compiling C++ object pitrac_lm.p/gs_ipc_mat.cpp.o -[41/82] Compiling C++ object pitrac_lm.p/gs_ui_system.cpp.o -[42/82] Compiling C++ object pitrac_lm.p/gs_ipc_result.cpp.o -[43/82] Compiling C++ object pitrac_lm.p/core_buffer_sync.cpp.o -[44/82] Compiling C++ object pitrac_lm.p/core_dma_heaps.cpp.o -[45/82] Compiling C++ object pitrac_lm.p/gs_ipc_test.cpp.o -[46/82] Compiling C++ object pitrac_lm.p/gs_ipc_system.cpp.o -[47/82] Compiling C++ object pitrac_lm.p/gs_message_consumer.cpp.o -[48/82] Compiling C++ object pitrac_lm.p/core_post_processor.cpp.o -[49/82] Compiling C++ object pitrac_lm.p/gs_message_producer.cpp.o -[50/82] Compiling C++ object pitrac_lm.p/core_options.cpp.o -[51/82] Compiling C++ object pitrac_lm.p/encoder_encoder.cpp.o -[52/82] Compiling C++ object pitrac_lm.p/encoder_h264_encoder.cpp.o -[53/82] Compiling C++ object pitrac_lm.p/encoder_mjpeg_encoder.cpp.o -[54/82] Compiling C++ object pitrac_lm.p/image_bmp.cpp.o -[55/82] Compiling C++ object pitrac_lm.p/encoder_libav_encoder.cpp.o -[56/82] Compiling C++ object pitrac_lm.p/core_rpicam_app.cpp.o -[57/82] Compiling C++ object pitrac_lm.p/encoder_null_encoder.cpp.o -[58/82] Compiling C++ object pitrac_lm.p/image_dng.cpp.o -[59/82] Compiling C++ object pitrac_lm.p/image_png.cpp.o -[60/82] Compiling C++ object pitrac_lm.p/image_jpeg.cpp.o -[61/82] Compiling C++ object pitrac_lm.p/output_circular_output.cpp.o -[62/82] Compiling C++ object pitrac_lm.p/output_file_output.cpp.o -[63/82] Compiling C++ object pitrac_lm.p/image_yuv.cpp.o -[64/82] Compiling C++ object pitrac_lm.p/output_net_output.cpp.o -[65/82] Compiling C++ object pitrac_lm.p/output_output.cpp.o -[66/82] Compiling C++ object pitrac_lm.p/preview_null_preview.cpp.o -[67/82] Compiling C++ object pitrac_lm.p/preview_preview.cpp.o -[68/82] Compiling C++ object pitrac_lm.p/post_processing_stages_histogram.cpp.o -[69/82] Compiling C++ object pitrac_lm.p/preview_drm_preview.cpp.o -[70/82] Compiling C++ object pitrac_lm.p/preview_egl_preview.cpp.o -[71/82] Compiling C++ object pitrac_lm.p/post_processing_stages_pwl.cpp.o -[72/82] Compiling C++ object pitrac_lm.p/post_processing_stages_negate_stage.cpp.o -[73/82] Compiling C++ object pitrac_lm.p/preview_qt_preview.cpp.o -[74/82] Compiling C++ object pitrac_lm.p/post_processing_stages_post_processing_stage.cpp.o -[75/82] Compiling C++ object pitrac_lm.p/post_processing_stages_hdr_stage.cpp.o -[76/82] Compiling C++ object pitrac_lm.p/post_processing_stages_sobel_cv_stage.cpp.o -[77/82] Compiling C++ object pitrac_lm.p/post_processing_stages_annotate_cv_stage.cpp.o -[78/82] Compiling C++ object pitrac_lm.p/post_processing_stages_plot_pose_cv_stage.cpp.o -[79/82] Compiling C++ object pitrac_lm.p/post_processing_stages_face_detect_cv_stage.cpp.o -[80/82] Compiling C++ object pitrac_lm.p/post_processing_stages_object_detect_draw_cv_stage.cpp.o -[81/82] Compiling C++ object pitrac_lm.p/post_processing_stages_motion_detect_stage.cpp.o -[82/82] Linking target pitrac_lm diff --git a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp index dbf930ca..b8223e57 100644 --- a/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp +++ b/Software/LMSourceCode/ImageProcessing/camera_hardware.cpp @@ -91,7 +91,7 @@ namespace golf_sim { { "100", CameraModel::kCameraUnknown }, }; if (camera_table.count(model_enum_value_string) == 0) - throw std::runtime_error("Invalid camera model_enum_value_string: " + model_enum_value_string + ". Expected an integer. Check environment variables."); + throw std::runtime_error("Invalid camera model_enum_value_string: " + model_enum_value_string + ". Expected an integer. Check the cameras.slotN.type setting."); CameraHardware::CameraModel camera_model = (CameraHardware::CameraModel)camera_table[model_enum_value_string]; @@ -107,7 +107,7 @@ namespace golf_sim { { "100", LensType::kLensUnknown }, }; if (lens_table.count(lens_enum_value_string) == 0) - throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check environment variables."); + throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check the cameras.slotN.lens setting."); CameraHardware::LensType lens_type = (CameraHardware::LensType)lens_table[lens_enum_value_string]; @@ -123,7 +123,7 @@ namespace golf_sim { { "100", CameraOrientation::kCameraOrientationUnknown }, }; if (orientation_table.count(lens_enum_value_string) == 0) - throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check environment variables."); + throw std::runtime_error("Invalid camera lens_enum_value_string: " + lens_enum_value_string + ". Expected an integer. Check the cameras.slotN.orientation setting."); CameraHardware::CameraOrientation orientation_type = (CameraHardware::CameraOrientation)orientation_table[lens_enum_value_string]; @@ -265,11 +265,11 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "Video resolution (x,y) is: " + std::to_string(video_resolution_x_) + "/" + std::to_string(video_resolution_y_) + "."); // Attempt to get the expected ball radius from the .json file - std::string ball_radius_pixels_at_40cm_name = "kExpectedBallRadiusPixelsAt40cmCamera" + std::string(camera_number == GsCameraNumber::kGsCamera1 ? "1" : "2"); + std::string ball_radius_pixels_at_40cm_name = camera_number == GsCameraNumber::kGsCamera1 ? "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera1" : "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera2"; int expected_ball_radius_pixels_at_40cm_from_config_file = -1; - GolfSimConfiguration::SetConstant("gs_config.cameras." + ball_radius_pixels_at_40cm_name, expected_ball_radius_pixels_at_40cm_from_config_file); + GolfSimConfiguration::SetConstant(ball_radius_pixels_at_40cm_name, expected_ball_radius_pixels_at_40cm_from_config_file); // The default will be used unless the .json file has a value, in which case that value will be used. @@ -290,12 +290,13 @@ namespace golf_sim { camera_distortion_values.cols, camera_distortion_values.type()); - std::string calibration_element_name = "kCamera" + std::to_string((int)camera_number_) + "CalibrationMatrix"; - std::string distortion_element_name = "kCamera" + std::to_string((int)camera_number_) + "DistortionVector"; + const bool is_camera1 = camera_number_ == GsCameraNumber::kGsCamera1; + std::string calibration_element_name = is_camera1 ? "gs_config.cameras.kCamera1CalibrationMatrix" : "gs_config.cameras.kCamera2CalibrationMatrix"; + std::string distortion_element_name = is_camera1 ? "gs_config.cameras.kCamera1DistortionVector" : "gs_config.cameras.kCamera2DistortionVector"; - if (GolfSimConfiguration::PropertyExists("gs_config.cameras." + calibration_element_name) ) { - GolfSimConfiguration::SetConstant("gs_config.cameras." + calibration_element_name, camera_calibration_matrix_values); - GolfSimConfiguration::SetConstant("gs_config.cameras." + distortion_element_name, camera_distortion_values); + if (GolfSimConfiguration::PropertyExists(calibration_element_name) ) { + GolfSimConfiguration::SetConstant(calibration_element_name, camera_calibration_matrix_values); + GolfSimConfiguration::SetConstant(distortion_element_name, camera_distortion_values); } bool calibration_information_is_valid = (camera_calibration_matrix_values.at(0,0) != 0.0) && @@ -478,24 +479,18 @@ namespace golf_sim { // Only if we are using a custom focal length, then use that length if (lens_type == Lens_Custom) { - tag = "gs_config.cameras.kSlot" + std::to_string(camera_number_) + "CustomLensFocalLength"; - if (GolfSimConfiguration::PropertyExists(tag)) { - GolfSimConfiguration::SetConstant(tag, focal_length_); - GS_LOG_TRACE_MSG(trace, "Setting custom focal length (from JSON file) = " + std::to_string(focal_length_)); - } + tag = camera_number_ == GsCameraNumber::kGsCamera1 ? "cameras.kSlot1CustomLensFocalLength" : "cameras.kSlot2CustomLensFocalLength"; } else { // Otherwise, for non-custom lenses, just use the standard focal length parameter - tag = "gs_config.cameras.kCamera" + std::to_string(camera_number_) + "FocalLength"; - if (GolfSimConfiguration::PropertyExists(tag)) { - GolfSimConfiguration::SetConstant(tag, focal_length_); - GS_LOG_TRACE_MSG(trace, "Setting focal length (from JSON file) = " + std::to_string(focal_length_)); - } + tag = camera_number_ == GsCameraNumber::kGsCamera1 ? "gs_config.cameras.kCamera1FocalLength" : "gs_config.cameras.kCamera2FocalLength"; } + GolfSimConfiguration::SetConstant(tag, focal_length_); + GS_LOG_TRACE_MSG(trace, "Focal length = " + std::to_string(focal_length_)); } - tag = "gs_config.cameras.kCamera" + std::to_string(camera_number_) + "Angles"; + tag = camera_number_ == GsCameraNumber::kGsCamera1 ? "gs_config.cameras.kCamera1Angles" : "gs_config.cameras.kCamera2Angles"; GolfSimConfiguration::SetConstant(tag, camera_angles_); cameraInitialized = true; diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp b/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp deleted file mode 100644 index 463b5e14..00000000 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.cpp +++ /dev/null @@ -1,509 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include "configuration_manager.h" -#include "logging_tools.h" -#include -#include -#include -#include - -// Helper function to merge property trees (user overrides defaults) -namespace { - void merge_ptree(const boost::property_tree::ptree& from, boost::property_tree::ptree& to) { - for (const auto& [key, value] : from) { - if (value.empty()) { - // Leaf node - copy value - to.put(key, value.data()); - } else { - // Non-leaf node - recursive merge - auto child = to.get_child_optional(key); - if (child) { - boost::property_tree::ptree merged = *child; - merge_ptree(value, merged); - to.put_child(key, merged); - } else { - to.put_child(key, value); - } - } - } - } - - // Helper function to convert YAML to property tree - void yaml_to_ptree(const YAML::Node& node, boost::property_tree::ptree& pt, const std::string& key = "") { - if (node.IsScalar()) { - pt.put(key, node.as()); - } else if (node.IsSequence()) { - for (size_t i = 0; i < node.size(); ++i) { - yaml_to_ptree(node[i], pt, key + "." + std::to_string(i)); - } - } else if (node.IsMap()) { - for (const auto& it : node) { - std::string child_key = key.empty() ? it.first.as() : key + "." + it.first.as(); - yaml_to_ptree(it.second, pt, child_key); - } - } - } - - // Helper function to load YAML file to property tree - void load_yaml_to_ptree(const std::string& filename, boost::property_tree::ptree& pt) { - try { - YAML::Node yaml_node = YAML::LoadFile(filename); - yaml_to_ptree(yaml_node, pt); - } catch (const YAML::Exception& e) { - throw std::runtime_error("Failed to load YAML file: " + std::string(e.what())); - } - } -} - -namespace golf_sim { - -bool ConfigurationManager::Initialize( - const std::string& json_config_file, - const std::string& yaml_config_file, - const std::map& cli_overrides) { - - GS_LOG_TRACE_MSG(trace, "Initializing ConfigurationManager"); - - json_config_file_ = json_config_file; - - // Load system defaults from golf_sim_config.json - if (std::filesystem::exists(json_config_file)) { - try { - boost::property_tree::read_json(json_config_file, json_config_); - GS_LOG_MSG(info, "Loaded system defaults from: " + json_config_file); - } catch (const boost::property_tree::json_parser_error& e) { - GS_LOG_MSG(error, "Failed to parse system config: " + std::string(e.what())); - return false; - } - } else { - GS_LOG_MSG(warning, "System configuration file not found: " + json_config_file); - } - - // Load user settings from user_settings.json (new JSON-only approach) - std::string user_settings_file = std::string(std::getenv("HOME") ? std::getenv("HOME") : "") + "/.pitrac/config/user_settings.json"; - - if (std::filesystem::exists(user_settings_file)) { - try { - boost::property_tree::ptree user_settings; - boost::property_tree::read_json(user_settings_file, user_settings); - - // Merge user settings into json_config (user overrides defaults) - merge_ptree(user_settings, json_config_); - - GS_LOG_MSG(info, "Loaded user settings from: " + user_settings_file); - } catch (const boost::property_tree::json_parser_error& e) { - GS_LOG_MSG(error, "Failed to parse user settings: " + std::string(e.what())); - // Continue with defaults if user settings are corrupt - } - } else { - GS_LOG_MSG(debug, "No user settings found at: " + user_settings_file); - } - - // DEPRECATED: Support legacy YAML for migration period only - // This will be removed in future versions - if (!yaml_config_file.empty() && yaml_config_file != "none") { - std::string yaml_file = yaml_config_file; - if (yaml_file.empty()) { - // Check for legacy YAML in old locations - const std::vector yaml_locations = { - std::string(std::getenv("HOME") ? std::getenv("HOME") : "") + "/.pitrac/config/pitrac.yaml", - "/etc/pitrac/pitrac.yaml" - }; - - for (const auto& location : yaml_locations) { - if (std::filesystem::exists(location)) { - GS_LOG_MSG(warning, "Found legacy YAML config at: " + location); - GS_LOG_MSG(warning, "Please run 'pitrac config migrate-to-json' to convert to new format"); - break; - } - } - } - } - - // Apply CLI overrides - for (const auto& [key, value] : cli_overrides) { - SetOverride(key, value); - } - - // Check if a preset is specified - std::string preset = GetString("_preset", ""); - if (!preset.empty()) { - ApplyPreset(preset); - } - - return true; -} - -std::string ConfigurationManager::GetString(const std::string& key, const std::string& default_value) const { - return GetValue(key, default_value); -} - -int ConfigurationManager::GetInt(const std::string& key, int default_value) const { - std::string str_val = GetString(key, std::to_string(default_value)); - try { - return std::stoi(str_val); - } catch (...) { - return default_value; - } -} - -float ConfigurationManager::GetFloat(const std::string& key, float default_value) const { - std::string str_val = GetString(key, std::to_string(default_value)); - try { - return std::stof(str_val); - } catch (...) { - return default_value; - } -} - -bool ConfigurationManager::GetBool(const std::string& key, bool default_value) const { - std::string str_val = GetString(key, default_value ? "true" : "false"); - - // Handle various boolean representations - if (str_val == "true" || str_val == "1" || str_val == "yes" || str_val == "on") { - return true; - } - if (str_val == "false" || str_val == "0" || str_val == "no" || str_val == "off") { - return false; - } - - return default_value; -} - -void ConfigurationManager::SetOverride(const std::string& key, const std::string& value) { - std::lock_guard lock(config_mutex_); - SetInTree(cli_overrides_, key, value); -} - -bool ConfigurationManager::HasKey(const std::string& key) const { - if (GetFromTree(cli_overrides_, key).has_value()) return true; - if (GetFromTree(yaml_config_, key).has_value()) return true; - - std::string json_path = MapToJsonPath(key); - if (GetFromTree(json_config_, json_path).has_value()) return true; - - return false; -} - -std::string ConfigurationManager::GetJsonPath(const std::string& yaml_key) const { - return MapToJsonPath(yaml_key); -} - -bool ConfigurationManager::ApplyPreset(const std::string& preset_name) { - try { - auto preset = presets_.get_child_optional("presets." + preset_name); - if (!preset) { - GS_LOG_MSG(warning, "Preset not found: " + preset_name); - return false; - } - - GS_LOG_MSG(info, "Applying preset: " + preset_name); - - // Apply preset settings - auto settings = preset->get_child_optional("settings"); - if (settings) { - for (const auto& [key, value] : *settings) { - std::string val = value.get_value(); - SetInTree(yaml_config_, key, val); - } - } - - return true; - } catch (const std::exception& e) { - GS_LOG_MSG(error, "Failed to apply preset: " + std::string(e.what())); - return false; - } -} - -bool ConfigurationManager::ValidateConfiguration() const { - validation_errors_.clear(); - bool valid = true; - - // Validate all YAML settings against schema - for (const auto& [key, value] : yaml_config_) { - std::string str_val = value.get_value(); - if (!ValidateValue(key, str_val)) { - valid = false; - } - } - - // Validate CLI overrides - for (const auto& [key, value] : cli_overrides_) { - std::string str_val = value.get_value(); - if (!ValidateValue(key, str_val)) { - valid = false; - } - } - - return valid; -} - -std::vector ConfigurationManager::GetValidationErrors() const { - return validation_errors_; -} - -bool ConfigurationManager::ExportEffectiveConfig(const std::string& output_file, const std::string& format) const { - try { - boost::property_tree::ptree effective_config; - - // Build effective configuration by merging all tiers - // Start with JSON defaults - effective_config = json_config_; - - // Apply YAML overrides - for (const auto& [key, value] : yaml_config_) { - std::string json_path = MapToJsonPath(key); - SetInTree(effective_config, json_path, value.get_value()); - } - - // Apply CLI overrides - for (const auto& [key, value] : cli_overrides_) { - std::string json_path = MapToJsonPath(key); - SetInTree(effective_config, json_path, value.get_value()); - } - - // Export in requested format - if (format == "json") { - boost::property_tree::write_json(output_file, effective_config); - } else if (format == "yaml") { - // For now, export as JSON when yaml is requested - // TODO: Implement proper YAML export using yaml-cpp - GS_LOG_MSG(warning, "YAML export not yet implemented, exporting as JSON instead"); - boost::property_tree::write_json(output_file, effective_config); - } else { - GS_LOG_MSG(error, "Unknown export format: " + format); - return false; - } - - return true; - } catch (const std::exception& e) { - GS_LOG_MSG(error, "Failed to export configuration: " + std::string(e.what())); - return false; - } -} - -std::string ConfigurationManager::GetValueSource(const std::string& key) const { - if (GetFromTree(cli_overrides_, key).has_value()) { - return "cli"; - } - if (GetFromTree(yaml_config_, key).has_value()) { - return "yaml"; - } - - std::string json_path = MapToJsonPath(key); - if (GetFromTree(json_config_, json_path).has_value()) { - return "json"; - } - - return "not_found"; -} - -bool ConfigurationManager::Reload() { - GS_LOG_MSG(info, "Reloading configuration"); - - // Clear existing configuration - json_config_.clear(); - yaml_config_.clear(); - // Keep CLI overrides - - // Reload files - return Initialize(json_config_file_, yaml_config_file_, {}); -} - -bool ConfigurationManager::LoadMappings(const std::string& mappings_file) { - try { - load_yaml_to_ptree(mappings_file, mappings_); - - // Extract presets if present - auto presets = mappings_.get_child_optional("presets"); - if (presets) { - presets_ = *presets; - } - - // Build reverse mapping cache - BuildReverseMappingCache(); - - GS_LOG_MSG(info, "Loaded parameter mappings from: " + mappings_file); - return true; - } catch (const std::exception& e) { - GS_LOG_MSG(error, "Failed to load mappings: " + std::string(e.what())); - return false; - } -} - -std::string ConfigurationManager::MapToJsonPath(const std::string& yaml_key) const { - try { - // Look up mapping - auto mapping = mappings_.get_child_optional("mappings." + yaml_key); - if (mapping) { - auto json_path = mapping->get_optional("json_path"); - if (json_path) { - return *json_path; - } - } - } catch (...) { - // Ignore and return original key - } - - // No mapping found, return original key - return yaml_key; -} - -std::string ConfigurationManager::MapToYamlKey(const std::string& json_path) const { - // First check the cache - auto it = json_to_yaml_map_.find(json_path); - if (it != json_to_yaml_map_.end()) { - return it->second; - } - - // No mapping found, return original path - return json_path; -} - -void ConfigurationManager::BuildReverseMappingCache() { - json_to_yaml_map_.clear(); - - try { - auto mappings = mappings_.get_child_optional("mappings"); - if (!mappings) { - return; - } - - // Iterate through all mappings and build reverse map - for (const auto& [yaml_key, mapping_node] : *mappings) { - auto json_path = mapping_node.get_optional("json_path"); - if (json_path) { - // Store the reverse mapping: JSON path -> YAML key - json_to_yaml_map_[*json_path] = yaml_key; - GS_LOG_TRACE_MSG(trace, "Reverse mapping: " + *json_path + " -> " + yaml_key); - } - } - - GS_LOG_MSG(debug, "Built reverse mapping cache with " + std::to_string(json_to_yaml_map_.size()) + " entries"); - } catch (const std::exception& e) { - GS_LOG_MSG(warning, "Failed to build reverse mapping cache: " + std::string(e.what())); - } -} - -std::string ConfigurationManager::ConvertToJson(const std::string& yaml_key, const std::string& value) const { - try { - auto mapping = mappings_.get_child_optional("mappings." + yaml_key); - if (mapping) { - auto to_json = mapping->get_optional("to_json"); - if (to_json) { - // Simple conversion for boolean to "0"/"1" - if (*to_json == "value ? \"1\" : \"0\"") { - return (value == "true" || value == "1") ? "1" : "0"; - } - } - } - } catch (...) { - // Ignore and return original value - } - - return value; -} - -std::string ConfigurationManager::ConvertFromJson(const std::string& yaml_key, const std::string& value) const { - try { - auto mapping = mappings_.get_child_optional("mappings." + yaml_key); - if (mapping) { - auto from_json = mapping->get_optional("from_json"); - if (from_json) { - // Simple conversion for "0"/"1" to boolean - if (*from_json == "value == \"1\"") { - return value == "1" ? "true" : "false"; - } - } - } - } catch (...) { - // Ignore and return original value - } - - return value; -} - -std::string ConfigurationManager::ExpandPath(const std::string& path) const { - if (path.empty() || path[0] != '~') { - return path; - } - - const char* home = std::getenv("HOME"); - if (!home) { - return path; - } - - return std::string(home) + path.substr(1); -} - -bool ConfigurationManager::ValidateValue(const std::string& key, const std::string& value) const { - try { - auto mapping = mappings_.get_child_optional("mappings." + key); - if (!mapping) { - return true; // No validation rules, assume valid - } - - auto validation = mapping->get_child_optional("validation"); - if (!validation) { - return true; // No validation rules - } - - // Check enum values - auto enum_values = validation->get_child_optional("enum"); - if (enum_values) { - bool found = false; - for (const auto& [_, enum_val] : *enum_values) { - if (enum_val.get_value() == value) { - found = true; - break; - } - } - if (!found) { - validation_errors_.push_back(key + ": value '" + value + "' not in allowed values"); - return false; - } - } - - // Check min/max for numeric values - auto min = validation->get_optional("min"); - auto max = validation->get_optional("max"); - - if (min || max) { - try { - float num_val = std::stof(value); - - if (min && num_val < *min) { - validation_errors_.push_back(key + ": value " + value + " below minimum " + std::to_string(*min)); - return false; - } - - if (max && num_val > *max) { - validation_errors_.push_back(key + ": value " + value + " above maximum " + std::to_string(*max)); - return false; - } - } catch (...) { - validation_errors_.push_back(key + ": value '" + value + "' is not numeric"); - return false; - } - } - - // Check pattern (regex) - auto pattern = validation->get_optional("pattern"); - if (pattern) { - std::regex re(*pattern); - if (!std::regex_match(value, re)) { - validation_errors_.push_back(key + ": value '" + value + "' does not match pattern"); - return false; - } - } - - return true; - } catch (...) { - return true; // Ignore validation errors - } -} - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/configuration_manager.h b/Software/LMSourceCode/ImageProcessing/configuration_manager.h deleted file mode 100644 index 1f327b11..00000000 --- a/Software/LMSourceCode/ImageProcessing/configuration_manager.h +++ /dev/null @@ -1,310 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "logging_tools.h" - -namespace golf_sim { - -/** - * ConfigurationManager implements a three-tier configuration system: - * 1. Default values (built-in/JSON template) - * 2. User overrides (YAML configuration) - * 3. Runtime overrides (command-line arguments) - * - * The system maintains backward compatibility with golf_sim_config.json - * while providing user-friendly YAML configuration options. - */ -class ConfigurationManager { -public: - static ConfigurationManager& GetInstance() { - static ConfigurationManager instance; - return instance; - } - - // Delete copy constructor and assignment operator - ConfigurationManager(const ConfigurationManager&) = delete; - ConfigurationManager& operator=(const ConfigurationManager&) = delete; - - /** - * Initialize the configuration system - * @param json_config_file Path to golf_sim_config.json (defaults) - * @param yaml_config_file Path to pitrac.yaml (user overrides) - * @param cli_overrides Command-line overrides - * @return true if initialization successful - */ - bool Initialize( - const std::string& json_config_file = ".pitrac/config/generated_golf_sim_config.json", - const std::string& yaml_config_file = "", - const std::map& cli_overrides = {} - ); - - /** - * Get a configuration value with fallback hierarchy - * @param key Configuration key (e.g., "ball_detection.method") - * @param default_value Default if not found - * @return Configuration value - */ - template - T GetValue(const std::string& key, const T& default_value) const; - - /** - * Get a configuration value as string - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as string - */ - std::string GetString(const std::string& key, const std::string& default_value = "") const; - - /** - * Get a configuration value as integer - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as integer - */ - int GetInt(const std::string& key, int default_value = 0) const; - - /** - * Get a configuration value as float - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as float - */ - float GetFloat(const std::string& key, float default_value = 0.0f) const; - - /** - * Get a configuration value as boolean - * @param key Configuration key - * @param default_value Default if not found - * @return Configuration value as boolean - */ - bool GetBool(const std::string& key, bool default_value = false) const; - - /** - * Set a runtime override value - * @param key Configuration key - * @param value Override value - */ - void SetOverride(const std::string& key, const std::string& value); - - /** - * Check if a configuration key exists - * @param key Configuration key - * @return true if key exists in any tier - */ - bool HasKey(const std::string& key) const; - - /** - * Get the JSON configuration path for a YAML key - * @param yaml_key YAML configuration key - * @return JSON path if mapped, empty if not - */ - std::string GetJsonPath(const std::string& yaml_key) const; - - /** - * Apply a configuration preset - * @param preset_name Name of preset (e.g., "indoor", "putting") - * @return true if preset applied successfully - */ - bool ApplyPreset(const std::string& preset_name); - - /** - * Validate configuration against schema - * @return true if configuration is valid - */ - bool ValidateConfiguration() const; - - /** - * Get validation errors if any - * @return Vector of validation error messages - */ - std::vector GetValidationErrors() const; - - /** - * Export effective configuration (with all overrides applied) - * @param output_file Path to output file - * @param format Output format ("yaml" or "json") - * @return true if export successful - */ - bool ExportEffectiveConfig(const std::string& output_file, const std::string& format = "yaml") const; - - /** - * Get the source of a configuration value (for debugging) - * @param key Configuration key - * @return Source ("default", "json", "yaml", "cli", or "not_found") - */ - std::string GetValueSource(const std::string& key) const; - - /** - * Reload configuration files - * @return true if reload successful - */ - bool Reload(); - -private: - ConfigurationManager() = default; - ~ConfigurationManager() = default; - - // Configuration storage (three tiers) - boost::property_tree::ptree json_config_; // golf_sim_config.json - boost::property_tree::ptree yaml_config_; // pitrac.yaml overrides - boost::property_tree::ptree cli_overrides_; // Command-line overrides - boost::property_tree::ptree mappings_; // Parameter mappings - boost::property_tree::ptree presets_; // Configuration presets - - // File paths for reloading - std::string json_config_file_; - std::string yaml_config_file_; - - // Thread safety - mutable std::mutex config_mutex_; - - // Validation errors - mutable std::vector validation_errors_; - - // Reverse mapping cache (JSON path -> YAML key) - mutable std::map json_to_yaml_map_; - - /** - * Load parameter mappings from file - * @param mappings_file Path to parameter-mappings.yaml - * @return true if successful - */ - bool LoadMappings(const std::string& mappings_file); - - /** - * Build reverse mapping cache from loaded mappings - */ - void BuildReverseMappingCache(); - - /** - * Map a YAML key to JSON path - * @param yaml_key YAML configuration key - * @return Mapped JSON path or original key if no mapping - */ - std::string MapToJsonPath(const std::string& yaml_key) const; - - /** - * Map a JSON path to YAML key (reverse mapping) - * @param json_path JSON configuration path - * @return Mapped YAML key or original path if no mapping - */ - std::string MapToYamlKey(const std::string& json_path) const; - - /** - * Convert YAML value to JSON format - * @param yaml_key YAML configuration key - * @param value Value to convert - * @return Converted value - */ - std::string ConvertToJson(const std::string& yaml_key, const std::string& value) const; - - /** - * Convert JSON value to YAML format - * @param yaml_key YAML configuration key - * @param value Value to convert - * @return Converted value - */ - std::string ConvertFromJson(const std::string& yaml_key, const std::string& value) const; - - /** - * Expand home directory in path - * @param path Path potentially containing ~ - * @return Expanded path - */ - std::string ExpandPath(const std::string& path) const; - - /** - * Validate a value against schema - * @param key Configuration key - * @param value Value to validate - * @return true if valid - */ - bool ValidateValue(const std::string& key, const std::string& value) const; - - /** - * Get value from property tree with dot notation support - * @param tree Property tree to search - * @param path Dot-separated path - * @return Value if found - */ - template - std::optional GetFromTree(const boost::property_tree::ptree& tree, const std::string& path) const; - - /** - * Set value in property tree with dot notation support - * @param tree Property tree to modify - * @param path Dot-separated path - * @param value Value to set - */ - template - void SetInTree(boost::property_tree::ptree& tree, const std::string& path, const T& value) const; -}; - -// Template implementation -template -T ConfigurationManager::GetValue(const std::string& key, const T& default_value) const { - std::lock_guard lock(config_mutex_); - - // Check CLI overrides first - if (auto val = GetFromTree(cli_overrides_, key)) { - return val.value(); - } - - // For YAML config, we need to handle both cases: - // 1. If the key is a JSON path (e.g., "gs_config.cameras.kCamera1Gain"), - // map it to YAML key and check - // 2. If the key is already a YAML key, check directly - - // Try to map JSON path to YAML key - std::string yaml_key = MapToYamlKey(key); - if (yaml_key != key) { - // It was a JSON path that got mapped to a YAML key - if (auto val = GetFromTree(yaml_config_, yaml_key)) { - return val.value(); - } - } - - // Also check with the original key in case it's already a YAML key - if (auto val = GetFromTree(yaml_config_, key)) { - return val.value(); - } - - // Map YAML key to JSON path and check JSON config - std::string json_path = MapToJsonPath(key); - if (auto val = GetFromTree(json_config_, json_path)) { - return val.value(); - } - - // Return default - return default_value; -} - -template -std::optional ConfigurationManager::GetFromTree(const boost::property_tree::ptree& tree, const std::string& path) const { - try { - return tree.get(path); - } catch (const boost::property_tree::ptree_error&) { - // Too Noise - Turn OFF - GS_LOG_MSG(trace, "GetFromTree failed for path: " + path); - return std::nullopt; - } -} - -template -void ConfigurationManager::SetInTree(boost::property_tree::ptree& tree, const std::string& path, const T& value) const { - tree.put(path, value); -} - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh b/Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh deleted file mode 100755 index e063e756..00000000 --- a/Software/LMSourceCode/ImageProcessing/create_closed_source_objects.sh +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-License-Identifier: GPL-2.0-only */ -# -# Copyright (C) 2022-2025, Verdant Consultants, LLC. -# -#!/bin/bash - -echo "Inserting Closed Source Object Files..." -cp -f $PITRAC_ROOT/ImageProcessing/ClosedSourceObjectFiles/* $PITRAC_ROOT/ImageProcessing/build/pitrac_lm.p -cp -f $PITRAC_ROOT/ImageProcessing/ClosedSourceObjectFiles/* $PITRAC_ROOT/ImageProcessing -touch gs_e6_response.cpp.o $PITRAC_ROOT/ImageProcessing/build/pitrac_lm.p/gs_e6_response.cpp.o diff --git a/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp new file mode 100644 index 00000000..2c0a176c --- /dev/null +++ b/Software/LMSourceCode/ImageProcessing/e6_auth_shim.cpp @@ -0,0 +1,121 @@ +/* SPDX-License-Identifier: GPL-2.0-only */ +/* + * Copyright (C) 2022-2025, Verdant Consultants, LLC. + */ + +// C entry point that answers the E6 authentication challenge for the web +// server's E6 sim (sims/e6_auth.py). +// The real headers for the symbols stubbed below pull in OpenCV, so they are +// declared here with the same signatures instead. + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "gs_e6_response.h" + +namespace golf_sim { + + // Only reached for Arm, Disarm and PlayerDataModified, which the web server + // handles itself and never passes in. + class GsSimInterface { + public: + enum GolfSimulatorType { kNone = 0, kGSPro = 1, kE6 = 2 }; + static GsSimInterface* GetSimInterfaceByType(GolfSimulatorType sim_type); + }; + + GsSimInterface* GsSimInterface::GetSimInterfaceByType(GolfSimulatorType) { + return nullptr; + } + + enum class GsIPCControlMsgType { kUnknown = 0, kClubChangeToPutter = 1, kClubChangeToDriver = 2 }; + + class GsIPCControlMsg { + public: + static std::string FormatControlMessageType(const GsIPCControlMsgType t); + }; + + std::string GsIPCControlMsg::FormatControlMessageType(const GsIPCControlMsgType) { + return ""; + } + + class GolfSimEventElement; + + class GolfSimEventQueue { + public: + static bool QueueEvent(GolfSimEventElement& event); + }; + + bool GolfSimEventQueue::QueueEvent(GolfSimEventElement&) { + return false; + } + + class GsResults { + public: + static std::string GenerateStringFromJsonTree(const boost::property_tree::ptree& root); + }; + + // The object calls this. write_json quotes every value, so numbers and booleans are unquoted here + std::string GsResults::GenerateStringFromJsonTree(const boost::property_tree::ptree& root) { + std::stringstream ss; + boost::property_tree::write_json(ss, root); + + std::regex reg("\\\"([+-]?[0-9]+\\.{0,1}[0-9]*)\\\""); + std::string result = std::regex_replace(ss.str(), reg, "$1"); + + boost::replace_all(result, "\"true\"", "true"); + boost::replace_all(result, "\"false\"", "false"); + boost::replace_all(result, "\"APIversion\": 1,", "\"APIversion\": \"1\","); + + return result; + } +} + +namespace { + // GsE6Response logs every message at trace, which would fill the web server's journal + const bool kLogFilterSet = [] { + boost::log::core::get()->set_filter(boost::log::trivial::severity >= boost::log::trivial::warning); + return true; + }(); +} + +// Only challenge messages reach GsE6Response: Arm and Disarm dereference the +// null GetSimInterfaceByType stub, which would kill the web server process. +static bool IsChallenge(const char* json) { + boost::property_tree::ptree pt; + std::istringstream ss(json); + boost::property_tree::read_json(ss, pt); + const std::string type = pt.get("Type", ""); + return (type == "Handshake" || type == "Challenge") && pt.count("Challenge") > 0; +} + +// sims/e6_auth.py depends on these codes: reply length, 0 for no reply, +// -1 rejected, -2 reply plus terminator larger than cap. +extern "C" int e6_process(const char* json, char* out, size_t cap) { + std::string reply; + try { + if (!IsChallenge(json)) { + return -1; + } + golf_sim::GsE6Response response; + if (!response.ProcessJson(json, reply)) { + return -1; + } + } + catch (...) { + return -1; + } + if (reply.size() + 1 > cap) { + return -2; + } + std::memcpy(out, reply.c_str(), reply.size() + 1); + return static_cast(reply.size()); +} diff --git a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp index b817623e..87be9a3f 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_calibration.cpp @@ -17,7 +17,7 @@ #include "gs_camera.h" #include "gs_calibration.h" -#include "gs_web_api.h" +#include "gs_http_client.h" namespace golf_sim { @@ -55,9 +55,9 @@ namespace golf_sim { // Number of pictures to average when determining focal length. Because the focal length can tend // to bounce around a bit due to small variations in ball detection, averaging multiple pictures can help. - int GolfSimCalibration::kNumberPicturesForFocalLengthAverage = 5; + int GolfSimCalibration::kNumberPicturesForFocalLengthAverage = 6; - int GolfSimCalibration::kNumberOfCalibrationFailuresToTolerate = 2; + int GolfSimCalibration::kNumberOfCalibrationFailuresToTolerate = 4; GolfSimCalibration::GolfSimCalibration() { @@ -151,7 +151,7 @@ namespace golf_sim { bool result = ip->GetBall(color_image, ball, return_balls, nullROI, search_mode); if (!result || return_balls.empty()) { - GS_LOG_MSG(error, "GetBall() failed to get a ball. Consider setting --show_images=1 in order to determine why no ball was found."); + GS_LOG_MSG(error, "GetBall() failed to get a ball. Consider enabling Show Debug Images (gs_config.debug.kShowDebugImages) to determine why no ball was found."); return -1.0; } @@ -419,6 +419,7 @@ namespace golf_sim { else { GS_LOG_MSG(warning, "Could not DetermineFocalLengthForAutoCalibration -- trying again."); i--; + continue; } } @@ -442,7 +443,7 @@ namespace golf_sim { } average_focal_length /= number_samples; - GS_LOG_MSG(info, "====> Average Focal Length = " + std::to_string(average_focal_length) + ". Will set this value into the gs_config.json file."); + GS_LOG_MSG(info, "====> Average Focal Length = " + std::to_string(average_focal_length) + "."); const double kMinFocalLength = 2.0; const double kMaxFocalLength = 50.0; @@ -479,54 +480,17 @@ namespace golf_sim { return false; } - // Now save the values out to a .json file + const bool is_camera1 = camera_number == GsCameraNumber::kGsCamera1; + std::string focal_length_tag_name = is_camera1 ? "gs_config.cameras.kCamera1FocalLength" : "gs_config.cameras.kCamera2FocalLength"; + std::string camera_angles_tag_name = is_camera1 ? "gs_config.cameras.kCamera1Angles" : "gs_config.cameras.kCamera2Angles"; - std::string camera_number_string = std::to_string(camera_number); - - std::string focal_length_tag_name = "gs_config.cameras.kCamera" + camera_number_string + "FocalLength"; - std::string camera_angles_tag_name = "gs_config.cameras.kCamera" + camera_number_string + "Angles"; - - GolfSimConfiguration::SetTreeValue(focal_length_tag_name, average_focal_length); - GolfSimConfiguration::SetTreeValue(camera_angles_tag_name, camera_angles); - - WebApi::UpdateCalibration(focal_length_tag_name, average_focal_length); - +#ifdef __unix__ std::vector angles_vector = {camera_angles[0], camera_angles[1]}; - WebApi::UpdateCalibration(camera_angles_tag_name, angles_vector); - - std::string config_file_name = "golf_sim_config.json"; - - if (!GolfSimOptions::GetCommandLineOptions().config_file_.empty()) { - - config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - } - - // Add only to the tail of the file name to ensure that any prefixed path will remain valid - std::string backup_json_file_name = config_file_name + "_BACKUP_" + LoggingTools::GetUniqueLogName() + ".json"; - - GS_LOG_TRACE_MSG(info, "Saving current golf_sim_config.json file to filename = " + backup_json_file_name); - -#ifdef __unix__ - std::string cp_command = "cp " + config_file_name + " " + backup_json_file_name; + return GsHttpClient::UpdateCalibration(focal_length_tag_name, average_focal_length) && + GsHttpClient::UpdateCalibration(camera_angles_tag_name, angles_vector); #else - std::string cp_command = "copy " + config_file_name + " " + backup_json_file_name; + return false; #endif - int command_result = system(cp_command.c_str()); - - if (command_result != 0) { - GS_LOG_TRACE_MSG(trace, "system(cp_command) failed. Could not backup existing golf_sim_config.json file. Exiting"); - return false; - } - - // NOTE - we will overwrite the original config file - std::string results_tree_file_name = config_file_name; - - if (!GolfSimConfiguration::WriteTreeToFile(results_tree_file_name)) { - GS_LOG_MSG(error, "Could not WriteTreeToFile(" + results_tree_file_name + ")."); - return false; - } - - return true; } } diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp index a0ba0084..06b4ed17 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.cpp @@ -18,7 +18,6 @@ #include "libcamera_interface.h" #include "gs_camera.h" -#include "gs_web_api.h" namespace golf_sim { @@ -26,15 +25,15 @@ namespace golf_sim { // Constants used by this class bool GolfSimCamera::kLogIntermediateExposureImagesToFile = false; bool GolfSimCamera::kLogWebserverImagesToFile = true; - bool GolfSimCamera::kLogDiagnosticImagesToUniqueFiles = false; + bool GolfSimCamera::kLogDiagnosticImagesToUniqueFiles = true; - int GolfSimCamera::kMaximumOffTrajectoryDistance = 5; + int GolfSimCamera::kMaximumOffTrajectoryDistance = 8; unsigned int GolfSimCamera::kNumberHighQualityBallsToRetain = 2; - double GolfSimCamera::kMaxStrobedBallColorDifferenceRelaxed = 35000.; - double GolfSimCamera::kMaxPuttingBallColorDifferenceRelaxed = 35000.; - double GolfSimCamera::kMaxStrobedBallColorDifferenceStrict = 15000.; - double GolfSimCamera::kBallProximityMarginPercentRelaxed = 50.; - double GolfSimCamera::kBallProximityMarginPercentStrict = 5.; + double GolfSimCamera::kMaxStrobedBallColorDifferenceRelaxed = 70000.; + double GolfSimCamera::kMaxPuttingBallColorDifferenceRelaxed = 40000.; + double GolfSimCamera::kMaxStrobedBallColorDifferenceStrict = 30000.; + double GolfSimCamera::kBallProximityMarginPercentRelaxed = 65.; + double GolfSimCamera::kBallProximityMarginPercentStrict = 15.; // These constants may be used before this class's constructor is called. // For that reason, they are initialized in the GsConfiguration startup @@ -42,60 +41,55 @@ namespace golf_sim { cv::Vec3d GolfSimCamera::kCamera2PositionsFromExpectedBallMeters; cv::Vec3d GolfSimCamera::kCamera2OffsetFromCamera1OriginMeters; - double GolfSimCamera::kColorDifferenceRgbPostMultiplierForDarker = 5.0; - double GolfSimCamera::kColorDifferenceRgbPostMultiplierForLighter = 10.0; + double GolfSimCamera::kColorDifferenceRgbPostMultiplierForDarker = 4.0; + double GolfSimCamera::kColorDifferenceRgbPostMultiplierForLighter = 1.0; double GolfSimCamera::kColorDifferenceStdPostMultiplierForDarker = 3.0; - double GolfSimCamera::kColorDifferenceStdPostMultiplierForLighter = 2.0; + double GolfSimCamera::kColorDifferenceStdPostMultiplierForLighter = 5.0; - double GolfSimCamera::kMaxDistanceFromTrajectory = 20.; + double GolfSimCamera::kMaxDistanceFromTrajectory = 30.; int GolfSimCamera::kClosestBallPairEdgeBackoffPixels = 200; - double GolfSimCamera::kMaxIntermediateBallRadiusChangePercent = 10.0; - double GolfSimCamera::kMaxPuttingIntermediateBallRadiusChangePercent = 10.0; + double GolfSimCamera::kMaxIntermediateBallRadiusChangePercent = 5.0; + double GolfSimCamera::kMaxPuttingIntermediateBallRadiusChangePercent = 8.0; double GolfSimCamera::kMaxOverlappedBallRadiusChangeRatio = 1.3; - double GolfSimCamera::kMaxRadiusDifferencePercentageFromBest = 20; + double GolfSimCamera::kMaxRadiusDifferencePercentageFromBest = 35; bool GolfSimCamera::kUsePreImageSubtraction = false; // Ultimately, this concept was not as helpful as hoped for - double GolfSimCamera::kPreImageWeightingOverall = 1.0; - double GolfSimCamera::kPreImageWeightingBlue = 1.0; - double GolfSimCamera::kPreImageWeightingGreen = 1.0; + double GolfSimCamera::kPreImageWeightingOverall = 0.0; + double GolfSimCamera::kPreImageWeightingBlue = 1.05; + double GolfSimCamera::kPreImageWeightingGreen = 1.2; double GolfSimCamera::kPreImageWeightingRed = 1.0; - float GolfSimCamera::kTeedBallSearchAreaMaskRadiusRatio = 5.0f; - double GolfSimCamera::kCamera1CalibrationDistanceToBall = 0.5; - double GolfSimCamera::kCamera2CalibrationDistanceToBall = 0.5; + float GolfSimCamera::kTeedBallSearchAreaMaskRadiusRatio = 0.0f; double GolfSimCamera::kCamera1XOffsetForTilt = 0.0; double GolfSimCamera::kCamera1YOffsetForTilt = 0.0; double GolfSimCamera::kCamera2XOffsetForTilt = 0.0; double GolfSimCamera::kCamera2YOffsetForTilt = 0.0; - double GolfSimCamera::kExpectedBallPositionXcm = -50.0; - double GolfSimCamera::kExpectedBallPositionYcm = -28.0; - double GolfSimCamera::kExpectedBallPositionZcm = 50.0; - double GolfSimCamera::kExpectedBallRadiusPixelsAt40cm = 50; - float GolfSimCamera::kMaxMovedBallRadiusRatio = 1.40f; - float GolfSimCamera::kMinMovedBallRadiusRatio = 0.50f; - double GolfSimCamera::kMinRadiusRatio = 0.7; - double GolfSimCamera::kMaxRadiusRatio = 1.2; + double GolfSimCamera::kExpectedBallRadiusPixelsAt40cm = 87; + float GolfSimCamera::kMaxMovedBallRadiusRatio = 1.5f; + float GolfSimCamera::kMinMovedBallRadiusRatio = 0.6f; + double GolfSimCamera::kMinRadiusRatio = 0.8; + double GolfSimCamera::kMaxRadiusRatio = 1.7; // Fixed amount to subtract or add to an expected radius to produce a reasonable range - int GolfSimCamera::kMinRadiusOffset = 10; - int GolfSimCamera::kMaxRadiusOffset = 10; + int GolfSimCamera::kMinRadiusOffset = 20; + int GolfSimCamera::kMaxRadiusOffset = 20; double GolfSimCamera::kUnlikelyAngleMinimumDistancePixels = 40; - double GolfSimCamera::kMaxQualityExposureLaunchAngle = 45.0; + double GolfSimCamera::kMaxQualityExposureLaunchAngle = 35.0; double GolfSimCamera::kMinQualityExposureLaunchAngle = -5.0; - double GolfSimCamera::kMaxPuttingQualityExposureLaunchAngle = +10.0; - double GolfSimCamera::kMinPuttingQualityExposureLaunchAngle = -10.0; - int GolfSimCamera::kNumberAngleCheckExposures = 3; + double GolfSimCamera::kMaxPuttingQualityExposureLaunchAngle = +8.0; + double GolfSimCamera::kMinPuttingQualityExposureLaunchAngle = -5.0; + int GolfSimCamera::kNumberAngleCheckExposures = 4; - double GolfSimCamera::kStandardBallSpeedSlowdownPercentage = 0.5; - double GolfSimCamera::kPracticeBallSpeedSlowdownPercentage = 2.0; - double GolfSimCamera::kPuttingBallSpeedSlowdownPercentage = 5.0; + double GolfSimCamera::kStandardBallSpeedSlowdownPercentage = 0.1; + double GolfSimCamera::kPracticeBallSpeedSlowdownPercentage = 4.0; + double GolfSimCamera::kPuttingBallSpeedSlowdownPercentage = 5.2; bool GolfSimCamera::kCameraRequiresFlushPulse = false; - int GolfSimCamera::kMaxBallsToRetain = 18; + int GolfSimCamera::kMaxBallsToRetain = 30; bool GolfSimCamera::kExternallyStrobedEnvFilterImage = true; int GolfSimCamera::kExternallyStrobedEnvBottomIgnoreHeight = 70; @@ -105,18 +99,16 @@ namespace golf_sim { int GolfSimCamera::kExternallyStrobedEnvFilterHsvUpperS = 255; int GolfSimCamera::kExternallyStrobedEnvFilterHsvLowerV = 114; int GolfSimCamera::kExternallyStrobedEnvFilterHsvUpperV = 255; - int GolfSimCamera::kExternallyStrobedEnvCannyLower = 156; - int GolfSimCamera::kExternallyStrobedEnvCannyUpper = 337; - int GolfSimCamera::kExternallyStrobedEnvPreHoughBlurSize = 13; // 6 for External Strobe, 9 for normal + int GolfSimCamera::kExternallyStrobedEnvCleanupCannyLower = 156; + int GolfSimCamera::kExternallyStrobedEnvCleanupCannyUpper = 337; + int GolfSimCamera::kExternallyStrobedEnvCleanupPreHoughBlurSize = 13; // 6 for External Strobe, 9 for normal int GolfSimCamera::kExternallyStrobedEnvPreCannyBlurSize = 3; - int GolfSimCamera::kExternallyStrobedEnvHoughLineIntersections = 235; int GolfSimCamera::kExternallyStrobedEnvLinesAngleLower = 140; int GolfSimCamera::kExternallyStrobedEnvLinesAngleUpper = 180; - int GolfSimCamera::kExternallyStrobedEnvMaximumHoughLineGap = 7; int GolfSimCamera::kExternallyStrobedEnvMinimumHoughLineLength = 23; - bool GolfSimCamera::kPlacedBallUseLargestBall = true; + bool GolfSimCamera::kPlacedBallUseLargestBall = false; CameraHardware::CameraModel GolfSimCamera::kSystemSlot1CameraType = CameraHardware::CameraModel::PiGS; CameraHardware::CameraModel GolfSimCamera::kSystemSlot2CameraType = CameraHardware::CameraModel::PiGS; @@ -127,19 +119,12 @@ namespace golf_sim { CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; CameraHardware::CameraOrientation GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::CameraOrientation::kUpsideUp; - float GolfSimCamera::kHLAOffsetAngleDegrees = 0.0; - float GolfSimCamera::kVLAOffsetAngleDegrees = 0.0; + bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = false; - bool GolfSimCamera::kUseOnlyHighQualityBallImagesForHLA = true; - - - GolfSimCamera::GolfSimCamera() { - - // TBD - Probably shouldn't be doing all of this in the constructor - + void GolfSimCamera::LoadConfigurationValues() { GS_LOG_TRACE_MSG(trace, "GolfSimCamera reading constants from JSON file."); - // The following constants are only used internal to the GolfSimCamera class, and so can be initialized in the constructor + GolfSimConfiguration::SetConstant("gs_config.logging.kLogIntermediateExposureImagesToFile", kLogIntermediateExposureImagesToFile); GolfSimConfiguration::SetConstant("gs_config.logging.kLogWebserverImagesToFile", kLogWebserverImagesToFile); GolfSimConfiguration::SetConstant("gs_config.logging.kLogDiagnosticImagesToUniqueFiles", kLogDiagnosticImagesToUniqueFiles); @@ -172,9 +157,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kMaxOverlappedBallRadiusChangeRatio", kMaxOverlappedBallRadiusChangeRatio); GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kMaxRadiusDifferencePercentageFromBest", kMaxRadiusDifferencePercentageFromBest); - GolfSimConfiguration::SetConstant("gs_config.calibration.kCamera1CalibrationDistanceToBall", kCamera1CalibrationDistanceToBall); - GolfSimConfiguration::SetConstant("gs_config.calibration.kCamera2CalibrationDistanceToBall", kCamera2CalibrationDistanceToBall); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kTeedBallSearchAreaMaskRadiusRatio", kTeedBallSearchAreaMaskRadiusRatio); GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1XOffsetForTilt", kCamera1XOffsetForTilt); @@ -182,9 +164,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera2XOffsetForTilt", kCamera2XOffsetForTilt); GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera2YOffsetForTilt", kCamera2YOffsetForTilt); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallPositionXcm", kExpectedBallPositionXcm); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallPositionYcm", kExpectedBallPositionYcm); - GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallPositionZcm", kExpectedBallPositionZcm); GolfSimConfiguration::SetConstant("gs_config.ball_position.kExpectedBallRadiusPixelsAt40cm", kExpectedBallRadiusPixelsAt40cm); GolfSimConfiguration::SetConstant("gs_config.ball_position.kMaxMovedBallRadiusRatio", kMaxMovedBallRadiusRatio); GolfSimConfiguration::SetConstant("gs_config.ball_position.kMinMovedBallRadiusRatio", kMinMovedBallRadiusRatio); @@ -212,20 +191,18 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvUpperS", kExternallyStrobedEnvFilterHsvUpperS); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvLowerV", kExternallyStrobedEnvFilterHsvLowerV); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvFilterHsvUpperV", kExternallyStrobedEnvFilterHsvUpperV); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyLower", kExternallyStrobedEnvCannyLower); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCannyUpper", kExternallyStrobedEnvCannyUpper); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize", kExternallyStrobedEnvPreHoughBlurSize); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupCannyLower", kExternallyStrobedEnvCleanupCannyLower); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupCannyUpper", kExternallyStrobedEnvCleanupCannyUpper); + GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvCleanupPreHoughBlurSize", kExternallyStrobedEnvCleanupPreHoughBlurSize); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize", kExternallyStrobedEnvPreCannyBlurSize); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvHoughLineIntersections", kExternallyStrobedEnvHoughLineIntersections); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleLower", kExternallyStrobedEnvLinesAngleLower); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvLinesAngleUpper", kExternallyStrobedEnvLinesAngleUpper); - GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMaximumHoughLineGap", kExternallyStrobedEnvMaximumHoughLineGap); GolfSimConfiguration::SetConstant("gs_config.testing.kExternallyStrobedEnvMinimumHoughLineLength", kExternallyStrobedEnvMinimumHoughLineLength); GolfSimConfiguration::SetConstant("gs_config.ball_identification.kPlacedBallUseLargestBall", kPlacedBallUseLargestBall); + } - GolfSimConfiguration::SetConstant("gs_config.cameras.kHLAOffsetAngleDegrees", kHLAOffsetAngleDegrees); - GolfSimConfiguration::SetConstant("gs_config.cameras.kVLAOffsetAngleDegrees", kVLAOffsetAngleDegrees); + GolfSimCamera::GolfSimCamera() { } GolfSimCamera::~GolfSimCamera() { @@ -445,25 +422,6 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "GetCalibratedBall called for camera2 (usually used for camera1 images)."); } - // If we are calibrating the focal length, then override the distance to that specified in - // the JSON file - if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1BallLocation || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2BallLocation || - GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1TestStandalone) { - - if (kCamera1CalibrationDistanceToBall > 0.01) { - if (GolfSimOptions::GetCommandLineOptions().GetCameraNumber() == GsCameraNumber::kGsCamera1) { - expected_distance = kCamera1CalibrationDistanceToBall; - } - else { - expected_distance = kCamera2CalibrationDistanceToBall; - } - GS_LOG_TRACE_MSG(trace, "GetCalibratedBall overriding expected_distance. Setting to: " + std::to_string(expected_distance)); - } - } - GS_LOG_TRACE_MSG(trace, "GetCalibratedBall using expected ball distance of: " + std::to_string(expected_distance)); double expectedRadius = GetExpectedBallRadiusPixels(camera.camera_hardware_, rgbImg.cols, expected_distance); @@ -577,12 +535,10 @@ namespace golf_sim { if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate) { - if (GolfSimOptions::GetCommandLineOptions().GetCameraNumber() == GsCameraNumber::kGsCamera1) { - distance_direct_to_ball = kCamera1CalibrationDistanceToBall; - } - else { - distance_direct_to_ball = kCamera2CalibrationDistanceToBall; - } + distance_direct_to_ball = CvUtils::GetDistance( + GolfSimOptions::GetCommandLineOptions().GetCameraNumber() == GsCameraNumber::kGsCamera1 + ? kCamera1PositionsFromExpectedBallMeters + : kCamera2PositionsFromExpectedBallMeters); b.distance_to_z_plane_from_lens_ = distance_direct_to_ball; @@ -3594,16 +3550,16 @@ namespace golf_sim { // Get a good picture of the edges of the balls. Will probably have way too many shaft lines cv::Mat cannyOutput_for_balls; - cv::Canny(image_gray, cannyOutput_for_balls, kExternallyStrobedEnvCannyLower, kExternallyStrobedEnvCannyUpper); + cv::Canny(image_gray, cannyOutput_for_balls, kExternallyStrobedEnvCleanupCannyLower, kExternallyStrobedEnvCleanupCannyUpper); LoggingTools::DebugShowImage("Initial cannyOutput", cannyOutput_for_balls); - if (kExternallyStrobedEnvPreHoughBlurSize > 0) { - if (kExternallyStrobedEnvPreHoughBlurSize % 2 != 1) { - kExternallyStrobedEnvPreHoughBlurSize++; + if (kExternallyStrobedEnvCleanupPreHoughBlurSize > 0) { + if (kExternallyStrobedEnvCleanupPreHoughBlurSize % 2 != 1) { + kExternallyStrobedEnvCleanupPreHoughBlurSize++; } - cv::GaussianBlur(cannyOutput_for_balls, cannyOutput_for_balls, cv::Size(kExternallyStrobedEnvPreHoughBlurSize, kExternallyStrobedEnvPreHoughBlurSize), 0); + cv::GaussianBlur(cannyOutput_for_balls, cannyOutput_for_balls, cv::Size(kExternallyStrobedEnvCleanupPreHoughBlurSize, kExternallyStrobedEnvCleanupPreHoughBlurSize), 0); } LoggingTools::DebugShowImage("Post-Blur cannyOutput", cannyOutput_for_balls); diff --git a/Software/LMSourceCode/ImageProcessing/gs_camera.h b/Software/LMSourceCode/ImageProcessing/gs_camera.h index 80a3b38b..01ff5ae3 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_camera.h +++ b/Software/LMSourceCode/ImageProcessing/gs_camera.h @@ -129,17 +129,11 @@ namespace golf_sim { static double kMaxOverlappedBallRadiusChangeRatio; static double kMaxRadiusDifferencePercentageFromBest; - static double kCamera1CalibrationDistanceToBall; - static double kCamera2CalibrationDistanceToBall; - static double kCamera1XOffsetForTilt; static double kCamera1YOffsetForTilt; static double kCamera2XOffsetForTilt; static double kCamera2YOffsetForTilt; - static double kExpectedBallPositionXcm; - static double kExpectedBallPositionYcm; - static double kExpectedBallPositionZcm; static double kExpectedBallRadiusPixelsAt40cm; static double kMinRadiusRatio; static double kMaxRadiusRatio; @@ -174,14 +168,12 @@ namespace golf_sim { static int kExternallyStrobedEnvFilterHsvUpperS; static int kExternallyStrobedEnvFilterHsvLowerV; static int kExternallyStrobedEnvFilterHsvUpperV; - static int kExternallyStrobedEnvCannyLower; - static int kExternallyStrobedEnvCannyUpper; - static int kExternallyStrobedEnvPreHoughBlurSize; + static int kExternallyStrobedEnvCleanupCannyLower; + static int kExternallyStrobedEnvCleanupCannyUpper; + static int kExternallyStrobedEnvCleanupPreHoughBlurSize; static int kExternallyStrobedEnvPreCannyBlurSize; - static int kExternallyStrobedEnvHoughLineIntersections; static int kExternallyStrobedEnvLinesAngleLower; static int kExternallyStrobedEnvLinesAngleUpper; - static int kExternallyStrobedEnvMaximumHoughLineGap; static int kExternallyStrobedEnvMinimumHoughLineLength; @@ -199,12 +191,6 @@ namespace golf_sim { static CameraHardware::CameraOrientation kSystemSlot1CameraOrientation; static CameraHardware::CameraOrientation kSystemSlot2CameraOrientation; - // The following angles are used to adjust the final HLA and VLA to account for - // problems such as the auto-calibration rig being slightly off. - // See the HLA and VLA calculations in gs_camera.cpp for more details on the meaning of negative versus positive angles - static float kHLAOffsetAngleDegrees; - static float kVLAOffsetAngleDegrees; - static bool kUseOnlyHighQualityBallImagesForHLA; // Refers to the camera_hardware device object associated with this higher-level camera object @@ -214,6 +200,9 @@ namespace golf_sim { ~GolfSimCamera(); + // Call once, after GolfSimConfiguration::Initialize + static void LoadConfigurationValues(); + // One of the main workhorses of the system. It determines a ball in and // image by using various circle-identification algorithms and other processing. // diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp index dcc56a31..109fd386 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.cpp @@ -25,16 +25,13 @@ namespace golf_sim { // that we would want when just watching the ball to see when it moves, // but the trade off is getting the club images. // Must call Configure() before these will be set. - uint GolfSimClubData::kClubImageWidthPixels = 200; - uint GolfSimClubData::kClubImageHeightPixels = 150; + uint GolfSimClubData::kClubImageWidthPixels = 340; + uint GolfSimClubData::kClubImageHeightPixels = 200; - // The fully-qualified output directory - std::string GolfSimClubData::kClubImageOutputDir; + uint GolfSimClubData::kNumberFramesToSaveBeforeHit = 6; + uint GolfSimClubData::kNumberFramesToSaveAfterHit = 8; - uint GolfSimClubData::kNumberFramesToSaveBeforeHit = 4; - uint GolfSimClubData::kNumberFramesToSaveAfterHit = 4; - - float GolfSimClubData::kClubImageCameraGain = 30.0F; + float GolfSimClubData::kClubImageCameraGain = 40.0F; float GolfSimClubData::kClubImageShutterSpeedMultiplier = 0.4F; @@ -44,7 +41,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.club_data.kEnableClubImages", kGatherClubData); if (kGatherClubData) { - GolfSimConfiguration::SetConstant("gs_config.club_data.kEnableClubImages", kClubImageOutputDir); GolfSimConfiguration::SetConstant("gs_config.club_data.kNumberFramesToSaveBeforeHit", kNumberFramesToSaveBeforeHit); GolfSimConfiguration::SetConstant("gs_config.club_data.kNumberFramesToSaveAfterHit", kNumberFramesToSaveAfterHit); GolfSimConfiguration::SetConstant("gs_config.club_data.kClubImageWidthPixels", kClubImageWidthPixels); diff --git a/Software/LMSourceCode/ImageProcessing/gs_club_data.h b/Software/LMSourceCode/ImageProcessing/gs_club_data.h index 688665bf..4c2d782c 100755 --- a/Software/LMSourceCode/ImageProcessing/gs_club_data.h +++ b/Software/LMSourceCode/ImageProcessing/gs_club_data.h @@ -39,9 +39,6 @@ namespace golf_sim { static uint kClubImageWidthPixels; static uint kClubImageHeightPixels; - // The fully-qualified output directory - static std::string kClubImageOutputDir; - static uint kNumberFramesToSaveBeforeHit; static uint kNumberFramesToSaveAfterHit; diff --git a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp index 6a393592..c65da784 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_clubs.cpp @@ -6,8 +6,7 @@ #include "logging_tools.h" #include "gs_config.h" #include "gs_result_types.h" -#include "gs_http_client.h" -#include "gs_result_types.h" +#include "gs_ui_system.h" #include "gs_clubs.h" @@ -23,20 +22,11 @@ namespace golf_sim { void GolfSimClubs::SetCurrentClubType(GsClubType club_type) { current_club_ = club_type; - GS_LOG_MSG(info, "Club type set to " + std::string((club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver")); - - // Notify the GUI, and possibly any attached Golf Sims about the change - // TBD - We need a new type of message. - // For now, just send a zero-results message with the - // new driver setting. + std::string club_name = (club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver"; + GS_LOG_MSG(info, "Club type set to " + club_name); #ifdef __unix__ - std::string club_name = (club_type == GolfSimClubs::GsClubType::kPutter) ? "Putter" : "Driver"; - std::string json = "{\"result_type\":" + std::to_string(static_cast(GsIPCResultType::kHit)) - + ",\"message\":\"Club type was set to " + club_name + "\"" - + ",\"speed_mps\":0,\"launch_angle\":0,\"side_angle\":0" - + ",\"back_spin\":0,\"side_spin\":0,\"carry\":0,\"images\":[]}"; - GsHttpClient::PostResult(json); + GsUISystem::SendIPCStatusMessage(GsIPCResultType::kControlMessage, "Club type was set to " + club_name); #endif } diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.cpp b/Software/LMSourceCode/ImageProcessing/gs_config.cpp index eebc626f..41fd2df3 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_config.cpp @@ -11,11 +11,11 @@ #include #include #include +#include "gs_http_client.h" #include "logging_tools.h" #include "gs_camera.h" #include "gs_ui_system.h" #include "gs_config.h" -#include "configuration_manager.h" #include "gs_options.h" // Having to set the constants in this way creates more entanglement than we'd like. TBD - Re-architect @@ -30,10 +30,47 @@ namespace golf_sim { GolfSimConfiguration::EnclosureType GolfSimConfiguration::kEnclosureVersion = GolfSimConfiguration::EnclosureType::kEnclosureVersion_Unknown; - bool GolfSimConfiguration::Initialize(const std::string& configuration_filename) { +static std::string GetConfigString(const std::string& tag_name) { + std::string value; + GolfSimConfiguration::SetConstant(tag_name, value); + return value; +} + +static void PopulateOptionsFromConfig() { + GolfSimOptions& options = GolfSimOptions::GetCommandLineOptions(); + + auto apply_enum = [&options](const std::string& tag_name, bool (GolfSimOptions::*setter)(const std::string&)) { + std::string value = GetConfigString(tag_name); + if (!value.empty() && !(options.*setter)(value)) { + GS_LOG_MSG(warning, "Ignoring unrecognized value for " + tag_name + ": " + value); + } + }; + apply_enum("gs_config.player.kGolferOrientation", &GolfSimOptions::SetGolferOrientation); + apply_enum("logging.level", &GolfSimOptions::SetLoggingLevel); + apply_enum("gs_config.logging.kArtifactSaveLevel", &GolfSimOptions::SetArtifactSaveLevel); + + GolfSimConfiguration::SetConstant("gs_config.debug.kShowDebugImages", options.show_images_); + GolfSimConfiguration::SetConstant("gs_config.debug.kWaitForKeyOnImages", options.wait_for_key_on_images_); + GolfSimConfiguration::SetConstant("gs_config.player.kUsePracticeBalls", options.practice_ball_); + GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1SearchCenterX", options.search_center_x_); + GolfSimConfiguration::SetConstant("gs_config.cameras.kCamera1SearchCenterY", options.search_center_y_); +} + + bool GolfSimConfiguration::Initialize() { + + std::string config_body; +#ifdef __unix__ + config_body = GsHttpClient::FetchConfig(); +#endif + + if (config_body.empty()) { + GS_LOG_MSG(error, "GolfSimConfiguration::Initialize failed: no config. The web server must be running to serve /api/internal/config."); + return false; + } try { - boost::property_tree::read_json(configuration_filename, configuration_root_); + std::istringstream config_stream(config_body); + boost::property_tree::read_json(config_stream, configuration_root_); } catch (std::exception const& e) { @@ -41,19 +78,13 @@ namespace golf_sim { return false; } - // Initialize new ConfigurationManager for override support - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (!config_mgr.Initialize(configuration_filename)) { - GS_LOG_MSG(warning, "ConfigurationManager initialization failed, using JSON only"); - } else { - GS_LOG_MSG(info, "ConfigurationManager initialized with override support"); - } - // Read any values that we want to set early, here at initialization if (!ReadValues()) { return false; } + PopulateOptionsFromConfig(); + return true; } @@ -82,39 +113,6 @@ namespace golf_sim { } - bool GolfSimConfiguration::ReadShotInjectionData(std::vector& shots, - int & kInterShotInjectionPauseSeconds) { - try { - SetConstant("gs_config.testing.kInterShotInjectionPauseSeconds", kInterShotInjectionPauseSeconds); - - // Retrirve as many shots as are defined in the json file - boost::property_tree::ptree shots_json = configuration_root_.get_child("gs_config.testing.test_shots_to_inject"); - - int shot_number = 1; - for (boost::property_tree::ptree::iterator iter = shots_json.begin(); iter != shots_json.end(); iter++) { - // for (boost::property_tree::ptree& shot_section : shots_json) { - GsResults result; - result.shot_number_ = shot_number; - shot_number++; - - result.speed_mph_ = iter->second.get("Speed", 0); - result.hla_deg_ = iter->second.get("HLA", 0); - result.vla_deg_ = iter->second.get("VLA", 0); - result.back_spin_rpm_ = iter->second.get("BackSpin", 0); - result.side_spin_rpm_ = iter->second.get("SideSpin", 0); - result.club_type_ = GolfSimClubs::GsClubType::kNotSelected; - - shots.push_back(result); - } - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::ReadShotInjectionData failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - return true; - } - // Returns the valiue of the environment variable PITRAC_ROOT std::string GolfSimConfiguration::GetPiTracRootPath() { @@ -165,7 +163,7 @@ bool GolfSimConfiguration::ReadValues() { SetConstant("gs_config.cameras.kCamera2OffsetFromCamera1OriginMeters", GolfSimCamera::kCamera2OffsetFromCamera1OriginMeters); - int enclosure_type = 0; + int enclosure_type = 2; GolfSimConfiguration::SetConstant("gs_config.system.kEnclosureVersion", enclosure_type); kEnclosureVersion = (GolfSimConfiguration::EnclosureType)enclosure_type; @@ -197,13 +195,11 @@ bool GolfSimConfiguration::ReadValues() { SetConstant("gs_config.cameras.kCamera2CalibrateOrLocationGain", LibCameraInterface::kCamera2CalibrateOrLocationGain); SetConstant("gs_config.cameras.kCamera2ComparisonGain", LibCameraInterface::kCamera2ComparisonGain); - SetConstant("gs_config.testing.kCamera2StrobedEnvironmentGain", LibCameraInterface::kCamera2StrobedEnvironmentGain); SetConstant("gs_config.cameras.kCamera2Contrast", LibCameraInterface::kCamera2Contrast); SetConstant("gs_config.cameras.kCamera2PuttingGain", LibCameraInterface::kCamera2PuttingGain); SetConstant("gs_config.cameras.kCamera2PuttingContrast", LibCameraInterface::kCamera2PuttingContrast); SetConstant("gs_config.cameras.kCamera1StillShutterTimeuS", LibCameraInterface::kCamera1StillShutterTimeuS); SetConstant("gs_config.cameras.kCamera2StillShutterTimeuS", LibCameraInterface::kCamera2StillShutterTimeuS); - SetConstant("gs_config.cameras.kCameraMotionDetectSettings", LibCameraInterface::kCameraMotionDetectSettings); // The web server share directory isn't really a value we want to use from the .json configuration // file anymore, but for now, let's allow it as a fall-back to the command line @@ -212,7 +208,7 @@ bool GolfSimConfiguration::ReadValues() { } else { // Attempt to get the image logging directory from the .json config file - SetConstant("gs_config.user_interface.kWebServerShareDirectory", GsUISystem::kWebServerShareDirectory); + SetConstant("gs_config.ipc_interface.kWebServerShareDirectory", GsUISystem::kWebServerShareDirectory); } // If the configuration file forgot to add a "/" at the end of the logging directory, we should add it here ourselves @@ -222,87 +218,41 @@ bool GolfSimConfiguration::ReadValues() { #endif - // Read any environment variables that we may need - - std::string slot1_env = safe_getenv("PITRAC_SLOT1_CAMERA_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_TYPE environment variable was: " + slot1_env ); - if (slot1_env.empty()) { - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_TYPE environment variable was not set. Assuming default of: " + std::to_string(GolfSimCamera::kSystemSlot1CameraType)); - } else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot1_env = slot1_env.substr(0,1); -#endif - GolfSimCamera::kSystemSlot1CameraType = CameraHardware::string_to_camera_model(slot1_env); - } - - std::string slot2_env = safe_getenv("PITRAC_SLOT2_CAMERA_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT2_CAMERA_TYPE environment variable was: " + slot2_env ); - if (slot2_env.empty()) { - GS_LOG_TRACE_MSG(error, "PITRAC_SLOT2_CAMERA_TYPE must be set. Exiting."); - return false; - } else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot2_env = slot2_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot2CameraType = CameraHardware::string_to_camera_model(slot2_env); - } - - std::string slot1_lens_env = safe_getenv("PITRAC_SLOT1_LENS_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_LENS_TYPE environment variable was: " + slot1_lens_env); - if (slot1_lens_env.empty()) { - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_LENS_TYPE environment variable was not set. Assuming default of: " + std::to_string(GolfSimCamera::kSystemSlot1LensType)); - } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot1_lens_env = slot1_lens_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot1LensType = CameraHardware::string_to_lens_type(slot1_lens_env); + std::string slot1_type = GetConfigString("cameras.slot1.type"); + if (!slot1_type.empty()) { + GolfSimCamera::kSystemSlot1CameraType = CameraHardware::string_to_camera_model(slot1_type); } - std::string slot2_lens_env = safe_getenv("PITRAC_SLOT2_LENS_TYPE"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT2_LENS_TYPE environment variable was: " + slot2_lens_env); - if (slot2_lens_env.empty()) { - GS_LOG_TRACE_MSG(error, "PITRAC_SLOT2_LENS_TYPE must be set. Exiting."); + std::string slot2_type = GetConfigString("cameras.slot2.type"); + if (slot2_type.empty()) { + GS_LOG_MSG(error, "cameras.slot2.type must be set. Exiting."); return false; } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces - slot2_lens_env = slot2_lens_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot2LensType = CameraHardware::string_to_lens_type(slot2_lens_env); - } + GolfSimCamera::kSystemSlot2CameraType = CameraHardware::string_to_camera_model(slot2_type); + std::string slot1_lens = GetConfigString("cameras.slot1.lens"); + if (!slot1_lens.empty()) { + GolfSimCamera::kSystemSlot1LensType = CameraHardware::string_to_lens_type(slot1_lens); + } - std::string slot1_camera_orientation_env = safe_getenv("PITRAC_SLOT1_CAMERA_ORIENTATION"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_ORIENTATION environment variable was: " + slot1_camera_orientation_env); - if (slot1_camera_orientation_env.empty()) { - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT1_CAMERA_ORIENTATION environment variable was not set. Assuming default of: " + std::to_string(GolfSimCamera::kSystemSlot1CameraOrientation)); + std::string slot2_lens = GetConfigString("cameras.slot2.lens"); + if (slot2_lens.empty()) { + GS_LOG_MSG(error, "cameras.slot2.lens must be set. Exiting."); + return false; } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces. Visual Studio seems to add them? - slot1_camera_orientation_env = slot1_camera_orientation_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::string_to_camera_orientation(slot1_camera_orientation_env); + GolfSimCamera::kSystemSlot2LensType = CameraHardware::string_to_lens_type(slot2_lens); + + std::string slot1_orientation = GetConfigString("cameras.slot1.orientation"); + if (!slot1_orientation.empty()) { + GolfSimCamera::kSystemSlot1CameraOrientation = CameraHardware::string_to_camera_orientation(slot1_orientation); } - std::string slot2_camera_orientation_env = safe_getenv("PITRAC_SLOT2_CAMERA_ORIENTATION"); - GS_LOG_TRACE_MSG(info, "GolfSimConfiguration - PITRAC_SLOT2_CAMERA_ORIENTATION environment variable was: " + slot2_camera_orientation_env); - if (slot2_camera_orientation_env.empty()) { - GS_LOG_TRACE_MSG(error, "PITRAC_SLOT2_CAMERA_ORIENTATION must be set. Exiting."); + std::string slot2_orientation = GetConfigString("cameras.slot2.orientation"); + if (slot2_orientation.empty()) { + GS_LOG_MSG(error, "cameras.slot2.orientation must be set. Exiting."); return false; } - else { -#ifndef __unix__ // Ignore in Windows environment - // Ensure we don't have any trailing spaces - slot2_camera_orientation_env = slot2_camera_orientation_env.substr(0, 1); -#endif - GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::string_to_camera_orientation(slot2_camera_orientation_env); - } + GolfSimCamera::kSystemSlot2CameraOrientation = CameraHardware::string_to_camera_orientation(slot2_orientation); return true; @@ -316,136 +266,22 @@ bool GolfSimConfiguration::ReadValues() { } - void GolfSimConfiguration::SetConstant(const std::string& tag_name, bool& constant_value) { - // Try ConfigurationManager first for override support - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - bool val = config_mgr.GetBool(tag_name, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + (val ? "true" : "false")); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, false); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, int& constant_value) { - // Try ConfigurationManager first for override support - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - int val = config_mgr.GetInt(tag_name, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + std::to_string(val)); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, 0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, long& constant_value) { - try { - constant_value = configuration_root_.get(tag_name, 0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, unsigned int& constant_value) { - try { - constant_value = configuration_root_.get(tag_name, 0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, float& constant_value) { - // Try ConfigurationManager first for override support - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - if (config_mgr.HasKey(tag_name)) { - float val = config_mgr.GetFloat(tag_name, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + std::to_string(val)); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, 0.0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; - } - } - - void GolfSimConfiguration::SetConstant(const std::string& tag_name, double& constant_value) { - try { - constant_value = configuration_root_.get(tag_name, 0.0); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = false; + template + static void ReadScalar(const boost::property_tree::ptree& root, const std::string& tag_name, T& constant_value) { + if (auto v = root.get_optional(tag_name)) { + constant_value = *v; + } else { + GS_LOG_MSG(warning, "No config value for " + tag_name + ", keeping default"); } } - void GolfSimConfiguration::SetConstant(const std::string& tag_name, std::string& constant_value) { - // Try ConfigurationManager first for override support - ConfigurationManager& config_mgr = ConfigurationManager::GetInstance(); - - // First check if there's a mapped YAML key - std::string yaml_key = tag_name; - // Convert JSON path to potential YAML key (simplified mapping) - // e.g., "gs_config.cameras.kCamera1Gain" -> "cameras.camera1_gain" - - if (config_mgr.HasKey(yaml_key)) { - std::string val = config_mgr.GetString(yaml_key, constant_value); - if (val != constant_value) { - GS_LOG_TRACE_MSG(trace, "Override from ConfigurationManager: " + tag_name + " = " + val); - constant_value = val; - return; - } - } - - // Fall back to original JSON behavior - try { - constant_value = configuration_root_.get(tag_name, constant_value); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetConstant failed. ERROR: *** " + std::string(e.what()) + " ***"); - constant_value = ""; - } - } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, bool& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, int& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, long& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, unsigned int& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, float& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, double& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } + void GolfSimConfiguration::SetConstant(const std::string& tag_name, std::string& constant_value) { ReadScalar(configuration_root_, tag_name, constant_value); } void GolfSimConfiguration::SetConstant(const std::string& tag_name, cv::Vec3d& vec) { try { @@ -557,98 +393,4 @@ bool GolfSimConfiguration::ReadValues() { } } - bool GolfSimConfiguration::RemoveTreeNode(const std::string& tag_name) { - - try { - std::string end_node_name = "kCamera1FocalLength"; - - if (PropertyExists(tag_name)) { - configuration_root_.erase(tag_name); - return true; - } - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::RemoveTreeNode failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - - return false; - } - - bool GolfSimConfiguration::SetTreeValue(const std::string& tag_name, const cv::Vec2d& vec) { - - boost::property_tree::ptree values_node; - - try { - // RemoveTreeNode(tag_name); - - bool node_exists = PropertyExists(tag_name); - - if (node_exists) { - int i = 0; - for (boost::property_tree::ptree::value_type& element : configuration_root_.get_child(tag_name)) { - element.second.put("", vec[i++]); - } - } - else { - boost::property_tree::ptree new_node; - boost::property_tree::ptree element; - - element.put("", vec[0]); - new_node.push_back(std::make_pair("", element)); - - element.clear(); - element.put("", vec[1]); - new_node.push_back(std::make_pair("", element)); - - configuration_root_.add_child(tag_name, new_node); - } - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetTreeValue failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - - // TBD - Set the values - - return true; - } - - bool GolfSimConfiguration::SetTreeValue(const std::string& tag_name, const double value) { - - // RemoveTreeNode(tag_name); - - try { - configuration_root_.put(tag_name, value); - } - catch (std::exception const& e) - { - GS_LOG_MSG(error, "GolfSimConfiguration::SetTreeValue failed. ERROR: *** " + std::string(e.what()) + " ***"); - return false; - } - - // TBD - Set the values - - return true; - } - - bool GolfSimConfiguration::WriteTreeToFile(const std::string& file_name) { - - GS_LOG_MSG(trace, "GolfSimConfiguration::WriteTreeToFile called for file_name = " + file_name); - - std::ofstream file(file_name); - if (file.is_open()) { - boost::property_tree::write_json(file, configuration_root_); - file.close(); - } - else { - GS_LOG_MSG(error, "GolfSimConfiguration::WriteTreeToFile failed. Could not open file for writing."); - return false; - } - - return true; - } - } // namespace golf_sim diff --git a/Software/LMSourceCode/ImageProcessing/gs_config.h b/Software/LMSourceCode/ImageProcessing/gs_config.h index 978c9822..cd102132 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_config.h +++ b/Software/LMSourceCode/ImageProcessing/gs_config.h @@ -44,20 +44,18 @@ namespace golf_sim { kRPiUnknown }; - static bool Initialize(const std::string& configuration_filename = "gs_config.json"); + static bool Initialize(); // Uses a safer version of getenv when in Windows environment. static std::string safe_getenv(const std::string& varname); static PiModel GetPiModel(); - // Reads any values that need to be initialized early, such as static members of - // classes that won't otherwise have a good place to be otherwise initialized because, - // e.g., there's not constructor that will be called. static bool ReadValues(); static bool PropertyExists(const std::string& value_tag); + // The served config always wins; a variable's initializer applies only when the server omits the key. static void SetConstant(const std::string& value_tag, bool& constant_value); static void SetConstant(const std::string& value_tag, int& constant_value); static void SetConstant(const std::string& value_tag, long& constant_value); @@ -73,19 +71,6 @@ namespace golf_sim { static void SetConstant(const std::string& tag_name, cv::Mat& matrix); - static bool ReadShotInjectionData(std::vector& shots, - int& kInterShotInjectionPauseSeconds); - - // Set the specified value to the value. The node will be created if necessary - static bool SetTreeValue(const std::string& tag_name, const cv::Vec2d& vec); - static bool SetTreeValue(const std::string& tag_name, const double value); - - // Write the current json tree to the specified file - static bool WriteTreeToFile(const std::string& file_name); - - // Remove the named node in the json tree if it exists. - // Returns true if the node had previously existed, false if not - static bool RemoveTreeNode(const std::string& tag_name); // Returns the valiue of the environment variable PITRAC_ROOT static std::string GetPiTracRootPath(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp b/Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp deleted file mode 100644 index 00ae8195..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_control_msg.cpp +++ /dev/null @@ -1,49 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - - -#ifdef __unix__ // Ignore in Windows environment - -#include "logging_tools.h" - -#include "gs_control_msg.h" -#include "cv_utils.h" - -namespace golf_sim { - - GsIPCControlMsg::GsIPCControlMsg() { - } - - GsIPCControlMsg::~GsIPCControlMsg() { - } - - std::string GsIPCControlMsg::FormatControlMessageType(const GsIPCControlMsgType t) { - - std::string s; - - std::map result_table = - { { GsIPCControlMsgType::kUnknown, "Unknown" }, - { GsIPCControlMsgType::kClubChangeToPutter, "Change club to putter" }, - { GsIPCControlMsgType::kClubChangeToDriver, "Change club to driver" } - }; - - if (result_table.count(t) == 0) { - return "SYSTEM ERROR: Invalid GsIPCControlMsgType: " + std::to_string((int)t); - } - return result_table[t]; - } - - std::string GsIPCControlMsg::Format() const { - - std::string control_type = FormatControlMessageType(control_type_); - - std::string s = "GsIPCControlMsg: ControlType: " + control_type + "."; - - return s; - } - -} - -#endif // #ifdef __unix__ // Ignore in Windows environment diff --git a/Software/LMSourceCode/ImageProcessing/gs_control_msg.h b/Software/LMSourceCode/ImageProcessing/gs_control_msg.h index caa808fc..449452ba 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_control_msg.h +++ b/Software/LMSourceCode/ImageProcessing/gs_control_msg.h @@ -3,29 +3,17 @@ * Copyright (C) 2022-2025, Verdant Consultants, LLC. */ -// Base class for control messages, such as those sent between the LM and -// external systems like the web dashboard and third-party golf simulators +// Control message types used by the closed-source E6 object (gs_e6_response). +// e6_auth_shim.cpp defines FormatControlMessageType for it. #pragma once #ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include -#include -#include - -#include "logging_tools.h" - - -// The primary object for control-type communications from the Golf Sim user interface +#include namespace golf_sim { - // TBD - Add a change-player type? enum class GsIPCControlMsgType { kUnknown = 0, kClubChangeToPutter = 1, @@ -36,17 +24,8 @@ namespace golf_sim { public: - GsIPCControlMsg(); - virtual ~GsIPCControlMsg(); - - // Returns a string representation of this result - std::string Format() const; - static std::string FormatControlMessageType(const GsIPCControlMsgType t); - public: - GsIPCControlMsgType control_type_ = GsIPCControlMsgType::kUnknown; - }; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp deleted file mode 100644 index 5b4ef878..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.cpp +++ /dev/null @@ -1,271 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -// "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. -// The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - -#include - -#ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" -#include "gs_events.h" -#include "gs_control_msg.h" - -#include "gs_e6_interface.h" -#include "gs_e6_results.h" -#include "gs_e6_response.h" - -using namespace boost::asio; -using ip::tcp; - - -namespace golf_sim { - - long GsE6Interface::kE6InterMessageDelayMs = 50; - - GsE6Interface::GsE6Interface() { - - if (!GolfSimOptions::GetCommandLineOptions().e6_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().e6_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress", socket_connect_address_); - } - - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectPort", socket_connect_port_); - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6InterMessageDelayMs", kE6InterMessageDelayMs); - } - - GsE6Interface::~GsE6Interface() { - - } - - bool GsE6Interface::InterfaceIsPresent() { - // TBD - For now, just see if the JSON file has E6 information. - // If it does, assume that the interface is present and has been selected - // for use. - std::string test_socket_connect_address; - if (!GolfSimOptions::GetCommandLineOptions().e6_host_address_.empty()) { - test_socket_connect_address = GolfSimOptions::GetCommandLineOptions().e6_host_address_; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress", test_socket_connect_address); - return true; - } - else { - GS_LOG_TRACE_MSG(trace, "GsE6Interface::InterfaceIsPresent - Not Present."); - return false; - } - return (test_socket_connect_address != ""); - } - - - bool GsE6Interface::Initialize() { - // Setup the socket connect here first so - // that we don't have to repeatedly do so. May also want to - // setup a keep-alive ping to the E6 system. - GS_LOG_TRACE_MSG(trace, "GsE6Interface Initialize called."); - - // Get the connection address from the command line if possible - if (!GolfSimOptions::GetCommandLineOptions().e6_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().e6_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress", socket_connect_address_); - } - - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.E6.kE6ConnectPort", socket_connect_port_); - - if (!GsSimSocketInterface::Initialize()) { - GS_LOG_MSG(error, "GsE6Interface could not Initialize."); - return false; - } - - // Give the new thread a moment to get running - usleep(500); - - initialized_ = true; - - const std::string kE6KHandshakeMessage = "{\"Type\":\"Handshake\"}"; - - SendSimMessage(kE6KHandshakeMessage); - - // We should later receive a handshake message back - - return true; - } - - - void GsE6Interface::DeInitialize() { - - // Send disconnect message to TruGolf before we finish up - std::string results_msg = "{\"Type\":\"Disconnect\"}"; - - SendSimMessage(results_msg); - - GsSimSocketInterface::DeInitialize(); - } - - - void GsE6Interface::SetSimSystemArmed(const bool is_armed) { - boost::lock_guard lock(sim_arming_mutex_); - - GS_LOG_TRACE_MSG(trace, "GsE6Interface::SetSimSystemArmed called."); - - sim_system_is_armed_ = is_armed; - } - - bool GsE6Interface::GetSimSystemArmed() { - boost::lock_guard lock(sim_arming_mutex_); - - return sim_system_is_armed_; - } - - - bool GsE6Interface::SendResults(const GsResults& input_results) { - - GS_LOG_TRACE_MSG(trace, "GsE6Interface::SendResults called."); - - if (!initialized_) { - GS_LOG_MSG(error, "GsE6Interface::SendResults called before the interface was intialized."); - return false; - } - - if (!GetSimSystemArmed()) { - GS_LOG_MSG(warning, "GsE6Interface::SendResults called before the E6 system was armed."); - return false; - } - - if (receive_thread_exited_) { - // If we ended the recieve thread, try re-initializing the connection - - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - - DeInitialize(); - if (!Initialize()) { - GS_LOG_MSG(error, "GsE6Interface::SendResults called before the interface was intialized."); - return false; - } - } - - GsE6Results results(input_results); - - size_t write_length = -1; - - std::string results_msg = results.Format(); - - GS_LOG_MSG(info, "Sending E6 shot results message:\n" + results_msg); - write_length = SendSimMessage(results_msg); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::SendResults was not able to send Ball Data."); - return false; - } - - // E6 also requires SendClubData and a SendShot messages along with the ball data - // Give E6 a moment to proces the earlier message - usleep(kE6InterMessageDelayMs * 1000); - - boost::property_tree::ptree root; - root.put("Type", "SetClubData"); - - // Create the three required children objects and add to the JSON root - boost::property_tree::ptree club_data_child; - - // Create a dummy club data - we really don't have this information - // Head speed is feet per second - club_data_child.put("ClubHeadSpeed", GsResults::FormatDoubleAsString(0.0)); // (results.speed_mph_ / 3600.) * 5280.)); - club_data_child.put("ClubAngleFace", GsResults::FormatDoubleAsString(0.0)); - club_data_child.put("ClubAnglePath", GsResults::FormatDoubleAsString(0.0)); - club_data_child.put("ClubHeadSpeedMPH", GsResults::FormatDoubleAsString(0.0)); // results.speed_mph_)); - - root.add_child("ClubData", club_data_child); - - std::string club_data_message = GsE6Results::GenerateStringFromJsonTree(root); - - if (club_data_message == "") { - GS_LOG_MSG(warning, "GsE6Results::Format() returning empty string."); - } - - write_length = SendSimMessage(club_data_message); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::SendResults was not able to send Club Data."); - return false; - } - - // ShotData - usleep(kE6InterMessageDelayMs * 1000); - - results_msg = "{\"Type\":\"SendShot\"}"; - - write_length = SendSimMessage(results_msg); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::SendResults was not able to send SendShot message."); - return false; - } - - // If we successfully sent a shot, we assume that E6 is no longer armed - SetSimSystemArmed(false); - - GS_LOG_TRACE_MSG(trace, "Finished Sending E6 results input message:\n" + results.Format()); - - return true; - } - - - std::string GsE6Interface::GenerateResultsDataToSend(const GsResults& input_results) { - - GsE6Results e6_results(input_results); - - std::string results_string = e6_results.Format(); - - GS_LOG_TRACE_MSG(trace, "GsE6Interface::GenerateResultsDataToSend) returning:\n" + results_string); - - return results_string; - } - - - bool GsE6Interface::ProcessReceivedData(const std::string received_data) { - - GsE6Response e6_response; - std::string e6_response_string; - - if (!e6_response.ProcessJson(received_data, e6_response_string)) { - GS_LOG_MSG(error, "Failed GsE6Interface::ProcessReceivedData - Could not process json: " + received_data); - return false; - } - - if (e6_response_string == "") { - // GS_LOG_TRACE_MSG(trace, "GsE6Interface::ProcessReceivedData had no response string to return to E6"); - return true; - } - else { - GS_LOG_TRACE_MSG(trace, "GsE6Interface::ProcessReceivedData about to send response of: " + e6_response_string); - - size_t write_length = SendSimMessage(e6_response_string); - - if (write_length <= 0) { - GS_LOG_MSG(error, "GsE6Interface::ProcessReceivedData failed to send date message: " + e6_response_string); - return false; - } - } - - return true; - } - - - - -} -#endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.h b/Software/LMSourceCode/ImageProcessing/gs_e6_interface.h deleted file mode 100644 index e8418530..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_interface.h +++ /dev/null @@ -1,54 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - - // "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. - // The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - -#pragma once - - // Base class for representing and transferring Golf Sim results to the E6 golf simulator - -#include - -#include "gs_results.h" -#include "gs_sim_socket_interface.h" - -using namespace boost::asio; -using ip::tcp; - -namespace golf_sim { - - class GsE6Interface : public GsSimSocketInterface { - - public: - GsE6Interface(); - virtual ~GsE6Interface(); - - // Returns true iff the GSPro interface is to be used - static bool InterfaceIsPresent(); - - // Must be called before SendResults is called. - virtual bool Initialize(); - - // Deals with, for example, shutting down any socket connection - virtual void DeInitialize(); - - virtual bool SendResults(const GsResults& results); - - virtual void SetSimSystemArmed(const bool is_armed); - virtual bool GetSimSystemArmed(); - - protected: - - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - virtual bool ProcessReceivedData(const std::string received_data); - - protected: - static long kE6InterMessageDelayMs; - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp b/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp index 861cb559..aa030f79 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_e6_response.cpp @@ -20,9 +20,7 @@ #include "gs_events.h" #include "gs_control_msg.h" -#include "gs_e6_interface.h" #include "gs_e6_response.h" -#include "gs_e6_results.h" namespace golf_sim { diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp b/Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp deleted file mode 100644 index 6171d693..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_results.cpp +++ /dev/null @@ -1,78 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -// "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. -// The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - -#include -#include -#include - -#include "logging_tools.h" - -#include "gs_e6_results.h" - -namespace golf_sim { - - GsE6Results::GsE6Results() { - } - - GsE6Results::GsE6Results(const GolfBall& ball) : GsResults(ball) { - } - - // TBD - Copy constructor? - GsE6Results::GsE6Results(const GsResults& results) : GsResults(results) { - } - - - GsE6Results::~GsE6Results() { - - } - - - std::string GsE6Results::Format() const { - // Create a JSON object based on https://e6golf.com/E6ConnectV1.html - // Create a property tree object - boost::property_tree::ptree root; - - // Create the three required children objects and add to the JSON root - boost::property_tree::ptree ball_data_child; - boost::property_tree::ptree club_data_child; - boost::property_tree::ptree shot_data_options_child; - - // Root-level values - root.put("Type", "SetBallData"); - - // E6 Enforces certain ranges. Make sure we do, too - int back_spin_rpm = std::min(back_spin_rpm_, 19999); - back_spin_rpm = std::max(back_spin_rpm_, -999); - - float speed_mph = std::min(speed_mph_, (float)249.9); - speed_mph = std::max(speed_mph_, (float)0.09); - - int side_spin_rpm = std::min(side_spin_rpm_, 5999); - side_spin_rpm = std::max(side_spin_rpm_, -5999); - - - // Ball data - some of the values such as tilt are not required and we don't include them - ball_data_child.put("BackSpin", FormatDoubleAsString(back_spin_rpm_)); - ball_data_child.put("BallSpeed", FormatDoubleAsString(speed_mph_)); - ball_data_child.put("LaunchAngle", FormatDoubleAsString(vla_deg_)); - ball_data_child.put("LaunchDirection", FormatDoubleAsString(hla_deg_)); - ball_data_child.put("SideSpin", FormatDoubleAsString(side_spin_rpm_)); - - root.add_child("BallData", ball_data_child); - - std::string result = GenerateStringFromJsonTree(root); - - if (result == "") { - GS_LOG_MSG(warning, "GsE6Results::Format() returning empty string."); - } - - return result; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_e6_results.h b/Software/LMSourceCode/ImageProcessing/gs_e6_results.h deleted file mode 100644 index decbdc9e..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_e6_results.h +++ /dev/null @@ -1,31 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include "gs_results.h" - -// "TruGolf Simulators" and other marks such as E6 may be trademarked by TruGolf, Inc. -// The PiTrac project is not endorsed, sponsored by or associated with TrueGolf products or services. - - - -// Base class for representing and transferring Golf Sim results - -namespace golf_sim { - - class GsE6Results : public GsResults { - - public: - GsE6Results(); - GsE6Results(const GolfBall& ball); - GsE6Results(const GsResults& results); - virtual ~GsE6Results(); - // Will enforce range limits - virtual std::string Format() const; - - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_events.cpp b/Software/LMSourceCode/ImageProcessing/gs_events.cpp index c086e6b5..000d1186 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_events.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_events.cpp @@ -40,10 +40,6 @@ namespace golf_sim { return (dynamic_cast(event) != nullptr); } - bool GolfSimEventQueue::EventIsControlEvent(GolfSimEventBase* event) { - return (dynamic_cast(event) != nullptr); - } - // Down-cast a specific derived event type into the PossibleEvent variant type // TBD - Seems really clunky - how to improve? PossibleEvent GolfSimEventQueue::ConvertEventToPossibleEvent(GolfSimEventBase *event) { @@ -55,7 +51,6 @@ namespace golf_sim { GolfSimEvent::EventLoopTick* eventLoopTick = nullptr; GolfSimEvent::BallStabilized* ballStabilized = nullptr; GolfSimEvent::BallHit* ballHit = nullptr; - GolfSimEvent::ControlMessage* controlMessage = nullptr; GolfSimEvent::FoundMultipleBalls* foundMultipleBalls = nullptr; GolfSimEvent::Camera2ImageReceived* cam2ImageReceived = nullptr; GolfSimEvent::Restart* restart = nullptr; @@ -83,9 +78,6 @@ namespace golf_sim { else if ((ballHit = dynamic_cast(event))) { possible_event = *ballHit; } - else if ((controlMessage = dynamic_cast(event))) { - possible_event = *controlMessage; - } else if ((beginWatchingForBallHit = dynamic_cast(event))) { possible_event = *beginWatchingForBallHit; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_events.h b/Software/LMSourceCode/ImageProcessing/gs_events.h index 95370e8f..299e73c4 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_events.h +++ b/Software/LMSourceCode/ImageProcessing/gs_events.h @@ -95,6 +95,7 @@ namespace golf_sim { GolfBall ball_; cv::Mat ball_hit_image_; }; + // Only the closed-source E6 object builds these; pitrac_lm never queues one. class ControlMessage : public GolfSimEventBase { public: @@ -189,7 +190,6 @@ namespace golf_sim { GolfSimEvent::CheckForBallStable, GolfSimEvent::BallStabilized, GolfSimEvent::BallHit, - GolfSimEvent::ControlMessage, GolfSimEvent::BeginWatchingForBallHit, GolfSimEvent::FoundMultipleBalls, GolfSimEvent::CheckForCam2ImageReceived, @@ -232,8 +232,6 @@ namespace golf_sim { static bool EventIsShutdownEvent(GolfSimEventBase* event); - static bool EventIsControlEvent(GolfSimEventBase* event); - // Not thread safe static int GetQueueLength(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp index e583c244..08b7e5cc 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.cpp @@ -26,7 +26,6 @@ #include "ball_image_proc.h" #include "gs_http_client.h" #include "gs_ui_system.h" -#include "gs_sim_interface.h" #include "pulse_strobe.h" #include "libcamera_interface.h" @@ -38,6 +37,14 @@ namespace golf_sim { static Camera2Thread g_cam2_thread; + // Only the FSM applies the sims' club, and only where a change cannot land mid-shot + static void ApplySimClub() { + GolfSimClubs::GsClubType sim_club = GsUISystem::SimClub(); + if (sim_club != GolfSimClubs::kNotSelected && sim_club != GolfSimClubs::GetCurrentClubType()) { + GolfSimClubs::SetCurrentClubType(sim_club); + } + } + static int signal_received; static void default_signal_handler(int signal_number) { @@ -47,7 +54,7 @@ namespace golf_sim { GolfSimGlobals::golf_sim_running_ = false; } - static long kMaxCam2ImageReceivedTimeMs = 2000; + static long kMaxCam2ImageReceivedTimeMs = 40000; const int kWaitForBallPauseMs = 500; const int kEventLoopPauseMs = 5000; @@ -128,8 +135,12 @@ namespace golf_sim { // Let the monitor interface know what's happening GsUISystem::SendIPCStatusMessage(GsIPCResultType::kInitializing); + // A restart abandons any in-progress shot (cam2 timeout, trigger failure) without + // flushing a result POST, so drop its stale shot id/images before the next attempt. + GsUISystem::ResetCurrentShot(); + // If we're already armed, just start waiting for a ball to appear. - if (GsSimInterface::GetAllSystemsArmed()) { + if (GsUISystem::SimArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; GolfSimEventQueue::QueueEvent(beginWaitingForBallPlacedEvent); @@ -167,7 +178,6 @@ namespace golf_sim { // TBD - see if we need to move this back to the initializating state if (!waitingForBallState.already_sent_waiting_ipc_message) { GsUISystem::SendIPCStatusMessage(GsIPCResultType::kWaitingForBallToAppear); - GsSimInterface::SendHeartbeat(false); } // This check will be called repeatedly by re-queuing events. @@ -185,8 +195,6 @@ namespace golf_sim { } if (found) { - // Inform connected sims that the ball is on the tee. - GsSimInterface::SendHeartbeat(true); if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate) { @@ -287,7 +295,11 @@ namespace golf_sim { GS_LOG_MSG(info, "=============== Ball Stabilized - Let's Play Golf! (Waiting for hit)\n\n"); // Whatever happens, this is a new shot with a new shot number - GsSimInterface::IncrementShotCounter(); + GsUISystem::IncrementShotCounter(); + + // The last POST before arming was the stabilization status, so a club the sims picked + // since the previous shot applies here and then holds through the hit and its analysis. + ApplySimClub(); // Arm Camera2 thread to start waiting for the external trigger g_cam2_thread.arm(); @@ -308,7 +320,7 @@ namespace golf_sim { if (GolfSimCamera::kLogDiagnosticImagesToUniqueFiles) { // Save a unique version of the webserver image into a directory that will not get // over-written. A unique timestamp will be added to the file name - LoggingTools::LogImage(kWebServerLastTeedBallImage + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GsSimInterface::GetShotCounter())); + LoggingTools::LogImage(kWebServerLastTeedBallImage + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GsUISystem::GetShotCounter())); } @@ -344,7 +356,7 @@ namespace golf_sim { // Wait a moment so that we're not spinning too much sleep(1); - if (GsSimInterface::GetAllSystemsArmed()) { + if (GsUISystem::SimArmed()) { GolfSimEventElement beginWaitingForBallPlacedEvent{ new GolfSimEvent::BeginWaitingForBallPlaced{ } }; GolfSimEventQueue::QueueEvent(beginWaitingForBallPlacedEvent); @@ -453,7 +465,7 @@ namespace golf_sim { GsUISystem::SendIPCErrorStatusMessage("GolfSim FSM could not ProcessReceivedCam2Image."); - GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GsSimInterface::GetShotCounter()) + ", (carry - Error), (Total - Error), (Side Dest - Error), (Smash Factor - Error), (Club Speed - Error), " + GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GsUISystem::GetShotCounter()) + ", (carry - Error), (Total - Error), (Side Dest - Error), (Smash Factor - Error), (Club Speed - Error), " + std::to_string(0) + ", " + std::to_string(0) + ", " + std::to_string(0) + ", " @@ -466,13 +478,6 @@ namespace golf_sim { else { GS_LOG_TRACE_MSG(trace, "Received and processed cam2ImageReceived. Now sending Results to any connected Golf Simulator"); - GsResults results(result_ball); - - - // Get the result to the golf simulator ASAP - if (!GsSimInterface::SendResultsToGolfSims(results)) { - GS_LOG_MSG(error, "GolfSim FSM could not SendResultsToGolfSim."); - } GS_LOG_TRACE_MSG(trace, "Received and processed cam2ImageReceived. Now sending an IPC Results Message:"); @@ -482,17 +487,21 @@ namespace golf_sim { auto velocity_time_period_string = GS_FORMATLIB_FORMAT("{: <6.2f}", velocity_time_period); s = " Time between chosen images for velocity calculation: " + velocity_time_period_string + " ms."; - GsUISystem::SendIPCHitMessage(result_ball, s); - -#ifdef __unix__ + // Save the exposure-candidates image and announce it BEFORE SendIPCHitMessage: + // that call flushes the accumulated image paths into the POST and resets the + // shot id, so any image saved after it would land under a fresh shot. +#ifdef __unix__ if (exposures_image.empty()) { GS_LOG_MSG(warning, "Exposures_image from ProcessReceivedCamera2 was empty."); } GsUISystem::SaveWebserverImage(GsUISystem::kWebServerResultBallExposureCandidates, exposures_image, exposure_balls); - GsHttpClient::PostImageReady(GsUISystem::kWebServerResultBallExposureCandidates + ".png"); + GsHttpClient::PostImageReady( + GsUISystem::CurrentShotRelativePath(GsUISystem::kWebServerResultBallExposureCandidates + ".png")); #endif + GsUISystem::SendIPCHitMessage(result_ball, s); + } // Setup to go through the whole sequence again @@ -621,26 +630,6 @@ namespace golf_sim { } - bool ProcessControlMessageEvent(GolfSimEvent::ControlMessage &control_message) { - - GsIPCControlMsgType message_type = control_message.message_type_; - - GS_LOG_TRACE_MSG(trace, "Processing ControlMessage of type: " + GsIPCControlMsg::FormatControlMessageType(message_type)); - - if (message_type == GsIPCControlMsgType::kClubChangeToPutter) { - GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); - } - else if (message_type == GsIPCControlMsgType::kClubChangeToDriver) { - GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kDriver); - } - else { - GS_LOG_MSG(error, "Received ControlMessage event with unknown message type."); - } - - return true; - } - - bool RunGolfSimFsm( const GolfSimState& starting_state ) { GS_LOG_TRACE_MSG(trace, "RunGolfSimFsm"); @@ -655,6 +644,9 @@ namespace golf_sim { return false; } + // A club the sims named in reply to the startup Initializing post replaces the configured default + ApplySimClub(); + GolfSimConfiguration::SetConstant("gs_config.ipc_interface.kMaxCam2ImageReceivedTimeMs", kMaxCam2ImageReceivedTimeMs); GolfSimConfiguration::SetConstant("gs_config.user_interface.kWebServerCamera2Image", kWebServerCamera2Image); @@ -704,21 +696,6 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "----------- Shutting Down - Received Exit Event -------------"); GolfSimGlobals::golf_sim_running_ = false; } - else if (GolfSimEventQueue::EventIsControlEvent(eventElement.e_)) { - GS_LOG_TRACE_MSG(trace, "----------- Received Control Event -------------"); - - GolfSimEvent::ControlMessage* control_message = dynamic_cast(eventElement.e_); - - if (control_message == nullptr) { - GS_LOG_MSG(error, "Could not get ControlMessage event."); - continue; - } - - if (!ProcessControlMessageEvent(*control_message)) { - GS_LOG_MSG(error, "Could not ProcessControlMessageEvent."); - continue; - } - } else { // Let the FSM handle the event golfSim.processEvent(e); @@ -801,8 +778,6 @@ namespace golf_sim { std::this_thread::yield(); - GsSimInterface::DeInitializeSims(); - GS_LOG_TRACE_MSG(trace, "System shutdown complete"); PulseStrobe::DeinitGPIOSystem(); @@ -819,8 +794,6 @@ namespace golf_sim { // These modes use test images or simulate functionality without cameras bool skip_camera = (mode == SystemMode::kTest || mode == SystemMode::kTestSpin || - mode == SystemMode::kTestExternalSimMessage || - mode == SystemMode::kTestGSProServer || mode == SystemMode::kAutomatedTesting); if (!skip_camera) { @@ -833,7 +806,7 @@ namespace golf_sim { GS_LOG_MSG(info, "Skipping camera initialization for test mode: " + std::to_string(mode)); } - GsHttpClient::Init(); + GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); GsUISystem::SendIPCStatusMessage(GsIPCResultType::kInitializing); if (!PulseStrobe::InitGPIOSystem(default_signal_handler)) { @@ -841,11 +814,6 @@ namespace golf_sim { return false; } - if (!GsSimInterface::InitializeSims()) { - GS_LOG_MSG(error, "Failed to Initialize the Golf Simulator Interface."); - return false; - } - // Start Camera2 capture thread for the main shot-detection modes // Wait for Camera2's pipeline to finish before Camera1 opens — // libcamera's pipeline handler is not thread-safe for concurrent setup. diff --git a/Software/LMSourceCode/ImageProcessing/gs_fsm.h b/Software/LMSourceCode/ImageProcessing/gs_fsm.h index 92eff934..2ab84fc0 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_fsm.h +++ b/Software/LMSourceCode/ImageProcessing/gs_fsm.h @@ -86,10 +86,6 @@ namespace golf_sim { // This function is really the brains of the operation. bool RunGolfSimFsm(const GolfSimState& starting_state); - // Control messages are external messages coming to the system. - // Currently driver/putter changes are the only such messages. - bool ProcessControlMessageEvent(GolfSimEvent::ControlMessage &event); - bool PerformSystemStartupTasks(); bool PerformSystemShutdownTasks(); diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp deleted file mode 100644 index a9095f27..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.cpp +++ /dev/null @@ -1,222 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include - -#ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" -#include "gs_events.h" -#include "gs_control_msg.h" - -#include "gs_gspro_interface.h" -#include "gs_gspro_response.h" -#include "gs_gspro_results.h" - -using namespace boost::asio; -using ip::tcp; - - -namespace golf_sim { - - - GsGSProInterface::GsGSProInterface() { - - // We prefer the command-line setting even if there's one in the .json config file - if (!GolfSimOptions::GetCommandLineOptions().gspro_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().gspro_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", socket_connect_address_); - } - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", socket_connect_port_); - } - - GsGSProInterface::~GsGSProInterface() { - - } - - bool GsGSProInterface::InterfaceIsPresent() { - - // TBD - For now, just see if the command line has GSPro information. - // If it does, assume that the interface is present and has been selected - // for use. We are not going to use the address in the .json file any more, - // though we will use the port and other settings that are not likely to change. - std::string test_socket_connect_address; - - if (!GolfSimOptions::GetCommandLineOptions().gspro_host_address_.empty()) { - test_socket_connect_address = GolfSimOptions::GetCommandLineOptions().gspro_host_address_; - GS_LOG_TRACE_MSG(trace, "GsGSProInterface::InterfaceIsPresent - kGSProConnectAddress=" + test_socket_connect_address); - return true; - } - else { - GS_LOG_TRACE_MSG(trace, "GsGSProInterface::InterfaceIsPresent - Not Present"); - return false; - } - - return (test_socket_connect_address != ""); - } - - - - bool GsGSProInterface::Initialize() { - // Setup the socket connect here first so - // that we don't have to repeatedly do so. May also want to - // setup a keep-alive ping to the GSPro system. - GS_LOG_TRACE_MSG(trace, "GsGSProInterface Initialize called."); - - if (!GolfSimOptions::GetCommandLineOptions().gspro_host_address_.empty()) { - socket_connect_address_ = GolfSimOptions::GetCommandLineOptions().gspro_host_address_; - } - else { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", socket_connect_address_); - } - - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", socket_connect_port_); - - if (!GsSimSocketInterface::Initialize()) { - GS_LOG_MSG(error, "GsGSProInterface could not Initialize."); - return false; - } - -#ifdef __unix__ // Ignore in Windows environment - // Give the new thread a moment to get running - usleep(500); -#endif - - initialized_ = true; - - // Send an initial "I'm alive" message - GsGSProResults keep_alive_results; - keep_alive_results.result_message_is_keepalive_ = true; - - // TBD - Currently, it doesn't appear we get a response for a keep-alive ? - SendResults(keep_alive_results); - return true; - } - - void GsGSProInterface::DeInitialize() { - - // TBD - Send disconnect message to TruGolf before we finish up - /* - results_msg = "{\"Type\":\"Disconnect\"}"; - - SendSimMessage(results_msg); - */ - GsSimSocketInterface::DeInitialize(); - } - - - void GsGSProInterface::SetSimSystemArmed(const bool is_armed) { - boost::lock_guard lock(sim_arming_mutex_); - - GS_LOG_TRACE_MSG(trace, "GsGSProInterface::SetSimSystemArmed called."); - - sim_system_is_armed_ = is_armed; - } - - bool GsGSProInterface::GetSimSystemArmed() { - boost::lock_guard lock(sim_arming_mutex_); - - // The GSPro system is always ready to receive shot information, - // at least as far as we know - return true; - } - - bool GsGSProInterface::SendResults(const GsResults& input_results) { - - if (!initialized_) { - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - return false; - } - - if (receive_thread_exited_) { - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - - // If we ended the receive thread, try re-initializing the connection - DeInitialize(); - if (!Initialize()) { - GS_LOG_MSG(error, "GsGSProInterface::SendResults called before the interface was intialized."); - return false; - } - } - - GsGSProResults results(input_results); - - GS_LOG_TRACE_MSG(trace, "Sending GSPro results input message:\n" + results.Format()); - - try { - static std::array buf; - boost::system::error_code error; - - std::string results_msg = results.Format(); - - size_t write_length = SendSimMessage(results_msg); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestExternalSimMessage - Error was: " + std::string(e.what())); - return false; - } - - return true; - } - - - - std::string GsGSProInterface::GenerateResultsDataToSend(const GsResults& input_results) { - - GsGSProResults gspro_results(input_results); - return gspro_results.Format(); - } - - bool GsGSProInterface::ProcessReceivedData(const std::string received_data) { - - GsGSProResponse gspro_response; - if (!gspro_response.ParseJson(received_data)) { - GS_LOG_MSG(error, "Failed TestExternalSimMessage - Could not parse json: " + received_data); - return false; - } - - // May need to enter a club-change control message - if (gspro_response.return_code_ == GsGSProResponse::ReturnCode::kPlayerInformation) { - GS_LOG_MSG(info, "Received GSPro kPlayerInformation Result of: \n" + gspro_response.Format()); - - GsIPCControlMsgType club_instruction; - - if (gspro_response.player_club_ == GsGSProResponse::PlayerClub::kPutter) { - club_instruction = GsIPCControlMsgType::kClubChangeToPutter; - } - else if (gspro_response.player_club_ == GsGSProResponse::PlayerClub::kDriver) { - club_instruction = GsIPCControlMsgType::kClubChangeToDriver; - } - else { - GS_LOG_MSG(warning, "Received GSPro unknown club information. Player_club was: " + std::to_string((int)gspro_response.player_club_)); - club_instruction = GsIPCControlMsgType::kUnknown; - } - - // The the instruction to switch clubs to the main FSM - GolfSimEventElement control_message{ new GolfSimEvent::ControlMessage{ club_instruction } }; - GolfSimEventQueue::QueueEvent(control_message); - } - else { - GS_LOG_MSG(info, "GsSimSocketInterface::ProcessReceivedData Received unknown GSPro result type. Result was: \n" + gspro_response.Format()); - } - - return true; - } - - - - -} -#endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h deleted file mode 100644 index 64f34699..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_interface.h +++ /dev/null @@ -1,47 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include - -#include "gs_results.h" -#include "gs_sim_socket_interface.h" - -using namespace boost::asio; -using ip::tcp; - -// Base class for representing and transferring Golf Sim results - -namespace golf_sim { - - class GsGSProInterface : public GsSimSocketInterface { - - public: - GsGSProInterface(); - virtual ~GsGSProInterface(); - - // Returns true iff the GSPro interface is to be used - static bool InterfaceIsPresent(); - - // Must be called before SendResults is called. - virtual bool Initialize(); - - // Deals with, for example, shutting down any socket connection - virtual void DeInitialize(); - - virtual bool SendResults(const GsResults& results); - - virtual void SetSimSystemArmed(const bool is_armed); - virtual bool GetSimSystemArmed(); - - protected: - - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - virtual bool ProcessReceivedData(const std::string received_data); - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp deleted file mode 100644 index 48be3a81..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.cpp +++ /dev/null @@ -1,130 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include -#include - -#include "logging_tools.h" - -#include "gs_gspro_response.h" - -namespace golf_sim { - - GsGSProResponse::GsGSProResponse() { - } - - - bool GsGSProResponse::ParseJson(const std::string& gspro_json_string) { - - int return_code = 0; - std::string message_str; - std::string handedness_str; - std::string club_str; - - std::stringstream ss; - ss << gspro_json_string; - - try { - boost::property_tree::ptree pt; - boost::property_tree::read_json(ss, pt); - - GS_LOG_TRACE_MSG(trace, "GsGSProResponse::Parsing return_code and message_str."); - - return_code = pt.get("Code", 0); - message_str = pt.get("Message", ""); - - // Player handedness and club may not be set - boost::optional< boost::property_tree::ptree& > child = pt.get_child_optional("Player"); - if (!child) { - GS_LOG_MSG(warning, "GsGSProResponse::ParseJson - No player information was provided."); - } - else { - handedness_str = pt.get("Player.Handed", ""); - club_str = pt.get("Player.Club", ""); - } - } - catch (std::exception const& e) - { - // For now, return true even if we failed - TBD - need to figure out what garbage at end means - // from the boost library - GS_LOG_MSG(error, "GsGSProResponse::ParseJson failed to parse GSPro response: " + std::string(e.what())); - return true; - } - - message_ = message_str; - - if (handedness_str == "RH") { - player_handed_ = PlayerHandedness::kRightHanded; - } - else if (handedness_str == "LH") { - player_handed_ = PlayerHandedness::kLeftHanded; - } - else { - // Don't bail even if this is not set - GS_LOG_MSG(warning, "GsGSProResponse::ParseJson received unknown player handedness value from GSPro response:" + handedness_str); - } - - if (club_str == "DR") { - player_club_ = PlayerClub::kDriver; - } - else if (club_str == "PT") { - player_club_ = PlayerClub::kPutter; - } - else { - GS_LOG_MSG(warning, "GsGSProResponse::ParseJson received unknown player club value from GSPro response:" + club_str + ". Defaulting to Driver"); - player_club_ = PlayerClub::kDriver; - } - - switch (return_code) { - case 200: { - return_code_ = kShotReceivedSuccessfully; - break; - } - - case 201: { - return_code_ = kPlayerInformation; - break; - } - - case 501: { - return_code_ = k501Failure; - break; - } - - default: { - if (return_code == 500 || - (return_code > 501 && return_code < 600)) { - return_code_ = kShotOtherFailure; - } - else { - GS_LOG_MSG(error, "Received unknown return_code response from GSPro: " + std::to_string(return_code)); - return_code_ = kShotOtherFailure; - } - break; - } - } - - return true; - } - - GsGSProResponse::~GsGSProResponse() { - - } - - std::string GsGSProResponse::Format() const { - std::string s; - - std::string handed_str = (player_handed_ == PlayerHandedness::kLeftHanded) ? "LH" : "RH"; - std::string club_str = (player_club_ == PlayerClub::kDriver) ? "Driver" : "Putter"; - - s += "Return Code: " + std::to_string(return_code_) + "."; - s += " Message: " + message_ + "\n"; - s += " Player.Handed: " + handed_str; - s += " Player.Club: " + club_str; - - return s; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_response.h deleted file mode 100644 index 175901ef..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_response.h +++ /dev/null @@ -1,46 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - - -// Class for representing, parsing and transferring Golf Sim responses from GsPro - -namespace golf_sim { - - class GsGSProResponse { - - public: - enum ReturnCode { - kShotReceivedSuccessfully = 200, - kPlayerInformation = 201, - k501Failure = 501, - kShotOtherFailure = 599 - }; - - enum PlayerHandedness { - kRightHanded = 0, - kLeftHanded = 1 - }; - - enum PlayerClub { - kDriver = 0, - kPutter = 1 - }; - - public: - GsGSProResponse(); - bool ParseJson(const std::string& gspro_json_string); - virtual ~GsGSProResponse(); - virtual std::string Format() const; - - ReturnCode return_code_ = ReturnCode::kShotReceivedSuccessfully; - std::string message_ = "Not Set"; - PlayerHandedness player_handed_ = PlayerHandedness::kRightHanded; - PlayerClub player_club_ = PlayerClub::kDriver; - - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp deleted file mode 100644 index 2709fb14..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.cpp +++ /dev/null @@ -1,103 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include -#include "gs_format_lib.h" -#include -#include - -#include "logging_tools.h" - -#include "gs_gspro_results.h" - -namespace golf_sim { - - GsGSProResults::GsGSProResults() { - } - - GsGSProResults::GsGSProResults(const GolfBall& ball) : GsResults(ball) { - } - - // TBD - Copy constructor? - GsGSProResults::GsGSProResults(const GsResults& results) : GsResults(results) { - } - - - GsGSProResults::~GsGSProResults() { - - } - - - std::string GsGSProResults::Format() const { - // Create a JSON object based on https://gsprogolf.com/GSProConnectV1.html - // Create a property tree object - boost::property_tree::ptree root; - - // Create the three required children objects and add to the JSON root - boost::property_tree::ptree ball_data_child; - boost::property_tree::ptree club_data_child; - boost::property_tree::ptree shot_data_options_child; - - // Root-level values - root.put("DeviceID", "PiTrac LM 0.1"); - root.put("Units", "Yards"); - // The shot number should be increased with each shot - root.put("ShotNumber", shot_number_); - root.put("APIversion", "1"); - - // Ball data - some of these values are not required - ball_data_child.put("Speed", FormatDoubleAsString(speed_mph_)); - ball_data_child.put("SpinAxis", FormatDoubleAsString(GetSpinAxis())); - ball_data_child.put("TotalSpin", "0.0"); - ball_data_child.put("BackSpin", FormatDoubleAsString(back_spin_rpm_)); - ball_data_child.put("SideSpin", FormatDoubleAsString(side_spin_rpm_)); - ball_data_child.put("HLA", FormatDoubleAsString(hla_deg_)); - ball_data_child.put("VLA", FormatDoubleAsString(vla_deg_)); - // ball_data_child.put("CarryDistance", 0.0); - - // Club data - we don't currently implement any of this, but - // just to be safe, we will still send the information - club_data_child.put("Speed", "0.0"); - club_data_child.put("AngleOfAttack", "0.0"); - club_data_child.put("FaceToTarget", "0.0"); - club_data_child.put("Lie", "0.0"); - club_data_child.put("Loft", "0.0"); - club_data_child.put("Path", "0.0"); - club_data_child.put("SpeedAtImpact", "0.0"); - club_data_child.put("VerticalFaceImpact", "0.0"); - club_data_child.put("HorizontalFaceImpact", "0.0"); - club_data_child.put("ClosureRate", "0.0"); - - if (!result_message_is_keepalive_) { - // Only the ball data is valid - shot_data_options_child.put("ContainsBallData", true); - shot_data_options_child.put("ContainsClubData", false); - // TBD - Consider if we want to send the next two values in a heartbeat? - shot_data_options_child.put("LaunchMonitorIsReady", true); - shot_data_options_child.put("LaunchMonitorBallDetected", heartbeat_ball_detected_); - shot_data_options_child.put("IsHeartBeat", false); - } - else { - shot_data_options_child.put("ContainsBallData", false); - shot_data_options_child.put("ContainsClubData", false); - shot_data_options_child.put("LaunchMonitorIsReady", true); - shot_data_options_child.put("LaunchMonitorBallDetected", heartbeat_ball_detected_); - shot_data_options_child.put("IsHeartBeat", true); - } - - root.add_child("BallData", ball_data_child); - root.add_child("ClubData", club_data_child); - root.add_child("ShotDataOptions", shot_data_options_child); - - std::string result = GenerateStringFromJsonTree(root); - - if (result == "") { - GS_LOG_MSG(warning, "GsGSProResults::Format() returning empty string."); - } - - return result; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_results.h deleted file mode 100644 index 62e48b06..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_results.h +++ /dev/null @@ -1,26 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include "gs_results.h" - - -// Class for representing and transferring Golf Sim results to GSPro - -namespace golf_sim { - - class GsGSProResults : public GsResults { - - public: - GsGSProResults(); - GsGSProResults(const GolfBall& ball); - GsGSProResults(const GsResults& results); - virtual ~GsGSProResults(); - virtual std::string Format() const; - - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp b/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp deleted file mode 100644 index 02c62983..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.cpp +++ /dev/null @@ -1,134 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" - -#include "gs_gspro_test_server.h" - - -using boost::asio::ip::tcp; - - -namespace golf_sim { - - GsGSProConnection::pointer GsGSProConnection::Create(boost::asio::io_context& io_context, int port_number) - { - return pointer(new GsGSProConnection(io_context, port_number)); - } - - tcp::socket& GsGSProConnection::GsGSProConnection::GetSocket() - { - return socket_; - } - - std::string GsGSProConnection::GenerateResponseString() - { - std::string response_string = "{\n\ - \"Code\": 201, \ - \"Message\" : \"GSPro Player Information\", \ - \"Player\" : {\n \ - \"Handed\": \"RH\",\n \ - \"Club\" : \"DR\"\n \ - } }"; - - return response_string; - } - - void GsGSProConnection::Start() - { - std::array buf; - boost::system::error_code error; - - - std::string buffer_string; - - while (true) { - GS_LOG_TRACE_MSG(trace, "About to read data."); - size_t len = socket_.read_some(boost::asio::buffer(buf), error); - if (len == 0) { - GS_LOG_MSG(warning, "Received a 0-length string from GSPro server."); - } - else { - // Null-terminate the string - buf[len] = (char)0; - buffer_string += std::string(buf.data()); - GS_LOG_TRACE_MSG(trace, " Read some data (" + std::to_string(len) + " bytes) : " + buffer_string); - } - - if (error == boost::asio::error::eof) { - GS_LOG_MSG(error, "Received unexpected EOF from the Launch Monitor."); - return; // Connection closed cleanly by peer. - } - else if (error) { - GS_LOG_MSG(error, "Received unexpected error from the Launch Monitor."); - throw boost::system::system_error(error); // Some other error. - } - - GS_LOG_TRACE_MSG(trace, "Received the following message from the Launch Monitor: " + buffer_string); - - message_ = GenerateResponseString(); - - GS_LOG_TRACE_MSG(trace, "Sending the following message from the GSPro simulated server: " + message_); - boost::asio::async_write(socket_, boost::asio::buffer(message_), - boost::bind(&GsGSProConnection::HandleWrite, shared_from_this(), - boost::asio::placeholders::error, - boost::asio::placeholders::bytes_transferred)); - } - } - - GsGSProConnection::GsGSProConnection(boost::asio::io_context& io_context, int port_number) - : socket_(io_context) - { - } - - void GsGSProConnection::HandleWrite(const boost::system::error_code& error, - size_t bytes_transferred) { - GS_LOG_TRACE_MSG(trace, "bytes_transferred: " + std::to_string(bytes_transferred)); - } - - -//================== NEXT CLASS HERE ==================== - - - GsGSProTestServer::GsGSProTestServer(boost::asio::io_context& io_context, int port_number) : io_context_(io_context), - acceptor_(io_context, tcp::endpoint(tcp::v4(), port_number)) - { - port_number_ = port_number; - StartAccept(); - } - - void GsGSProTestServer::StartAccept() - { - GS_LOG_TRACE_MSG(trace, "GsGSProTestServer::StartAccept. port_number_: " + std::to_string(port_number_)); - - GsGSProConnection::pointer new_connection = - GsGSProConnection::Create(io_context_, port_number_); - - acceptor_.async_accept(new_connection->GetSocket(), - boost::bind(&GsGSProTestServer::HandleAccept, this, new_connection, - boost::asio::placeholders::error)); - } - - void GsGSProTestServer::HandleAccept(GsGSProConnection::pointer new_connection, - const boost::system::error_code& error) - { - if (!error) - { - new_connection->Start(); - } - - StartAccept(); - } - - -} \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h b/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h deleted file mode 100644 index 0d5cb4ec..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_gspro_test_server.h +++ /dev/null @@ -1,58 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -// Just for testing. This class simulates the GSPro golf simulator interface. - -#pragma once - -#include -#include -#include -#include - -using boost::asio::ip::tcp; - - -namespace golf_sim { - - class GsGSProConnection : public std::enable_shared_from_this - { - public: - typedef std::shared_ptr pointer; - - static pointer Create(boost::asio::io_context& io_context, int port_number); - - tcp::socket& GetSocket(); - - std::string GenerateResponseString(); - - void Start(); - - private: - GsGSProConnection(boost::asio::io_context& io_context, int port_number); - - void HandleWrite(const boost::system::error_code& error, size_t bytes_transferred); - - tcp::socket socket_; - std::string message_; - }; - - class GsGSProTestServer - { - public: - GsGSProTestServer(boost::asio::io_context& io_context, int port_number); - - private: - void StartAccept(); - - void HandleAccept(GsGSProConnection::pointer new_connection, - const boost::system::error_code& error); - - boost::asio::io_context& io_context_; - int port_number_ = 0; - tcp::acceptor acceptor_; - }; - -} \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp index a24e50e1..d949c420 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.cpp @@ -5,6 +5,9 @@ #ifdef __unix__ +#include +#include + #include "gs_http_client.h" #include "logging_tools.h" #include "httplib.h" @@ -19,7 +22,27 @@ void GsHttpClient::Init(const std::string& host, int port) { port_ = port; } -void GsHttpClient::PostResult(const std::string& json_body) { +std::string GsHttpClient::FetchConfig() { + try { + httplib::Client cli(host_, port_); + cli.set_connection_timeout(2); + cli.set_read_timeout(5); + + auto res = cli.Get("/api/internal/config"); + + if (!res || res->status != 200) { + GS_LOG_MSG(error, "Config fetch failed (status " + + std::to_string(res ? res->status : 0) + ")"); + return ""; + } + return res->body; + } catch (const std::exception& e) { + GS_LOG_MSG(error, "Config fetch exception: " + std::string(e.what())); + return ""; + } +} + +std::string GsHttpClient::PostResult(const std::string& json_body) { try { httplib::Client cli(host_, port_); cli.set_connection_timeout(1); @@ -31,10 +54,13 @@ void GsHttpClient::PostResult(const std::string& json_body) { GS_LOG_MSG(warning, "HTTP POST to web server failed (no response)"); } else if (res->status != 200) { GS_LOG_MSG(warning, "HTTP POST returned status " + std::to_string(res->status)); + } else { + return res->body; } } catch (const std::exception& e) { GS_LOG_MSG(warning, "HTTP POST exception: " + std::string(e.what())); } + return ""; } void GsHttpClient::PostImageReady(const std::string& filename) { @@ -54,6 +80,62 @@ void GsHttpClient::PostImageReady(const std::string& filename) { } } +bool GsHttpClient::PutJson(const std::string& path, const std::string& json_body) { + try { + httplib::Client cli(host_, port_); + cli.set_connection_timeout(2); + cli.set_read_timeout(5); + + auto res = cli.Put(path, json_body, "application/json"); + if (!res) { + GS_LOG_MSG(error, "HTTP PUT " + path + " failed (no response)"); + return false; + } + if (res->status != 200) { + GS_LOG_MSG(error, "HTTP PUT " + path + " returned " + std::to_string(res->status) + ": " + res->body); + return false; + } + return true; + } catch (const std::exception& e) { + GS_LOG_MSG(warning, "HTTP PUT exception: " + std::string(e.what())); + return false; + } +} + +bool GsHttpClient::PutConfigValue(const std::string& key, const std::string& json_value) { + if (!PutJson("/api/config/" + key, "{\"value\": " + json_value + "}")) { + GS_LOG_MSG(error, "Failed to save calibration value " + key + " to the web server."); + return false; + } + GS_LOG_MSG(info, "Saved calibration: " + key + " = " + json_value); + return true; +} + +bool GsHttpClient::UpdateCalibration(const std::string& key, double value) { + return PutConfigValue(key, FormatAsJson(value)); +} + +bool GsHttpClient::UpdateCalibration(const std::string& key, const std::vector& values) { + return PutConfigValue(key, FormatAsJson(values)); +} + +std::string GsHttpClient::FormatAsJson(double value) { + std::stringstream ss; + ss << std::setprecision(10) << value; + return ss.str(); +} + +std::string GsHttpClient::FormatAsJson(const std::vector& values) { + std::stringstream ss; + ss << "["; + for (size_t i = 0; i < values.size(); ++i) { + if (i > 0) ss << ", "; + ss << std::setprecision(10) << values[i]; + } + ss << "]"; + return ss.str(); +} + } // namespace golf_sim #endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_http_client.h b/Software/LMSourceCode/ImageProcessing/gs_http_client.h index c027a1e8..54772348 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_http_client.h +++ b/Software/LMSourceCode/ImageProcessing/gs_http_client.h @@ -8,18 +8,27 @@ #ifdef __unix__ #include +#include namespace golf_sim { -// Lightweight HTTP client for posting shot results to the Python web server. -// Fire-and-forget — failures are logged but don't block the shot cycle. +// HTTP client for the Python web server. Shot result post failures are logged +// but don't block the shot cycle. class GsHttpClient { public: static void Init(const std::string& host = "localhost", int port = 8080); - static void PostResult(const std::string& json_body); + static std::string FetchConfig(); // GET /api/internal/config; returns body or "" on failure + static std::string PostResult(const std::string& json_body); // returns the reply body or "" on failure static void PostImageReady(const std::string& filename); + static bool PutJson(const std::string& path, const std::string& json_body); + static bool UpdateCalibration(const std::string& key, double value); + static bool UpdateCalibration(const std::string& key, const std::vector& values); private: + static bool PutConfigValue(const std::string& key, const std::string& json_value); + static std::string FormatAsJson(double value); + static std::string FormatAsJson(const std::vector& values); + static std::string host_; static int port_; }; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.cpp b/Software/LMSourceCode/ImageProcessing/gs_options.cpp index 005e9906..b19e2215 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_options.cpp @@ -63,8 +63,6 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) { "test_spin", SystemMode::kTestSpin }, { "camera1_ball_location", SystemMode::kCamera1BallLocation }, { "camera2_ball_location", SystemMode::kCamera2BallLocation }, - { "test_sim_message", SystemMode::kTestExternalSimMessage }, - { "test_gspro_server", SystemMode::kTestGSProServer }, { "automated_testing", SystemMode::kAutomatedTesting }, { "camera1AutoCalibrate", SystemMode::kCamera1AutoCalibrate }, { "camera2AutoCalibrate", SystemMode::kCamera2AutoCalibrate }, @@ -74,17 +72,40 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) system_mode_ = (SystemMode)mode_table[system_mode_string_]; - std::map artifact_save_level_table = + if (help_) + { + std::cout << options_; + return true; + } + + if (version_) + { + std::cout << "GolfSim build version: TBD" << std::endl; + return false; + } + + + return true; +} + +bool GolfSimOptions::SetArtifactSaveLevel(const std::string& level) +{ + static const std::map table = { { "none", ArtifactSaveLevel::kNoArtifacts }, { "final_results_only", ArtifactSaveLevel::kFinalResultsOnly }, { "all", ArtifactSaveLevel::kAll }, }; - if (artifact_save_level_table.count(artifact_save_level_string_) == 0) - throw std::runtime_error("Invalid system_mode: " + artifact_save_level_string_); - artifact_save_level_ = (ArtifactSaveLevel)artifact_save_level_table[artifact_save_level_string_]; - + auto it = table.find(level); + if (it == table.end()) + return false; + artifact_save_level_string_ = level; + artifact_save_level_ = it->second; + return true; +} - std::map log_level_table = +bool GolfSimOptions::SetLoggingLevel(const std::string& level) +{ + static const std::map table = { { "trace", LoggingLevel::kTrace }, { "debug", LoggingLevel::kDebug }, { "info", LoggingLevel::kInfo }, @@ -92,30 +113,25 @@ bool GolfSimOptions::Parse(int argc, char *argv[]) { "error", LoggingLevel::kError }, { "none", LoggingLevel::kNone }, }; - if (log_level_table.count(logging_level_string_) == 0) - throw std::runtime_error("Invalid log_level: " + logging_level_string_); - logging_level_ = (LoggingLevel)log_level_table[logging_level_string_]; - - std::map orientation_table = - { { "right_handed", GolferOrientation::kRightHanded }, - { "left_handed", GolferOrientation::kLeftHanded } }; - if (mode_table.count(system_mode_string_) == 0) - throw std::runtime_error("Invalid golfer_orientation: " + golfer_orientation_string_); - golfer_orientation_ = (GolferOrientation)orientation_table[golfer_orientation_string_]; - - if (help_) - { - std::cout << options_; - return true; - } - - if (version_) - { - std::cout << "GolfSim build version: TBD" << std::endl; + auto it = table.find(level); + if (it == table.end()) return false; - } - + logging_level_string_ = level; + logging_level_ = it->second; + return true; +} +bool GolfSimOptions::SetGolferOrientation(const std::string& orientation) +{ + static const std::map table = + { { "right_handed", GolferOrientation::kRightHanded }, + { "left_handed", GolferOrientation::kLeftHanded }, + }; + auto it = table.find(orientation); + if (it == table.end()) + return false; + golfer_orientation_string_ = orientation; + golfer_orientation_ = it->second; return true; } @@ -128,19 +144,13 @@ void GolfSimOptions::Print() const std::cout << " shutdown: " << std::to_string(shutdown_) << std::endl; std::cout << " cam_still_mode: " << std::to_string(camera_still_mode_) << std::endl; std::cout << " lm_comparison_mode: " << std::to_string(lm_comparison_mode_) << std::endl; - std::cout << " send_test_results: " << std::to_string(send_test_results_) << std::endl; if (!output_filename_.empty()) std::cout << " output_filename: " << output_filename_ << std::endl; if (!base_image_logging_dir_.empty()) std::cout << " base_image_logging_dir_: " << base_image_logging_dir_ << std::endl; if (!web_server_share_dir_.empty()) std::cout << " web_server_share_dir: " << web_server_share_dir_ << std::endl; - if (!e6_host_address_.empty()) - std::cout << " e6_host_address: " << e6_host_address_ << std::endl; - if (!gspro_host_address_.empty()) - std::cout << " gspro_host_address: " << gspro_host_address_ << std::endl; - if (!config_file_.empty()) - std::cout << " configuration file: " << config_file_ << std::endl; + std::cout << " web_server_port: " << std::to_string(web_server_port_) << std::endl; std::cout << " pulse_test: " << std::to_string(perform_pulse_test_) << std::endl; std::cout << " golfer_orientation: " << golfer_orientation_string_ << std::endl; std::cout << " practice_ball: " << std::to_string(practice_ball_) << std::endl; diff --git a/Software/LMSourceCode/ImageProcessing/gs_options.h b/Software/LMSourceCode/ImageProcessing/gs_options.h index c08658cf..a28eb376 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_options.h +++ b/Software/LMSourceCode/ImageProcessing/gs_options.h @@ -24,8 +24,6 @@ namespace golf_sim { kTestSpin = 7, kCamera1BallLocation = 8, kCamera2BallLocation = 9, - kTestExternalSimMessage = 10, - kTestGSProServer = 11, kAutomatedTesting = 12, kCamera1AutoCalibrate = 13, kCamera2AutoCalibrate = 14, @@ -70,42 +68,22 @@ namespace golf_sim { "Print this help message") ("version", value(&version_)->default_value(false)->implicit_value(true), "Displays the build version number") - ("golfer_orientation", value(&golfer_orientation_string_)->default_value("right_handed"), - "Set the golfer's handed-ness (right_handed, left_handed)") ("system_mode", value(&system_mode_string_)->default_value("test"), - "Set the system's operating mode (camera1, camera1_test_standalone, camera1Calibrate, camera2Calibrate, test_spin, camera1_ball_location, camera2_ball_location, test_gspro_server, automated_testing, camera1AutoCalibrate, camera2AutoCalibrate, test)") - ("logging_level", value(&logging_level_string_)->default_value("warn"), - "Set the system's logging level (trace, debug, info, warn, error, none)") - ("artifact_save_level", value(&artifact_save_level_string_)->default_value("final_results_only"), - "Set the system's level of saving artifact images to files (none, final_results_only, all)") + "Set the system's operating mode (camera1, camera1_test_standalone, camera1Calibrate, camera2Calibrate, test_spin, camera1_ball_location, camera2_ball_location, automated_testing, camera1AutoCalibrate, camera2AutoCalibrate, test)") ("shutdown", value(&shutdown_)->default_value(false)->implicit_value(true), "Request clean shutdown") ("cam_still_mode", value(&camera_still_mode_)->default_value(false)->implicit_value(true), "Take a single camera2 still picture (using one strobe flash) and exit") ("lm_comparison_mode", value(&lm_comparison_mode_)->default_value(false)->implicit_value(true), "Configure for operating in another infrared-based LM environment") - ("send_test_results", value(&send_test_results_)->default_value(false)->implicit_value(true), - "Send test shot results to the web server and exit") - ("skip_wait_armed", value(&skip_wait_armed_)->default_value(false)->implicit_value(true), - "Skip waiting for simulator armed state (for hardware-less testing)") ("output_filename", value(&output_filename_)->default_value("out.png"), "Write any still picture to the specified filename") ("pulse_test", value(&perform_pulse_test_)->default_value(false)->implicit_value(true), "Continually sends strobe and shutter signals") - ("practice_ball", value(&practice_ball_)->default_value(false)->implicit_value(true), - "Configure system to expect a lightweight, soft, practice ball") - ("wait_keys", value(&wait_for_key_on_images_)->default_value(false)->implicit_value(true), - "0 = Don't wait for a key press after showing each debug image, 1 = Do wait") ("run_single_pi", value(&run_single_pi_)->default_value(true)->implicit_value(true), "Deprecated — always single Pi. Accepted for backward compatibility.") - ("show_images", value(&show_images_)->default_value(false)->implicit_value(true), - "0 = Don't show any debug/trace images in windows on the screen, 1 = Do") ("use_non_IR_camera", value(&use_non_IR_camera_)->default_value(false)->implicit_value(true), "1 = The camera in use by this system is not an IR camera (and will likely need less gain)") - ("search_center_x", value(&search_center_x_)->default_value(0), - "Set the x coordinate of the center of the ball-search circle") - ("search_center_y", value(&search_center_y_)->default_value(0), - "Set the y coordinate of the center of the ball-search circle") ("simulate_found_ball", value(&simulate_found_ball_)->default_value(false)->implicit_value(true), "Causes camera1 system to act as though a ball was found even if none is present.") ("camera_gain", value(&camera_gain_)->default_value(0.0), @@ -116,12 +94,8 @@ namespace golf_sim { "Specify the full path (with an ending '/') where diagnostic images are to be written. Default is: ") ("web_server_share_dir", value(&web_server_share_dir_)->default_value(""), "Specify the full path (with an ending '/') where diagnostic images are to be written. Default is: ") - ("e6_host_address", value(&e6_host_address_)->default_value(""), - "Specify the name or IP address of the host PC that is running the E6 simulator. Default is: , indicating no TruGolf sim is connected.") - ("gspro_host_address", value(&gspro_host_address_)->default_value(""), - "Specify the name or IP address of the host PC that is running the GSPro simulator. Default is: , indicating no GSPro sim is connected.") - ("config_file", value(&config_file_)->default_value("golf_sim_config.json"), - "Specify the filename with the JSON configuration. Default is: golf_sim_config.json") + ("web_server_port", value(&web_server_port_)->default_value(8080), + "Port of the PiTrac web server (on localhost) to fetch configuration from and post results to.") ("cmd_file,cmd", value(&command_line_file_)->implicit_value("config.txt"), "Read the options from a file. If no filename is specified, default to config.txt. " "In case of duplicate options, the ones provided on the command line will be used. " @@ -143,38 +117,39 @@ namespace golf_sim { bool shutdown_; bool camera_still_mode_; bool lm_comparison_mode_; - bool send_test_results_; - bool skip_wait_armed_; - bool practice_ball_; + bool practice_ball_ = false; bool perform_pulse_test_; bool use_non_IR_camera_; bool simulate_found_ball_; std::string output_filename_; std::string system_mode_string_; - std::string artifact_save_level_string_; - std::string logging_level_string_; + std::string artifact_save_level_string_ = "final_results_only"; + std::string logging_level_string_ = "warn"; std::string command_line_file_; std::string msg_broker_address_; std::string base_image_logging_dir_; std::string web_server_share_dir_; - std::string e6_host_address_; - std::string gspro_host_address_; - std::string config_file_; - std::string golfer_orientation_string_; + std::string golfer_orientation_string_ = "right_handed"; SystemMode system_mode_ = kTest; - LoggingLevel logging_level_ = kInfo; - ArtifactSaveLevel artifact_save_level_ = kNoArtifacts; + LoggingLevel logging_level_ = kWarn; + ArtifactSaveLevel artifact_save_level_ = kFinalResultsOnly; GolferOrientation golfer_orientation_ = kRightHanded; - bool wait_for_key_on_images_; + bool wait_for_key_on_images_ = false; bool run_single_pi_ = true; // Always true — dual-Pi support removed - bool show_images_; + bool show_images_ = false; unsigned int search_center_x_ = 0; unsigned int search_center_y_ = 0; double camera_gain_ = 0.0; // 1.0 might seem more appropriate, but we want to be able to see if this is set or not + int web_server_port_ = 8080; virtual bool Parse(int argc, char* argv[]); virtual void Print() const; + // Each returns false and leaves the option unchanged if the string is not a recognized value + bool SetArtifactSaveLevel(const std::string& level); + bool SetLoggingLevel(const std::string& level); + bool SetGolferOrientation(const std::string& orientation); + protected: boost::program_options::options_description options_; static GolfSimOptions the_command_line_options_; diff --git a/Software/LMSourceCode/ImageProcessing/gs_results.cpp b/Software/LMSourceCode/ImageProcessing/gs_results.cpp index a94b5b3b..4e75c02a 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_results.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_results.cpp @@ -5,8 +5,6 @@ // Representation of the results of processing a golf shot -#include -#include "gs_format_lib.h" #include "math.h" #include "logging_tools.h" #include "cv_utils.h" @@ -32,9 +30,6 @@ namespace golf_sim { // but this is a reasonable default for now club_type_ = GolfSimClubs::GetCurrentClubType(); - // Real shot data implies a ball was definitely detected. - heartbeat_ball_detected_ = true; - // TBD - Even though this is a constructor, it might be a reasonable // place to calculate the Carry yardarge. } @@ -63,54 +58,4 @@ namespace golf_sim { return s; } - std::string GsResults::FormatDoubleAsString(const double original_value) { - - double value = std::round(original_value * 10.0) / 10.0; - - auto s = GS_FORMATLIB_FORMAT("{: <1.1f}", value); - - std::string result; - - // If we don't quote 0.0, it ends up as 0, and some systems don't like that - if (s == "0.0") { - result = "0.0"; - } - else { - result = s; - } - - return result; - } - - std::string GsResults::GenerateStringFromJsonTree(const boost::property_tree::ptree& root) { - - // Write the property tree to a JSON string - std::stringstream ss; - boost::property_tree::write_json(ss, root); - - // Get the JSON string - std::string json_string = ss.str(); - - // Remove any quotes around data values that should be numbers - // Apparently this is a deficiency in the boost library, see - // https://stackoverflow.com/questions/2855741/why-does-boost-property-tree-write-json-save-everything-as-string-is-it-possibl - - std::regex reg("\\\"([+-]?[0-9]+\\.{0,1}[0-9]*)\\\""); - std::string result = std::regex_replace(json_string, reg, "$1"); - - std::string subStringToRemove = "\"true\""; - std::string subStringToReplace = "true"; - boost::replace_all(result, subStringToRemove, subStringToReplace); - - subStringToRemove = "\"false\""; - subStringToReplace = "false"; - boost::replace_all(result, subStringToRemove, subStringToReplace); - - subStringToRemove = "\"APIversion\": 1,"; - subStringToReplace = "\"APIversion\": \"1\","; - boost::replace_all(result, subStringToRemove, subStringToReplace); - - return result; - } - } diff --git a/Software/LMSourceCode/ImageProcessing/gs_results.h b/Software/LMSourceCode/ImageProcessing/gs_results.h index ac2e9ee8..aea5a7ff 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_results.h +++ b/Software/LMSourceCode/ImageProcessing/gs_results.h @@ -33,13 +33,6 @@ namespace golf_sim { // to the right. float GetSpinAxis() const; - // Deals with problem where Boost will put double-quotes around double values - static std::string FormatDoubleAsString(const double value); - - // Helper that converts a boost JSON tree into a string. Includes processing that - // will remove extraneous quotes. - static std::string GenerateStringFromJsonTree(const boost::property_tree::ptree& root); - public: long shot_number_ = 0; @@ -49,12 +42,6 @@ namespace golf_sim { int back_spin_rpm_ = 0; int side_spin_rpm_ = 0; // Negative is left (counter-clockwise from above ball) GolfSimClubs::GsClubType club_type_ = GolfSimClubs::GsClubType::kNotSelected; - - // Some systems need a keep-alive - bool result_message_is_keepalive_ = false; - bool heartbeat_launch_monitor_ready_ = true; - bool heartbeat_ball_detected_ = false; - }; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp deleted file mode 100644 index c1250bb6..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.cpp +++ /dev/null @@ -1,289 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include "logging_tools.h" -#include "cv_utils.h" -#include "gs_options.h" -#include "gs_config.h" - -#include "gs_sim_interface.h" -#include "gs_gspro_interface.h" -#include "gs_e6_interface.h" - -namespace golf_sim { - - /* TBD - REMOVE - No longer static - bool GsSimInterface::sim_system_is_armed_ = false; - boost::mutex GsSimInterface::sim_arming_mutex_; - */ - std::string GsSimInterface::launch_monitor_id_string_ = "PiTrac LM 0.1"; - - std::vector GsSimInterface::interfaces_; - bool GsSimInterface::sims_initialized_ = false; - - // The first shot number the golf simulator receives should be 1, not 0, and - // the system will increment the counter first before storing information - long GsSimInterface::shot_counter_ = 0; - - - GsSimInterface::GsSimInterface() { - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.kLaunchMonitorIdString", launch_monitor_id_string_); - } - - GsSimInterface::~GsSimInterface() { - } - - bool GsSimInterface::InitializeSims() { - - GS_LOG_TRACE_MSG(trace, "GsSimInterface::InitializeSims()"); - - // Create and add an interface to the global vector of interfaces - // for each configured sim - -#ifdef __unix__ // Ignore in Windows environment - - if (GsGSProInterface::InterfaceIsPresent()) { - GS_LOG_TRACE_MSG(trace, "GSPro simulator interface detected."); - - GsGSProInterface* gspro_sim = new GsGSProInterface(); - if (gspro_sim == nullptr) { - GS_LOG_MSG(error, "Could not create a GSPro simulator interface."); - return false; - } - - gspro_sim->simulator_type_ = GolfSimulatorType::kGSPro; - - interfaces_.push_back(gspro_sim); - - if (!gspro_sim->Initialize()) { - GS_LOG_MSG(error, "GSPro simulator interface could not be initialized."); - return false; - } - } - - if (GsE6Interface::InterfaceIsPresent()) { - GS_LOG_TRACE_MSG(trace, "E6 simulator interface detected."); - GsE6Interface* e6_sim = new GsE6Interface(); - if (e6_sim == nullptr) { - GS_LOG_MSG(error, "Could not create an E6 simulator interface."); - return false; - } - - e6_sim->simulator_type_ = GolfSimulatorType::kE6; - - interfaces_.push_back(e6_sim); - - if (!e6_sim->Initialize()) { - GS_LOG_MSG(error, "E6 simulator interface could not be initialized."); - return false; - } - } - - if (interfaces_.size() == 0) { - GS_LOG_TRACE_MSG(trace, "No simulator interface detected."); - } -#endif - shot_counter_ = 0; - - sims_initialized_ = true; - - return true; - } - - - bool GsSimInterface::SimIsConnected() { - - GS_LOG_TRACE_MSG(trace, "GsSimInterface::SimIsConnected()"); - - if (!sims_initialized_) { - // If we're not even initialized, there can't be any connected golf sims. - return false; - } - - bool sim_is_connected = false; - -#ifdef __unix__ // Ignore in Windows environment - - for (auto interface : interfaces_) { - if (interface != nullptr) { - sim_is_connected = true; - continue; - } - } -#endif - return sim_is_connected; - } - - void GsSimInterface::DeInitializeSims() { - - GS_LOG_TRACE_MSG(trace, "GsSimInterface::DeInitializeSims()"); - -#ifdef __unix__ // Ignore in Windows environment - - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - interface->DeInitialize(); - delete interface; - } -#endif - sims_initialized_ = false; - } - - - void GsSimInterface::SetSimSystemArmed(const bool is_armed) { - boost::lock_guard lock(sim_arming_mutex_); - - // At the generic level, we'll just allow for setting and getting - // the system-armed status even though this is a virtual interface - GS_LOG_TRACE_MSG(trace, "GsSimInterface::SetSimSystemArmed called."); - - sim_system_is_armed_ = is_armed; - } - - bool GsSimInterface::GetSimSystemArmed() { - boost::lock_guard lock(sim_arming_mutex_); - - if (!SimIsConnected()) { - // If no sims, then consider the system armed as a reasonable fallback behavior - return true; - } - - return sim_system_is_armed_; - } - - - int GsSimInterface::SendSimMessage(const std::string& message) { - GS_LOG_MSG(warning, "GsSimInterface::SendSimMessage - message was:\n" + message); - return 0; - } - - - bool GsSimInterface::SendResultsToGolfSims(const GsResults& input_results) { - - // The shot number should already have been set when the ball was teed up - - // Make a local copy of the results so that we can set the shot_counter - GsResults results = input_results; - results.shot_number_ = shot_counter_; - - if (results.speed_mph_ > 200.0) { - GS_LOG_MSG(warning, "GsSimInterface::SendResultsToGolfSim got out of bounds speed_mph. Settting to 200."); - results.speed_mph_ = 200.0; - } - - bool status = true; - -#ifdef __unix__ // Ignore in Windows environment - - // Loop through any interfaces that we are configured for and send the results - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - interface->SendResults(results); - } - -#endif - return status; - } - - bool GsSimInterface::GetAllSystemsArmed() { - bool all_systems_armed = true; - - // Loop through any interfaces that we are configured for and send the results - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - // If even one interface is not armed, then we're not "all" ready - if (!interface->GetSimSystemArmed()) { - all_systems_armed = false; - } - } - - return all_systems_armed; - } - - - GsSimInterface* GsSimInterface::GetSimInterfaceByType(GolfSimulatorType sim_type) { - - // Loop through any interfaces that we are configured for and send the results - for (auto interface : interfaces_) { - if (interface == nullptr) { - GS_LOG_MSG(error, "GsSimInterface::DeInitializeSims() found a null interface"); - continue; - } - - if (interface->simulator_type_ == sim_type) { - return interface; - } - } - - return nullptr; - } - - - void GsSimInterface::IncrementShotCounter() { - shot_counter_++; - } - - void GsSimInterface::SendHeartbeat(bool ball_detected) { -#ifdef __unix__ // Ignore in Windows environment - if (!sims_initialized_) { - return; - } - - GsResults heartbeat; - heartbeat.result_message_is_keepalive_ = true; - heartbeat.heartbeat_ball_detected_ = ball_detected; - heartbeat.heartbeat_launch_monitor_ready_ = true; - - for (auto interface : interfaces_) { - if (interface == nullptr) { - continue; - } - interface->SendResults(heartbeat); - } -#endif - } - - bool GsSimInterface::InterfaceIsPresent() { - // The base interface isn't a real interface, so cannot be 'present' - return false; - } - - bool GsSimInterface::Initialize() { - // The base interface isn't a real interface, so cannot be initialized - return false; - } - - void GsSimInterface::DeInitialize() { - // The base interface isn't a real interface, so cannot be de-initializeds - return; - } - - bool GsSimInterface::SendResults(const GsResults& results) { - GS_LOG_TRACE_MSG(trace, "GsSimInterface::SendResults - No Golf Sim connected to Launch Monitor. Results are: " + results.Format()); - return true; - } - - std::string GsSimInterface::GenerateResultsDataToSend(const GsResults& results) { - return results.Format(); - } - - bool GsSimInterface::ProcessReceivedData(const std::string received_data) { - GS_LOG_TRACE_MSG(trace, "GsSimInterface::ProcessReceivedData - No Golf Sim connected to Launch Monitor, so not doing anything with data. Data was:\n" + received_data); - return true; - } - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.h b/Software/LMSourceCode/ImageProcessing/gs_sim_interface.h deleted file mode 100644 index 7e12e237..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_interface.h +++ /dev/null @@ -1,121 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include - - -#include "logging_tools.h" -#include "golf_ball.h" -#include "gs_results.h" - - -// Base class for interfaces to 3rd-party golf simulators - -namespace golf_sim { - - class GsSimInterface { - - public: - enum GolfSimulatorType { - kNone = 0, - kGSPro = 1, - kE6 = 2 - }; - - GsSimInterface(); - virtual ~GsSimInterface(); - - // Create and initialize and sim interfaces that are configured - static bool InitializeSims(); - - // De-initialize and destory and sim interfaces that are configured - static void DeInitializeSims(); - - // Returns true if at least one golf sim is connected to the system. - static bool SimIsConnected(); - - // To be called from the launch monitor - static bool SendResultsToGolfSims(const GsResults& results); - - // If the interface is present (usually indicated in the config.json file), - // this method returns true; - static bool InterfaceIsPresent(); - - // Allows the shot counter to be incremented from outside the simulator - // interface for such purposes and ensuring the counter keeps going even - // when a failure occurs. - - static void IncrementShotCounter(); - - // Will be overridden by each derived class - - virtual bool Initialize(); - - // De-initialize and destroy and sim interfaces that are configured - virtual void DeInitialize(); - - // Base class behavior is to simply print out the JSON - virtual bool SendResults(const GsResults& results); - - // Sends a string without any other side-effects - // Returns the number of bytes written - virtual int SendSimMessage(const std::string& message); - - // Deals with whether or not ALL of the connected simulators are armed - // (ready to take a shot). Some sims just return true. - virtual void SetSimSystemArmed(const bool is_armed); - virtual bool GetSimSystemArmed(); - - // These static functions operate at the collection level for all interfaces - static long GetShotCounter() { return shot_counter_; }; - - // Find the GSPro or E6 or whatever interface (if available) by type - static GsSimInterface *GetSimInterfaceByType(GolfSimulatorType sim_type); - - // Returns true only if each of the available interfaces is armed - static bool GetAllSystemsArmed(); - - // Heartbeat support for external simulators - static void SendHeartbeat(bool ball_detected); - static inline void ResetHeartbeatState() {} - - protected: - - // Typical derived-class behavior will be to convert the results into a - // sim-specific data packet, such as a JSON string - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - // Called when the LM receives data - virtual bool ProcessReceivedData(const std::string received_data); - - protected: - - // Holds pointers to derived interfaces for each attached sim - static std::vector interfaces_; - - static std::string launch_monitor_id_string_; - - // True if all the attached sims have been initialized - static bool sims_initialized_; - - static long shot_counter_; - - // True if all THIS sim has been initialized - bool initialized_; - - GolfSimulatorType simulator_type_; - - // Must be true before the simulator system is ready to accept shot data - // Only relevant for derived, non-virtual classes for whom arming is an - // actual thing. - bool sim_system_is_armed_ = false; - - boost::mutex sim_arming_mutex_; - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp b/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp deleted file mode 100644 index c94d2ea3..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.cpp +++ /dev/null @@ -1,283 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include - -#ifdef __unix__ // Ignore in Windows environment - -#include -#include -#include -#include - -#include "logging_tools.h" -#include "gs_options.h" -#include "gs_config.h" -#include "gs_events.h" -#include "gs_control_msg.h" - -#include "gs_sim_socket_interface.h" -#include "gs_gspro_interface.h" -#include "gs_gspro_response.h" -#include "gs_gspro_results.h" - -using namespace boost::asio; -using ip::tcp; - - -namespace golf_sim { - - GsSimSocketInterface::GsSimSocketInterface() { - } - - GsSimSocketInterface::~GsSimSocketInterface() { - - } - - bool GsSimSocketInterface::InterfaceIsPresent() { - // The socket interface is basically just a base class, so cannot on it's own ber present - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface InterfaceIsPresent should not have been called."); - return false; - } - - bool GsSimSocketInterface::Initialize() { - - // Derived classes must set the socket connection address and port before calling this function - - // Setup the socket connect here first so - // that we don't have to repeatedly do so. May also want to - // setup a keep-alive ping to the SimSocket system. - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface Initialize called."); - - try - { - io_context_ = new boost::asio::io_context(); - - if (io_context_ == nullptr) { - GS_LOG_MSG(error, "GsSimSocketInterface could not create a new io_context."); - return false; - } - - tcp::resolver resolver(*io_context_); - GS_LOG_TRACE_MSG(trace, "Connecting to SimSocketServer at address: " + socket_connect_address_ + ":" + socket_connect_port_); - tcp::resolver::results_type endpoints = resolver.resolve(socket_connect_address_, socket_connect_port_); - - // Create the socket if we haven't done so already - if (socket_ == nullptr) { - socket_ = new tcp::socket(*io_context_); - - if (socket_ == nullptr) { - GS_LOG_MSG(error, "GsSimSocketInterface could not create a new socket."); - return false; - } - } - - - boost::asio::connect(*socket_, endpoints); - - receiver_thread_ = std::unique_ptr(new std::thread(&GsSimSocketInterface::ReceiveSocketData, this)); - - // GS_LOG_TRACE_MSG(trace, "Thread was created. Thread id: " + std::string(receiver_thread_.get()->get_id()) ); - - // socket_->set_option(boost::asio::detail::socket_option::integer{ 10 }); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestSimSocketMessage - Error was: " + std::string(e.what())); - return false; - } - -#ifdef __unix__ // Ignore in Windows environment - // Give the new thread a moment to get running - usleep(500); -#endif - - initialized_ = true; - - // Connection just came up – make sure the first heartbeat reports no ball detected. - GsSimInterface::ResetHeartbeatState(); - GsSimInterface::SendHeartbeat(false); - - // Derived classes will need to deal with any initial messaging after the socket is established. - - return true; - } - - void GsSimSocketInterface::ReceiveSocketData() { - - receive_thread_exited_ = false; - - static std::array buf; - boost::system::error_code error; - std::string received_data_string; - - while (GolfSimGlobals::golf_sim_running_) - { - // We don't want to re-enter this while we're processing - // a received message - // boost::lock_guard lock(sim_socket_receive_mutex_); - - GS_LOG_TRACE_MSG(trace, "Waiting to receive data from SimSocketserver."); - - size_t len = 0; - - try { - // Read_some should be blocking, but if the socket has closed, it will return immediately - len = socket_->read_some(boost::asio::buffer(buf), error); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "GsSimSocketInterface::ReceiveSocketData failed to read from socket - Error was: " + std::string(e.what())); - } - - if (len == 0) { - GS_LOG_MSG(warning, "Received 0-length message from server. Will attempt to re-initialize"); - /// TBD - Are we sure we want to exit? - receive_thread_exited_ = true; - return; - } - - // Null-terminate the string - buf[len] = (char)0; - received_data_string = std::string(buf.data()); - GS_LOG_TRACE_MSG(trace, " Read some data (" + std::to_string(len) + " bytes) : " + received_data_string); - - if (error == boost::asio::error::eof) { - // Connection closed cleanly by peer. - // In this case, we may want to de-initialize - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::ReceiveSocketData Received EOF"); - receive_thread_exited_ = true; - return; - } - else if (error) { - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::ReceiveSocketData Received Error"); - throw boost::system::system_error(error); // Some other error. - } - // Derived classes will, for example, parse the message and inject any - // relevant events into the FSM. - - GS_LOG_TRACE_MSG(trace, "Received SimSocket message of: \n" + received_data_string); - - if (!ProcessReceivedData(received_data_string)) { - GS_LOG_MSG(error, "Failed GsSimSocketInterface::ReceiveSocketData - Could process data: " + received_data_string); - return; - } - } - - GS_LOG_MSG(error, "GsSimSocketInterface::ReceiveSocketData Exiting"); - } - - void GsSimSocketInterface::DeInitialize() { - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::DeInitialize() called."); - try { - - if (receiver_thread_ != nullptr) { - /*** TBD - Was locking up - GS_LOG_TRACE_MSG(trace, "Waiting for join of receiver_thread_."); - receiver_thread_->join(); - receiver_thread_.release(); - delete receiver_thread_.get(); - */ - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::DeInitialize() killing receive thread."); - -#ifdef __unix__ // Ignore in Windows environment - pthread_cancel(receiver_thread_.get()->native_handle()); -#endif - receiver_thread_ = nullptr; - } - - // TBD - not sure how to deinitialize the TCP socket stuff - delete socket_; - socket_ = nullptr; - delete io_context_; - io_context_ = nullptr; - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::DeInitialize() completed."); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed GsSimSocketInterface::DeInitialize() - Error was: " + std::string(e.what())); - } - - initialized_ = false; - } - - int GsSimSocketInterface::SendSimMessage(const std::string& message) { - size_t write_length = 0; - boost::system::error_code error; - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::SendSimMessage - Message was: " + message); - - // We don't want to re-enter this while we're processing - // a received message - boost::lock_guard lock(sim_socket_send_mutex_); - - - try { - - write_length = socket_->write_some(boost::asio::buffer(message), error); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestE6Message - Error was: " + std::string(e.what()) + ". Error code was:" + std::to_string(error.value()) ); - return -2; - } - - return write_length; - } - - - bool GsSimSocketInterface::SendResults(const GsResults& results) { - - if (!initialized_) { - GS_LOG_MSG(error, "GsSimSocketInterface::SendResults called before the interface was intialized."); - return false; - } - - if (receive_thread_exited_) { - GS_LOG_MSG(error, "GsSimSocketInterface::SendResults called before the interface was intialized - trying to re-initialize."); - // If we ended the receive thread, try re-initializing the connection - DeInitialize(); - if (!Initialize()) { - GS_LOG_MSG(error, "GsSimSocketInterface::SendResults could not re-intialize thew interface."); - return false; - } - } - - GS_LOG_TRACE_MSG(trace, "Sending GsSimSocketInterface::SendResult results input message:\n" + results.Format()); - - size_t write_length = -1; - - try { - static std::array buf; - boost::system::error_code error; - - std::string results_msg = GenerateResultsDataToSend(results); - - write_length = SendSimMessage(results_msg); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestSimSocketMessage - Error was: " + std::string(e.what())); - return false; - } - - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::SendResult sent " + std::to_string(write_length) + " bytes."); - - return true; - } - - std::string GsSimSocketInterface::GenerateResultsDataToSend(const GsResults& results) { - return results.Format(); - } - - bool GsSimSocketInterface::ProcessReceivedData(const std::string received_data) { - GS_LOG_TRACE_MSG(trace, "GsSimSocketInterface::ProcessReceivedData - No Scoket-based Golf Sim connected to Launch Monitor, so not doing anything with data. Data was:\n" + received_data); - return true; - } - -} -#endif diff --git a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h b/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h deleted file mode 100644 index 3abbbcb5..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_sim_socket_interface.h +++ /dev/null @@ -1,69 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include - -#include "gs_results.h" -#include "gs_sim_interface.h" - -using namespace boost::asio; -using ip::tcp; - -// Base class for representing and transferring Golf Sim results over sockets - -namespace golf_sim { - - class GsSimSocketInterface : public GsSimInterface { - - public: - GsSimSocketInterface(); - virtual ~GsSimSocketInterface(); - - // Returns true iff the SimSocket interface is to be used - static bool InterfaceIsPresent(); - - // Must be called before SendResults is called. - virtual bool Initialize(); - - // Deals with, for example, shutting down any socket connection - virtual void DeInitialize(); - - virtual bool SendResults(const GsResults& results); - - virtual void ReceiveSocketData(); - - public: - - std::string socket_connect_address_; - std::string socket_connect_port_; - - protected: - - virtual std::string GenerateResultsDataToSend(const GsResults& results); - - virtual bool ProcessReceivedData(const std::string received_data); - - // Default behavior here is just to send the message to the socket and - // return the number of bytes written - virtual int SendSimMessage(const std::string& message); - - protected: - - tcp::socket* socket_ = nullptr; - boost::asio::io_context* io_context_ = nullptr; - - std::unique_ptr receiver_thread_ = nullptr; - - // TBD - Is this thread safe? - bool receive_thread_exited_ = false; - - boost::mutex sim_socket_receive_mutex_; - boost::mutex sim_socket_send_mutex_; - }; - -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST b/Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST deleted file mode 100644 index 0d0f5576..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_test.cpp.B4_NEW_TEST +++ /dev/null @@ -1,1538 +0,0 @@ -// ImageProcessing.cpp { This file contains the main test function. Program execution begins and ends there. -// - -#include - -#include - -#include -#include -#include - -#include -#include -#include - -#include "gs_globals.h" -#include "golf_ball.h" -#include "gs_options.h" -#include "gs_gspro_results.h" -#include "gs_ui_system.h" - -#include "logging_tools.h" -#include "colorsys.h" -#include "ball_image_proc.h" -#include "cv_utils.h" -#include "gs_camera.h" -#include "gs_config.h" -#include "pulse_strobe.h" - - -#include "gs_gspro_results.h" -#include "gs_gspro_test_server.h" -#include "gs_gspro_response.h" - -#include "gs_fsm.h" -#include "gs_ipc_system.h" -#include "libcamera_interface.h" - - -namespace fs = std::filesystem; - -using namespace golf_sim; - -const double kLocationTolerancePercent = 10; - - -// The result files we create will be prefixed with this -static constexpr std::string_view TEST_IMAGE_PREFIX = "TEST_RESULT_GetBall_"; - -#ifdef __unix__ -const std::string kBaseTestDir = "/mnt/VerdantShare/dev/GolfSim/LM/Images/"; -#else -const std::string kBaseTestDir = "D:\\GolfSim\\LM\\Images\\"; -#endif - - -BallImageProc *get_image_processor() { - BallImageProc *ip = new BallImageProc; - // TBD - Setup as necessary - return ip; -} - - -void test_image(std::string_view subdir, std::string_view filename) { - BOOST_LOG_FUNCTION(); - - fs::path dir(subdir); - fs::path file(filename); - fs::path fullPath = dir / file; - std::string fname = fullPath.generic_string(); - - GolfBall ball; - - boost::to_lower(fname); - size_t i = fname.find("clr-"); - - std::string color; - if (i > 0) { - color = fname.substr(i); - } - - if (!color.empty()) { - - boost::to_lower(color); - - if (color.find("white") > 0) { - ball.ball_color_ = GolfBall::BallColor::kWhite; - } - else if (color.find("orange") > 0) { - ball.ball_color_ = GolfBall::BallColor::kOrange; - } - else if (color.find("yellow") > 0) { - ball.ball_color_ = GolfBall::BallColor::kYellow; - } - else if (color.find("green") > 0) { - ball.ball_color_ = GolfBall::BallColor::kOpticGreen; - } - else { - // For this part of our test suite, if there is no specified color, white(not kUnknown) is our best bet - ball.ball_color_ = GolfBall::BallColor::kWhite; - } - } - - BallImageProc *ip = get_image_processor(); - - cv::Mat img = cv::imread(fname, cv::IMREAD_COLOR); - ip->image_name_ = fname; - - cv::Rect nullROI; - std::vector return_balls; - bool result = ip->GetBall(img, ball, return_balls, nullROI, BallImageProc::BallSearchMode::kFindPlacedBall ); - - if (!result || return_balls.empty()) { - GS_LOG_MSG(error, "GetBall() failed to get a ball."); - return; - } - - GolfBall result_ball = return_balls.front(); - - // If there were any debugging windows, get rid of them now - cv::destroyAllWindows(); - - // Create a single output file with the result overlaid on the original image, along with - // whatever other debug images that we want. - - cv::Mat outputImage; - - auto ncFinalArray = nc::NdArray(ip->final_result_image_.data, ip->final_result_image_.rows, ip->final_result_image_.cols); - auto ncCandidatesArray = nc::NdArray(ip->candidates_image_.data, ip->candidates_image_.rows, ip->candidates_image_.cols); - auto ncOutputImage = nc::hstack({ ncFinalArray, ncCandidatesArray }); - - outputImage = CvUtils::ConvertNDArrayToMat(ncOutputImage); - - fs::path outputDir(subdir); - fs::path outputFile(std::string(TEST_IMAGE_PREFIX).append(filename)); - fs::path outputFullPath = outputDir / outputFile; - std::string outputFileName = outputFullPath.generic_string(); - - cv::imwrite(outputFileName, outputImage); - - LoggingTools::ShowImage(fname, outputImage); -} - -void test_certain_images() { - - test_image("./Images/", "FakePiCameraPhotoOfGolfBall-Clr-Green-Flat.png"); - test_image("./Images/", "WedgeNextToOrangeBall-Clr-Orange.png"); - test_image("./Images/", "FirstPiV1CamBall-Clr-Yellow.jpeg"); - test_image("./Images/", "AboutToBeHitLoRes-Clr-White.jpg"); - test_image("./Images/", "JustHitByIronToRight-Clr-White.png"); - test_image("./Images/", "JustHitSlightBlurClub-Clr-White.png"); - test_image("./Images/", "WedgeNextToOrangeBall-Clr-Orange.jpg"); - test_image("./Images/", "HitClubGoneWithFlyingTee-Clr-White.png"); - test_image("./Images/", "IMG_7713-Clr-Yellow.jpg"); -} - - - -void WalkDirectoryTree(const fs::path& pathToScan, int level = 0) { - for (const auto& entry : fs::directory_iterator(pathToScan)) { - const auto filenameStr = entry.path().filename().string(); - if (entry.is_directory() && (filenameStr.find("IGNORE") != std::string::npos) ) { - // Recurse to any sub-directories - WalkDirectoryTree(entry, level + 1); - } - else if (entry.is_regular_file()) { - if (((filenameStr.find(".png") > 0) && - (filenameStr.find(".jpg") > 0) && - (filenameStr.find(".jpeg") > 0)) && - ((filenameStr.find("IGNORE") == std::string::npos) && - (filenameStr.find("TEST_IMAGE_PREFIX") == std::string::npos)) - ) { - test_image(pathToScan.generic_string(), filenameStr); - } - } - else - std::cout << "(ignoring)" << filenameStr << "\n"; - } -} - -void test_all_test_files() { -// std::string rootdir = "D:/GolfSim/C++Code/GolfSim/ImageProcessing/Images"; - std::string rootdir = "D:/GolfSim/TestPictures"; - - - WalkDirectoryTree(rootdir); -} - -void test_calibrated_location(std::string twoFootImgName, std::string threeFootImgName, std::string fourFootImgName) { - - cv::Mat img = cv::imread(twoFootImgName); - LoggingTools::ShowImage(twoFootImgName, img); - - // Test of the GetCalibratedBall function - GolfSimCamera c; - GolfBall b; - - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - bool success = c.GetCalibratedBall(img, b); - - // Now, test the calibration by seeing if the same ball can be found using the calibrated ball - GS_LOG_MSG(trace, "GET BALL LOCATION AGAIN FOR 2 FEET"); - GolfBall newBall; - success = c.GetCurrentBallLocation(img, b, newBall); - - if (!threeFootImgName.empty()) { - // This original HSV masking range from the calibration is not working well.Try a lower H ? - // b.ball_hsv_range_ = [(0, 0, 175), (175, 102, 255)] - GS_LOG_MSG(trace, "GET BALL LOCATION FOR 3 FEET"); - img = cv::imread(threeFootImgName); - - // TBD - Consider the fact that the ball should be no larger than the radius of the last found - // ball, and should have that ball"s average color - success = c.GetCurrentBallLocation(img, b, newBall); - // TBD _ FIX b.average_color_, b.median_color_, b.std_color_ = cu.GetBallColorRgb(c.img, newBall.ball_circle_) - } - - if (!fourFootImgName.empty()) { - GS_LOG_MSG(trace, "GET BALL LOCATION FOR 4 FEET"); - img = cv::imread(fourFootImgName); - success = c.GetCurrentBallLocation(img, b, newBall); - } -} - - - -bool testProjection() { - - cv::Mat ball1Img, ball2Img; - GolfBall ball1, ball2; - - - const std::string kBaseTestDir = "D:\\GolfSim\\C++Code\\GolfSim\\ImageProcessing\\"; - - const std::string k0_DegreeBallFileName_00 = kBaseTestDir + "test_ball_masked_0_deg_dulled.png"; - const std::string k45_DegreeBallFileName_00 = kBaseTestDir + "test_ball_masked_45_deg_dulled.png"; - - ball1Img = cv::imread(k0_DegreeBallFileName_00, cv::IMREAD_COLOR); - ball2Img = cv::imread(k45_DegreeBallFileName_00, cv::IMREAD_COLOR); - - /* - gfilters = create_gaborfilter() - image_g = apply_filter(myimage, gfilters) - - min_interval = 120 - max_interval = 250 - image_edge_g = cv2.Canny(image_g, min_interval, max_interval) - - // Create a simple matrix of 3D points - // cv::Mat<> test3DPoints{ {0,0,1} } - - std::vector test2DPoints{ {0,0,1}, {1,0,0} }; - */ - return true; -} - -void showVisualization() { - /* TBD - probably too much work to get Viz working - ///Createawindow - viz::Viz3dmyWindow("VizDemo"); - ///Starteventloop - myWindow.spin(); - - ///Eventloopisoverwhenpressedq,Q,e,E cout<<"Firsteventloopisover"<({ cv::Point(ball1Img.cols / 2, ball1Img.rows / 2) })); - LoggingTools::DebugShowImage("Undistorted " + img2FileName, unDistortedBall2Img, std::vector({ cv::Point(ball1Img.cols / 2, ball1Img.rows / 2) })); - } - else { - unDistortedBall1Img = ball1Img; - unDistortedBall2Img = ball2Img; - } - - ball1ImgColor = unDistortedBall1Img; - ball2ImgColor = unDistortedBall2Img; - - cv::Mat ball1ImgGray; - cv::cvtColor(unDistortedBall1Img, ball1ImgGray, cv::COLOR_BGR2GRAY); - cv::Mat ball2ImgGray; - cv::cvtColor(unDistortedBall2Img, ball2ImgGray, cv::COLOR_BGR2GRAY); - - ball1Img = ball1ImgGray; - ball2Img = ball2ImgGray; - - return true; -} - - -bool absResultsPass(const cv::Vec2d& expected, const cv::Vec2d& result, const cv::Vec2d& absTolerances) { - bool pass = true; - - if (std::abs(expected[0] - result[0]) > absTolerances[0]) { - pass = false; - } - - if (std::abs(expected[1] - result[1]) > absTolerances[1]) { - pass = false; - } - - return pass; -} - - -bool absResultsPass(const cv::Vec3d& expected, const cv::Vec3d& result, const cv::Vec3d& absTolerances) { - bool pass = true; - - if (std::abs(expected[0] - result[0]) > absTolerances[0]) { - pass = false; - } - - if (std::abs(expected[1] - result[1]) > absTolerances[1]) { - pass = false; - } - - if (std::abs(expected[2] - result[2]) > absTolerances[2]) { - pass = false; - } - - return pass; -} - - - -struct LocationAndSpinTestScenario { - int testIndex = 0; - std::string img1; - std::string img2; - CameraHardware::CameraModel cameraModel; - std::vector camera_positions_from_origin; // In meters. Size=2, one vector for each image. If same, then only one camera position was used. - cv::Vec2i calibrationBallCenter; // The x,y coordinates of where the first ball's picture should concentrate as an ROI - cv::Vec3d expectedposition_deltas_ball_perspective_; - cv::Vec2d expectedXYBallAngleDegrees; - cv::Vec3d expectedXYz_rotation_degreesationDegrees; -}; - - -const cv::Vec3d kRotationAngleToleranceAbs = { 10, 10, 5 }; -const cv::Vec3d kDeltaLocationBallToleranceAbs = { 1, 1, 1 }; -const cv::Vec2d kLaunchAngleToleranceAbs = { 10, 10 }; - - - -void convertInchesToMeters(const cv::Vec3d& expectedPositionsInches, cv::Vec3d& expectedPositionsMeters) { - expectedPositionsMeters[0] = CvUtils::InchesToMeters(expectedPositionsInches[0]); - expectedPositionsMeters[1] = CvUtils::InchesToMeters(expectedPositionsInches[1]); - expectedPositionsMeters[2] = CvUtils::InchesToMeters(expectedPositionsInches[2]); -} - - - -bool testBallPosition() { - - LocationAndSpinTestScenario tests[] { - - - { 25, "test_3280w_BS_camoff04x_8.5y_20z_ball00z_02y_00degx_spin00z_v1.png", "test_3280w_camoff04x_8.5y_20z_ball10z_03y_15degx_spin30z_v1.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.1016, 0.2159, 0.508), cv::Vec3d(0.1016, 0.2159, 0.508)}), cv::Vec2i(1100, 1000), cv::Vec3d(3, 2, 10), cv::Vec2d(45, 7), cv::Vec3d(0, 0, 30) - }, - - { 20, "test_pos_4056w_cam04offx_14y_17z_Ball_0inz_00degx_00iny_00Zsp_00.png", "test_pos_4056w_cam04offx_14y_17z_Ball_10inz_10degx_00iny_30Zsp_00.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(0.1016, 0.3556, 0.4318), cv::Vec3d(.1016, 0.3556, 0.4318)}), cv::Vec2i(1100, 2000), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - - { 30, "IRtest02.1-filter.png", "IRtest02.2-filter.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(.0914, 0.0953, 0), cv::Vec3d(.0914, 0.0953, 0)}), cv::Vec2i(1400, 800), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - - { 21, "test_pos_4056w_cam04offx_14y_17z_Ball_0inz_00degx_00iny_00Zsp_00.png", "test_pos_4056w_cam04offx_3.5y_19x_Ball15inz_15degx_00iny_40Zsp_00.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(.0914, 0.0953, 0), cv::Vec3d(.0914, 0.0953, 0)}), cv::Vec2i(1100, 2000), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - - - /* These next two are too unfocused to work well and will likely fail */ - { 1, "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png", "test_pos_2592w_6off_22Dist_15inz_20degx_3iny_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(700, 1000), cv::Vec3d(0, 0, 0), cv::Vec2d(-20, 0), cv::Vec3d(0, 0, 0) - }, - { 2, "test_pos_BS_3280w_6off_22Dist_00inz_00degx_0iny_00_00s.png", "test_pos_3280w_6off_22Dist_15inz_10degx_3iny_00_90s.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(800, 1200), cv::Vec3d(0, 0, 0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 0) - }, - - { 22, "tt.png", "t.png", - CameraHardware::CameraModel::PiHQCam6mmWideLens, std::vector({cv::Vec3d(.0914, 0.0953, 0), cv::Vec3d(.0914, 0.0953, 0)}), cv::Vec2i(1100, 1000), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - { 0, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00_blur.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(1100, 1400), cv::Vec3d(6.5, -2, -1.5), cv::Vec2d(13, 0), cv::Vec3d(4, -2, 30) - }, - { 4, "test_ball_spin_strong_landmarks_00d_3280w_dark_00.png", "test_ball_spin_strong_landmarks_20d_3280w_dark_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0, 0, 0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 32) - }, - - /* These are better-focused and should work */ - { 11, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_00sp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 0) - }, - { 15, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_10degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.030, -0.038, 0.152), cv::Vec2d(-10, 4), cv::Vec3d(0, 0, 30) - }, - { 5, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_10degx_01iny_00sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(1200, 1100), cv::Vec3d(0.0, 0.025, 0.152), cv::Vec2d(15, 9.462), cv::Vec3d(0, 0, 0) - }, - { 3, "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png", "test_pos_2592w_6off_22Dist_10inz_30degx_.75iny_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0)}), cv::Vec2i(800, 1000), cv::Vec3d(0, 0, 0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 0) - }, - /* Clean pictures against a bllck back drop. This should be easy */ - { 7, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_30z20ysp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 26) - }, - /* Some tests that use only one camera */ - { 13, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01.25iny_15sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.038, -0.038, 0.152), cv::Vec2d(-15, 4), cv::Vec3d(5, 0, 15) - }, - /* Shadow hits the ball and creates a line - seems to screw up the spin analysis*/ - { 12, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01.25iny_15sp_Y00_shdw.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.038, -0.038, 0.152), cv::Vec2d(-15, 4), cv::Vec3d(0, 0, 15) - }, - { 14, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_15degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.038, -0.038, 0.152), cv::Vec2d(-15, 4), cv::Vec3d(5, 0, 30) - }, - { 16, "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00.png", "test_pos_3280w_03off_20Dist_06inz_00degx_01iny_30sp_Y00.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0.0762, 0, 0), cv::Vec3d(0.0762, 0, 0)}), cv::Vec2i(1300, 1100), cv::Vec3d(0.0, -0.038, 0.152), cv::Vec2d(0, 4), cv::Vec3d(0, 0, 30) - }, - - - - /* Same ball picture - should return zeros for everything */ - { 8, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(0, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0, 0.0, 0.0), cv::Vec2d(0, 0), cv::Vec3d(0, 0, 0) - }, - /* Clean pictures against a black back drop. This should be easy */ - { 9, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_30z00ysp_04.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 30) - }, - { 10, "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png", "test_pos_3280w_13off_18Dist_15inz_15degx_01iny_30z00ysp_04_F.png", - CameraHardware::CameraModel::PiCam2, std::vector({cv::Vec3d(0, 0, 0), cv::Vec3d(.33, 0, 0)}), cv::Vec2i(1400, 1100), cv::Vec3d(0.0925, -0.025, 0.381), cv::Vec2d(-15, 3), cv::Vec3d(0, 0, 30) - }, - { 6, "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png", "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png", - CameraHardware::CameraModel::PiCam2, std::vector({ cv::Vec3d(.0914, 0, 0), cv::Vec3d(.0914, 0, 0) }), cv::Vec2i(1200, 1000), cv::Vec3d(0.0, 0.0, 0.0), cv::Vec2d(0.0, 0.0), cv::Vec3d(0, 0, 45) - }, - }; - - /* TBD - this lower resolution is not working for spin detection right now - FIX! - // ALREADY ABOVE const std::string k0_DegreeBallFileName_00 = "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png"; - // const std::string kUnknown_DegreeBallFileName_00 = "test_pos_2592w_6off_22Dist_15inz_20degx_3iny_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_2592w_6off_22Dist_10inz_30degx_.75iny_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_6off_22Dist_00inz_00degx_0iny_00_00s.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_3280w_6off_22Dist_15inz_10degx_3iny_00_90s.png"; - - // Last trying this... - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_6off_20Dist_00inz_00degx_1iny_00sp_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_6off_20Dist_10inz_20degx_3iny_30sp_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_03.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_13off_18Dist_15inz_00degx_03iny_30sp_03.png"; - // The (simulated) camera and ball position will depend on the test images we use, above. - cv::Vec3d camera_positions_from_origin{ 0.33 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1500, 1200 }; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_0off_18Dist_00inz_00degx_01iny_00sp_04.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_13off_18Dist_15inz_15degx_01iny_30z20ysp_04.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_0off_20Dist_00inz_00degx_00iny_00sp_Y00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_BS_3280w_13off_20Dist_00inz_05degx_01iny_45sp_Y00.png"; - cv::Vec3d camera_positions_from_origin{ 0.33 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1500, 1200 }; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - cv::Vec3d camera_positions_from_origin{ 0.0 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1200, 1000 }; - - // ABOVE const std::string k0_DegreeBallFileName_00 = "test_pos_BS_3280w_03off_20Dist_00inz_00degx_01iny_00sp_Y00_blur.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_3280w_03off_20Dist_06inz_15degx_01iny_30sp_Y00.png"; - // The (simulated) camera and ball position will depend on the test images we use, above. - cv::Vec3d camera_positions_from_origin{ 0.0 ,0.0, 0.0 }; // In meters - cv::Vec2i calibrationBallCenter{ 1300, 1200 }; - - */ - - boost::timer::cpu_timer timer1; - - bool allTestsPassed = true; - int numTotalTests = 0; - int numTestsFailed = 0; - - for (auto& t : tests) { - - numTotalTests++; - - const std::string k0_DegreeBallFileName_00 = t.img1; - const std::string kUnknown_DegreeBallFileName_00 = t.img2; - // The (simulated) camera and ball position will depend on the test images we use, above. - std::vector camera_positions_from_origin = t.camera_positions_from_origin; // In meters - cv::Vec2i calibrationBallCenter = t.calibrationBallCenter; - - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - if (!read_test_images(k0_DegreeBallFileName_00, kUnknown_DegreeBallFileName_00, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, t.cameraModel)) { - GS_LOG_MSG(trace, "Failed to read valid images for Test No. " + std::to_string(t.testIndex)); - numTestsFailed++; - continue; - } - - - GolfSimCamera c; - c.camera_.resolutionX = ball1ImgColor.cols; - c.camera_.resolutionY = ball1ImgColor.rows; - c.camera_.resolutionXOverride = ball1ImgColor.cols; - c.camera_.resolutionYOverride = ball1ImgColor.rows; - - // Just for development on a non-Raspberry-Pi machine - c.camera_.firstCannedImageFileName = kBaseTestDir + k0_DegreeBallFileName_00; - c.camera_.secondCannedImageFileName = kBaseTestDir + kUnknown_DegreeBallFileName_00; - c.camera_.firstCannedImage = ball1ImgColor; - c.camera_.secondCannedImage = ball2ImgColor; - c.camera_.init_camera_parameters(t.cameraModel); - - long timeDelayuS = 7000; - GolfBall result_ball; - - GS_LOG_MSG(trace, "Starting Test No. " + std::to_string(t.testIndex) + "."); - - if (!c.analyzeShotImages(ball1ImgColor, ball2ImgColor, - timeDelayuS, - camera_positions_from_origin, - result_ball, - calibrationBallCenter)) { - GS_LOG_MSG(trace, "Failed Test No. " + std::to_string(t.testIndex)); - continue; - } - - double ball_velocity_meters_per_second_ = result_ball.velocity_; - - result_ball.PrintBallFlightResults(); - - bool testPassed = true; - - if (!absResultsPass(t.expectedXYz_rotation_degreesationDegrees, result_ball.ball_rotation_angles_camera_ortho_perspective_, kRotationAngleToleranceAbs)) { - GS_LOG_MSG(trace, "Test No. " + std::to_string(t.testIndex) + " - Failed ball rotation measurement."); - GS_LOG_MSG(trace, " Expected X,Y,Z rotation angles (in degrees) are: " + std::to_string((t.expectedXYz_rotation_degreesationDegrees[0])) + ", " + - std::to_string((t.expectedXYz_rotation_degreesationDegrees[1])) + ", " + std::to_string((t.expectedXYz_rotation_degreesationDegrees[2]))); - testPassed = false; - } - - cv::Vec3d expectedPositionsMeters; - convertInchesToMeters(t.expectedposition_deltas_ball_perspective_, expectedPositionsMeters); - - if (!absResultsPass(expectedPositionsMeters, result_ball.position_deltas_ball_perspective_, kDeltaLocationBallToleranceAbs)) { - GS_LOG_MSG(trace, "Test No. " + std::to_string(t.testIndex) + " - Failed ball delta location measurement."); - GS_LOG_MSG(trace, " Expected X,Y,Z deltas (ball perspective in inches) are: " + std::to_string(CvUtils::MetersToInches(t.expectedposition_deltas_ball_perspective_[0])) + ", " + - std::to_string(CvUtils::MetersToInches(t.expectedposition_deltas_ball_perspective_[1])) + ", " + std::to_string(CvUtils::MetersToInches(t.expectedposition_deltas_ball_perspective_[2]))); - testPassed = false; - } - - if (!absResultsPass(t.expectedXYBallAngleDegrees, result_ball.angles_ball_perspective_, kLaunchAngleToleranceAbs)) { - GS_LOG_MSG(trace, "Test No. " + std::to_string(t.testIndex) + " - Failed ball launch angle measurement."); - GS_LOG_MSG(trace, " Expected X,Y launch angles (ball perspective) (in degrees) are: " + std::to_string(t.expectedXYBallAngleDegrees[0]) + ", " + - std::to_string(t.expectedXYBallAngleDegrees[1])); - testPassed = false; - } - - if (!testPassed) { - numTestsFailed++; - } - - } - - GS_LOG_MSG(trace, "Final Test Statistics:\nTotal Tests: " + std::to_string(numTotalTests) + ".\nTests Failed: " + std::to_string(numTestsFailed) + "."); - - timer1.stop(); - boost::timer::cpu_times times = timer1.elapsed(); - std::cout << "analyzeShotImages timing: "; - std::cout << std::fixed << std::setprecision(8) - << times.wall / 1.0e9 << "s wall, " - << times.user / 1.0e9 << "s user + " - << times.system / 1.0e9 << "s system.\n"; - - - return true; -} - - -bool testSpinDetection() { - - /* - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_3280w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_14d_3280w_bright_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_01.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_08d_2592w_bright_01.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - - const std::string k0_DegreeBallFileName_00 = k"test_ball_spin_strong_landmarks_00d_2592w_bright_01.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_35d_2592w_bright_01.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d-x_3280w_bright_02.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_15d-x_3280w_bright_02.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_st-landmarks_rand_1st_3280w_bright_04.png"; - const std::string kUnknown_DegreeBallFileName_00 = k"test_ball_spin_st-landmarks_rand_2nd_3280w_bright_04.png"; - - const std::string k0_DegreeBallFileName_00 = "test_pos_2592w_BASE6off_22Dist_00inz_00degx_00iny_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_pos_2592w_6off_22Dist_10inz_30degx_.75iny_00.png"; - - const std::string k0_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_00d_2592w_bright_00.png"; - const std::string kUnknown_DegreeBallFileName_00 = "test_ball_spin_strong_landmarks_45d_2592w_bright_00.png"; - */ - - const std::string k0_DegreeBallFileName_00 = "strobed_spin_test_0z_ctr_02.png"; - const std::string kUnknown_DegreeBallFileName_00 = "strobed_spin_test_30z_-30x_ctr_02.png"; - // const std::string kUnknown_DegreeBallFileName_00 = "loc_test_20_degree_right_strobed.png"; - // NEEDS TO BE CORRECTED FOR EACH TEST - Assume only 1 camera for now, so all deltas are 0 - // In meters - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - - CameraHardware::CameraModel cameraModel = CameraHardware::PiGSCam6mmWideLens; - - if (!read_test_images(k0_DegreeBallFileName_00, kUnknown_DegreeBallFileName_00, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, cameraModel, false /*No Undistort*/)) { - GS_LOG_MSG(trace, "Failed to read valid images."); - return false; - } - - // Get the ball data. We will calibrate based on the first ball and then get the second one - // using that calibrated data from the first ball. - - GolfSimCamera c; - c.camera_.init_camera_parameters(cameraModel); - - GolfBall ball1, ball2; - - c.camera_.firstCannedImageFileName = kBaseTestDir + k0_DegreeBallFileName_00; - c.camera_.firstCannedImage = ball1ImgColor; - - cv::Vec2i expectedBallCenter = cv::Vec2i(1456 / 3, 1088 / 2); - - if (GolfSimOptions::GetCommandLineOptions().search_center_x_ > 0) { - expectedBallCenter[0] = GolfSimOptions::GetCommandLineOptions().search_center_x_; - } - - if (GolfSimOptions::GetCommandLineOptions().search_center_y_ > 0) { - expectedBallCenter[1] = GolfSimOptions::GetCommandLineOptions().search_center_y_; - } - - - bool success = c.GetCalibratedBall(ball1ImgColor, ball1, expectedBallCenter); - - if (!success) { - GS_LOG_MSG(trace, "Failed to GetCalibratedBall."); - return false; - } - c.camera_.firstCannedImageFileName = kBaseTestDir + k0_DegreeBallFileName_00; - c.camera_.secondCannedImageFileName = kBaseTestDir + kUnknown_DegreeBallFileName_00; - c.camera_.firstCannedImage = ball1ImgColor; - c.camera_.secondCannedImage = ball2ImgColor; - - success = c.GetCurrentBallLocation(ball2ImgColor, ball1, ball2); - - if (!success) { - GS_LOG_MSG(trace, "Could not find 2nd ball"); - return false; - } - - boost::timer::cpu_timer timer1; - - cv::Vec3d rotationResults = BallImageProc::GetBallRotation(ball1ImgGray, ball1, ball2ImgGray, ball2); - - timer1.stop(); - boost::timer::cpu_times times = timer1.elapsed(); - std::cout << "BallImageProc::GetBallRotation: "; - std::cout << std::fixed << std::setprecision(8) - << times.wall / 1.0e9 << "s wall, " - << times.user / 1.0e9 << "s user + " - << times.system / 1.0e9 << "s system.\n"; - - // TBD - Now implemented in the GetBallRotation() function. See how the original image would look if rotated as the GetBallRotation function calculated - - std::string s = "Ball Rotation (degrees): X: " + std::to_string(rotationResults[0]) + "\tY: " + std::to_string(rotationResults[1]) + "\tZ: " + std::to_string(rotationResults[2]) + "\n"; - - return true; -} - - - -bool TestSpin() { - - GolfBall ball1; - GolfBall ball2; - cv::Mat img1; - cv::Mat img2; - - GS_LOG_MSG(info, "TestSpin is in process."); - GS_LOG_MSG(trace, "Please position the ball for a first image and hit any key."); - - int keyPressed = cv::waitKey(0) & 0xFF; - -#ifdef __unix__ - - // Find the first ball. This will cause a pause to view the image - // that will allow the user to reposition the ball for a second time - if (!CheckForBall(ball1, img1)) { - GS_LOG_MSG(trace, "Failed to CheckForBall."); - return false; - } - - GS_LOG_MSG(trace, "Position the ball for a second image and hit any key."); - - if (!CheckForBall(ball2, img2)) { - GS_LOG_MSG(trace, "Failed to CheckForBall."); - return false; - } - - LoggingTools::LogImage("test_spin_img_ball1", img1, std::vector < cv::Point >{}, true); - LoggingTools::LogImage("test_spin_img_ball2", img2, std::vector < cv::Point >{}, true); - - cv::Mat grayImage1; - cv::Mat grayImage2; - cv::cvtColor(img1, grayImage1, cv::COLOR_BGR2GRAY); - cv::cvtColor(img2, grayImage2, cv::COLOR_BGR2GRAY); - - cv::Vec3d rotationResults = BallImageProc::GetBallRotation(grayImage1, ball1, grayImage2, ball2); -#endif - - return true; -} - -bool testAnalyzeStrobedBalls() { - - // Have to call this here because we are not starting the FSM, but need (simulated) pulse informatiom - PulseStrobe::InitGPIOSystem(); - - std::string kTwoImageTestTeedBallImage; - std::string kTwoImageTestStrobedImage; - std::string kTwoImageTestPreImage; - - GolfSimConfiguration::SetConstant("gs_config.testing.kTwoImageTestTeedBallImage", kTwoImageTestTeedBallImage); - GolfSimConfiguration::SetConstant("gs_config.testing.kTwoImageTestStrobedImage", kTwoImageTestStrobedImage); - GolfSimConfiguration::SetConstant("gs_config.testing.kTwoImageTestPreImage", kTwoImageTestPreImage); - - const std::string kTestCamImageFileName_00 = kTwoImageTestTeedBallImage; - - const std::string kTestCam2StrobedmageFileName = kTwoImageTestStrobedImage; - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - - CameraHardware::CameraModel cameraModel = CameraHardware::PiGSCam6mmWideLens; - - if (!read_test_images(kTestCamImageFileName_00, kTestCam2StrobedmageFileName, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, cameraModel,false /*No Undistort*/)) { - GS_LOG_MSG(trace, "Failed to read valid images."); - return false; - } - - // Also read the test pre-image - cv::Mat camera2_pre_image_color; - std::string preImageFileName = kBaseTestDir + kTwoImageTestPreImage; - - GS_LOG_MSG(trace, "Pre Image1: " + preImageFileName); - - camera2_pre_image_color = cv::imread(preImageFileName, cv::IMREAD_COLOR); - - if (camera2_pre_image_color.empty()) { - return false; - } - - GolfBall result_ball; - cv::Vec3d rotation_results; - - if (!GolfSimCamera::ProcessReceivedCam2Image(ball1ImgColor, ball2ImgColor, camera2_pre_image_color, result_ball, rotation_results)) { - GS_LOG_MSG(error, "Failed ProcessReceivedCam2Image."); - return false; - } - - GsGSProResults results(result_ball); - GS_LOG_MSG(trace, "Results are: " + results.Format()); - - PulseStrobe::DeinitGPIOSystem(); - - return true; -} - - - - -bool test_strobed_balls_detection() { - - const std::string kCam1BallOnTee = "test_strobe_spin_0_0_0.png"; - const std::string kCam2BallInFlight = "test_strobe_spin_0_0_45.png"; - - - cv::Mat ball1ImgGray; - cv::Mat ball2ImgGray; - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - if (!read_test_images(kCam1BallOnTee, kCam2BallInFlight, ball1ImgGray, ball2ImgGray, ball1ImgColor, ball2ImgColor, CameraHardware::PiGSCam6mmWideLens)) { - GS_LOG_MSG(error, "Failed to read valid images."); - return false; - } - - BallImageProc *ip = get_image_processor(); - - ip->image_name_ = kBaseTestDir + kCam1BallOnTee; - - GolfSimCamera c; - //get camera operational and make sure working correctly - c.camera_.cameraModel = CameraHardware::CameraModel::PiGSCam6mmWideLens; - - GolfBall ball1, ball2; - - c.camera_.firstCannedImageFileName = kBaseTestDir + kCam1BallOnTee; - c.camera_.firstCannedImage = ball1ImgColor; - c.camera_.secondCannedImageFileName = kBaseTestDir + kCam2BallInFlight; - c.camera_.secondCannedImage = ball2ImgColor; - c.camera_.init_camera_parameters(CameraHardware::CameraModel::PiGSCam6mmWideLens); - - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - - cv::Vec2i expectedBallCenter = cv::Vec2i(1456 / 2, 1088 / 2); - - if (GolfSimOptions::GetCommandLineOptions().search_center_x_ > 0) { - expectedBallCenter[0] = GolfSimOptions::GetCommandLineOptions().search_center_x_; - } - - if (GolfSimOptions::GetCommandLineOptions().search_center_y_ > 0) { - expectedBallCenter[1] = GolfSimOptions::GetCommandLineOptions().search_center_y_; - } - - bool success = c.GetCalibratedBall(ball1ImgColor, ball1, expectedBallCenter ); - - if (!success) { - GS_LOG_MSG(error, "Failed to determine first ball."); - return false; - } - - success = c.GetCurrentBallLocation(ball2ImgColor, ball1, ball2); - - if (!success) { - GS_LOG_MSG(error, "Failed to determine second ball."); - return false; - } - - - boost::timer::cpu_timer timer1; - - cv::Vec3d rotationResults = BallImageProc::GetBallRotation(ball1ImgGray, ball1, ball2ImgGray, ball2); - - timer1.stop(); - boost::timer::cpu_times times = timer1.elapsed(); - std::cout << "BallImageProc::GetBallRotation: "; - std::cout << std::fixed << std::setprecision(8) - << times.wall / 1.0e9 << "s wall, " - << times.user / 1.0e9 << "s user + " - << times.system / 1.0e9 << "s system.\n"; - - // TBD - Now implemented in the GetBallRotation() function. See how the original image would look if rotated as the GetBallRotation function calculated - - std::string s = "Ball Rotation (degrees): X: " + std::to_string(rotationResults[0]) + "\tY: " + std::to_string(rotationResults[1]) + "\tZ: " + std::to_string(rotationResults[2]) + "\n"; - - - return true; -} - - -bool test_hit_trigger() { - - GolfBall ball; - - //get camera operational and make sure working correctly - GolfSimCamera c; - c.camera_.cameraModel = CameraHardware::CameraModel::PiCam2; - - cv::Mat ball1ImgColor; - cv::Mat ball2ImgColor; - - const std::string kStationaryBallFileName_00 = kBaseTestDir + "move_test_ball_present_2592w_00.png"; - const std::string kStationaryBallFileName_01 = kBaseTestDir + "move_test_ball_present_2592w_01.png"; - const std::string kPreHitCloseBallFileName_00 = kBaseTestDir + "move_test_ball_and_club_present_2592w_00.png"; - const std::string kPostHitBallGoneFileName_00 = kBaseTestDir + "move_test_no_ball_present_2592w_00.png"; - - ball1ImgColor = cv::imread(kStationaryBallFileName_00, cv::IMREAD_COLOR); - ball2ImgColor = cv::imread(kStationaryBallFileName_01, cv::IMREAD_COLOR); - - cv::Mat ball1Img; - cv::Mat ball2Img; - ball1Img = undistort_image(ball1ImgColor, c.camera_.cameraModel); - ball2Img = undistort_image(ball2ImgColor, c.camera_.cameraModel); - - c.camera_.resolutionX = ball1Img.cols; - c.camera_.resolutionY = ball1Img.rows; - c.camera_.resolutionXOverride = ball1Img.cols; - c.camera_.resolutionYOverride = ball1Img.rows; - c.camera_.firstCannedImageFileName = kBaseTestDir + kStationaryBallFileName_00; - c.camera_.secondCannedImageFileName = kBaseTestDir + kStationaryBallFileName_01; - c.camera_.firstCannedImage = ball1Img; - c.camera_.secondCannedImage = ball2Img; - c.camera_.init_camera_parameters(c.camera_.cameraModel); - - - if (!c.prepareToTakePhoto()) { - GS_LOG_MSG(error, "Cannot prepare camera for photos"); - return false; - } - - - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - cv::Vec2i expectedBallCenter = cv::Vec2i(1300, 1000); - - GS_LOG_MSG(trace, "Looking for ball on tee"); - bool success = false; - bool timedOut = false; - cv::Mat img; - - while (!success && !timedOut) - { - img = c.camera_.take_photo(); - - if (img.empty()) { - GS_LOG_MSG(error, "Could not take picture!"); - return false; - } - - success = c.GetCalibratedBall(img, ball, expectedBallCenter); - GS_LOG_MSG(trace, "."); - } - - if (!success) { - LoggingTools::Warning("Could not find the first ball to calibrate!"); - return false; - } - - GS_LOG_MSG(trace, "Found ball" + ball.Format()); - - cv::Mat result_image; - - // ball.ball_circle_ = GsCircle{ (float)ball.x(), (float)ball.y(), (float)ball.measured_radius_pixels_ }; - - // TBD - override the camera hardware based on the image we find - if (BallImageProc::WaitForBallMovement(c, result_image, ball, 200)) { - GS_LOG_MSG(trace, "wait_for_movement returned True"); - LoggingTools::DebugShowImage("First image with movement", result_image); - } - else { - GS_LOG_MSG(trace, "wait_for_movement returned False"); - } - - return true; -} - - -bool TestGSProMessage() { - - try - { - std::string kGSProConnectAddress; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", kGSProConnectAddress); - std::string kGSProConnectPort; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", kGSProConnectPort); - - boost::asio::io_context io_context; - - tcp::resolver resolver(io_context); - GS_LOG_MSG(trace, "Connecting to GSProServer at address: " + kGSProConnectAddress + ":" + kGSProConnectPort); - tcp::resolver::results_type endpoints = resolver.resolve(kGSProConnectAddress, kGSProConnectPort); - - tcp::socket socket(io_context); - - boost::asio::connect(socket, endpoints); - - std::array buf; - boost::system::error_code error; - - GolfBall ball; - ball.velocity_ = 123.6; - GsGSProResults test_result(ball); - test_result.speed_mph_ = 123; - test_result.vla_deg_ = 23.4; - test_result.hla_deg_ = 1.23; - test_result.back_spin_rpm_ = 3456.0; - test_result.side_spin_rpm_ = 567.8; - - std::string test_msg = test_result.Format(); - - size_t write_length = socket.write_some(boost::asio::buffer(test_msg), error); - - std::cout << "Wrote " << write_length << " bytes to server socket." << std::endl; - - - std::string buffer_string; - - GS_LOG_MSG(trace, "Waiting to receive response from GSProserver."); - - size_t len = socket.read_some(boost::asio::buffer(buf), error); - if (len == 0) { - GS_LOG_MSG(warning, "Received 0-length message from server."); - return false; - } - - // Null-terminate the string - buf[len] = (char)0; - buffer_string += std::string(buf.data()); - GS_LOG_MSG(trace, " Read some data (" + std::to_string(len) + " bytes) : " + buffer_string); - - if (error == boost::asio::error::eof) - return false; // Connection closed cleanly by peer. - else if (error) - throw boost::system::system_error(error); // Some other error. - - std::string json = buf.data(); - - GsGSProResponse gspro_response; - if (!gspro_response.ParseJson(json)) { - GS_LOG_MSG(error, "Failed TestGSProMessage - Could not parse json: " + json); - return false; - } - - GS_LOG_MSG(trace, "Received GSPro Result of: " + gspro_response.Format() + ". Sleeping for a bit for test."); -#ifdef __unix__ - sleep(33); -#endif - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProMessage - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -bool TestGSProServer() { - try - { - int kGSProConnectPort; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", kGSProConnectPort); - - boost::asio::io_context io_context; - GsGSProTestServer server(io_context, kGSProConnectPort); - GS_LOG_MSG(trace, "About to call io_context.run()"); - io_context.run(); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProServer - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -void test_gspro_communication() { - - GolfBall ball; - ball.rotation_speeds_RPM_[2] = 5000.; - ball.rotation_speeds_RPM_[0] = 100.; - GsGSProResults results(ball); - std::string json = results.Format(); - GS_LOG_MSG(debug, json); -} - -void test_function(int argc, char* argv[]) -{ - GS_LOG_MSG(trace, "Test called"); - - // Start of testing - GS_LOG_MSG(trace, "Running on " + GolfSimPlatform); // platform.platform()); - GS_LOG_MSG(trace, "OpenCV Version " + std::string(CV_VERSION)); - - // test_strobed_balls_detection(); - // testBallPosition(); - // test_gspro_communication(); - -#ifdef __unix__ - - if (GolfSimOptions::GetCommandLineOptions().shutdown_) { - - GS_LOG_MSG(trace, "Running in global shutdown mode."); - if (!PerformSystemStartupTasks()) { - GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); - return; - } - - // Give the IPC threads time to start - sleep(2); - - GolfSimIPCMessage ipc_message(GolfSimIPCMessage::IPCMessageType::kShutdown); - GolfSimIpcSystem::SendIpcMessage(ipc_message); - - // Give the IPC thread time to send the message - sleep(1); - - PerformSystemShutdownTasks(); - - return; - } - - if (GolfSimOptions::GetCommandLineOptions().send_test_results_) { - - GS_LOG_MSG(trace, "Running in send_test_results mode."); - if (!PerformSystemStartupTasks()) { - GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); - return; - } - - // Give the IPC threads time to start - sleep(2); - - GolfSimIPCMessage ipc_message(GolfSimIPCMessage::IPCMessageType::kResults); - - - - GsIPCResult& results = ipc_message.GetResultsForModification(); - - results.result_type_ = GsIPCResultType::kHit; - results.speed_mpers_ = 50 + rand() % 40; - results.carry_meters_ = 100 + rand() % 150; - results.launch_angle_deg_ = 12.34; - // Tests edge case where a float can be interpreted by MSGPACK as an integer if there is no fractional part - results.side_angle_deg_ = 3.0; - results.back_spin_rpm_ = 4567; - results.side_spin_rpm_ = 89; - results.message_ = "Test message"; - - results.log_messages_.push_back("[2024-01-22 11:19:36.321980] (0x000053c0) [debug] [bool __cdecl golf_sim::GolfSimCamera::GetCalibratedBall(const class cv::Mat &,const class cv::Vec &,class golf_sim::GolfBall &,const class cv::Vec &)(D:\\GolfSim\\LM\\ImageProcessing\\gs_camera.cpp:118)<-bool __cdecl golf_sim::GolfSimCamera::GetCalibratedBall(const class cv::Mat &,const class cv::Vec &,class golf_sim::GolfBall &,const class cv::Vec &)(D:\\GolfSim\\LM\\ImageProcessing\\gs_camera.cpp:86)...] Looking for a ball with min/max radius (pixels) of: 56, 85"); - results.log_messages_.push_back("[2024 - 01 - 22 11:19 : 36.497181](0x000053c0)[trace][void __cdecl golf_sim::LoggingTools::ShowImage(class std::basic_string, class std::allocator >, const class cv::Mat&, const class std::vector, class std::allocator > > &)(D:\\GolfSim\\LM\\ImageProcessing\\logging_tools.cpp:270) < -bool __cdecl golf_sim::GolfSimCamera::GetCalibratedBall(const class cv::Mat&, const class cv::Vec &, class golf_sim::GolfBall&, const class cv::Vec &)(D:\\GolfSim\\LM\\ImageProcessing\\gs_camera.cpp:118)...] ShowImage(AreaMaskImage Photo, (sizeX, sizeY) = (1456, 1088)"); - - GS_LOG_MSG(trace, "Sending a test IPC Results Message:\n" + results.Format()); - GolfSimIpcSystem::SendIpcMessage(ipc_message); - - // Give the IPC thread time to send the message - sleep(1); - - PerformSystemShutdownTasks(); - - return; - } - - - // In this mode, we just try to send the shutter and strobe pulses asap - // Only do so if this is the camera1 system, of course - // if in cam2_still_mode on the camera2 system, the only difference in - // operation will be the number of strobe pulses is cut to 1 - if (GolfSimOptions::GetCommandLineOptions().camera_still_mode_) { - - if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1) { - // Just call the get-the-ball function to trigger an image capture. - // It will be saved as an artifact - - // TBD - - } - - - if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2) { - - // TBD - Not completed yet - GS_LOG_MSG(trace, "Running in pulse cam2 still mode on camera1 system. Will take one strobed picture in camera2 system."); - - if (GolfSimOptions::GetCommandLineOptions().output_filename_.empty()) { - GS_LOG_MSG(trace, "No output output_filename specified. Will save picture as: " + std::string(LoggingTools::kDefaultSaveFileName)); - } - - // Will need the GPIO system for the camera trigger and stobe - // The camera2 system will also have to be setup. - if (!PulseStrobe::InitGPIOSystem()) { - GS_LOG_MSG(error, "Failed to InitGPIOSystem."); - return; - } - - if (!PulseStrobe::SendCameraPrimingPulses()) { - GS_LOG_MSG(error, "FAILED to PulseStrobe::SendCameraPrimingPulses"); - } - - // Give the camera2 system a moment - sleep(1); - - PulseStrobe::SendExternalTrigger(); - - // At this point, the camera2 sytem should take a picture and return it - - } - - return; - } - - if (GolfSimOptions::GetCommandLineOptions().perform_pulse_test_) { - - GS_LOG_MSG(trace, "Running in pulse test mode."); - if (!PulseStrobe::InitGPIOSystem()) { - GS_LOG_MSG(error, "Failed to InitGPIOSystem."); - return; - } - - while (true) { - PulseStrobe::SendExternalTrigger(); - sleep(3); - } - return; - } - - switch (GolfSimOptions::GetCommandLineOptions().system_mode_) { - - case SystemMode::kCamera1: - case SystemMode::kCamera1TestStandalone: - { - GS_LOG_MSG(info, "Running in kCamera1 or kCamera1TestStandalone mode."); - state::InitializingCamera1System camera1_state; - RunGolfSimFsm(camera1_state); - break; - } - - case SystemMode::kCamera2: - case SystemMode::kCamera2TestStandalone: - { - GS_LOG_MSG(info, "Running in kCamera2 or kCamera2TestStandalone mode."); - - state::InitializingCamera2System camera2_state; - RunGolfSimFsm(camera2_state); - break; - } - - case SystemMode::kTestSpin: - { - GS_LOG_MSG(info, "Running in kTestSpin mode."); - - TestSpin(); - break; - } - - case SystemMode::kTest: - { - testAnalyzeStrobedBalls(); - // test_hit_trigger(); - break; - } - - case SystemMode::kCamera1Calibrate: - case SystemMode::kCamera2Calibrate: - { - GS_LOG_MSG(info, "Running in kCamera1Calibrate or kCamera2Calibrate mode."); - - // We will want to send a calibration message to any monitor UIs - if (!GolfSimIpcSystem::InitializeIPCSystem()) { - GS_LOG_MSG(info, "Failed to InitializeIPCSystem."); - return; - } - - GolfBall ball; - cv::Mat img; - // In addition to checking for the ball, this method will send an IPC results - // message if we are in calibration mode. - - GS_LOG_MSG(info, "Calibration Results (Distance of kCamera (1 OR 2) CalibrationDistanceToBall):"); - double average_focal_length = 0.0; - const int number_attempts = 10; - int number_samples = 0; - - for (int i = 0; i < number_attempts; i++) { - if (!CheckForBall(ball, img)) { - GS_LOG_MSG(trace, "Failed to CheckForBall - skipping"); - continue; - } - - number_samples++; - GS_LOG_MSG(trace, "Performing focal length calibration"); - - average_focal_length += ball.calibrated_focal_length_; - std::string calibration_results_message = "Focal Length = " + std::to_string(ball.calibrated_focal_length_) + "."; - GS_LOG_MSG(info, calibration_results_message); -#ifdef __unix__ - GsUISystem::SendIPCStatusMessage(GsIPCResultType::kCalibrationResults, calibration_results_message); -#endif - } - - average_focal_length /= number_samples; - GS_LOG_MSG(info, "====> Average Focal Length = " + std::to_string(average_focal_length) + ".Set this value into the gs_config.json file."); - - GolfSimIpcSystem::ShutdownIPCSystem(); - } - break; - - case SystemMode::kTestGSProMessage: - { - if (!TestGSProMessage()) { - GS_LOG_MSG(info, "Failed to TestGSProMessage."); - return; - } - } - break; - - case SystemMode::kTestGSProServer: - { - if (!TestGSProServer()) { - GS_LOG_MSG(info, "Failed to TestGSProSever."); - return; - } - } - break; - - case SystemMode::kCamera1BallLocation: - case SystemMode::kCamera2BallLocation: - { - GS_LOG_MSG(info, "Running in kCamera1BallLocation or kCamera2BallLocation mode."); - - // We will want to send a test rest results message to any monitor UIs - if (!GolfSimIpcSystem::InitializeIPCSystem()) { - GS_LOG_MSG(info, "Failed to InitializeIPCSystem."); - return; - } - - GolfBall ball; - cv::Mat img; - std::vector camera_positions_from_origin = std::vector({ GolfSimCamera::kCamera1PositionsFromOriginMeters, GolfSimCamera::kCamera2PositionsFromOriginMeters }); - - CameraHardware::CameraModel cameraModel = CameraHardware::PiGSCam6mmWideLens; - GolfSimCamera c; - c.camera_.init_camera_parameters(cameraModel); - - - while (GolfSimGlobals::golf_sim_running_) { - // In addition to checking for the ball, this method will send an IPC results - // message if we are in calibration mode. - bool status = CheckForBall(ball, img); - - if (status) { - - GS_LOG_MSG(info, "Found Ball:"); -/* TBD - REMOVE - if (!c.GolfSimCamera::ComputeSingleBallXYZOrthoCamPerspective(ball, camera_positions_from_origin, GolfSimCamera::kCamera1Angles)) { - GS_LOG_MSG(error, "ComputeBallDeltas: Could not ComputeSingleBallXYZOrthoCamPerspective for ball1"); - return; - } -*/ - GS_LOG_MSG(info, ball.Format() + "\n\n"); - - GsCircle final_circle; - - cv::Mat gray_image; - cv::cvtColor(img, gray_image, cv::COLOR_BGR2GRAY); - - bool choose_largest_final_ball = false; - - if (!BallImageProc::DetermineBestCircle(gray_image, ball, choose_largest_final_ball, final_circle)) { - GS_LOG_MSG(error, "Failed to DetermineBestCircle."); - } - else - { - // A ball was found - show it to the user - - GS_LOG_MSG(trace, "Best Circle xiRadius was: " + std::to_string(final_circle[2]) + " pixels.\n\n"); - } - - - // TBD - Send Test results IPC message - } - } - - GolfSimIpcSystem::ShutdownIPCSystem(); - } - break; - - default: - break; - } - -#else - // TBD - REMOVE -Just for testing - std::string address; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", address); - - testAnalyzeStrobedBalls(); - // testSpinDetection(); -#endif - - return; - - /* - - //TBD - Temporary to get ball location for action trigger test - // First call will ensure we have the OpenCV library loaded by doing a dummy test - for (int i = 0; i < 10; i++) { - test_hit_trigger(); - } - - - test_calibrated_location("D:\\GolfSim\\C++Code\\GolfSim\\ImageProcessing\\move_test_ball_present_1024w_00.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-4-feet-HiRes_01.png"); - - - - test_calibrated_location("D:\\GolfSim\\C++Code\\GolfSim\\ImageProcessing\\move_test_ball_present_00.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-4-feet-HiRes_01.png"); - test_calibrated_location("./Images/FakePiCamGolfBall-Perfect-Clr-Green-2-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-Green-4-feet-HiRes_01.png"); - - test_calibrated_location("./Images/FakePiCamGolfBall-Perfect-Clr-White-2-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-White-3-feet-HiRes_01.png", - "./Images/FakePiCamGolfBall-Perfect-Clr-White-4-feet-HiRes_01.png"); - - test_certain_images(); - - test_all_test_files(); - */ -} - - - -int main(int argc, char *argv[]) -{ - if (!GolfSimOptions::GetCommandLineOptions().Parse(argc, argv)) - { - GS_LOG_MSG(error, "Could not GetCommandLineOptions. Exiting."); - return 0; - } - - LoggingTools::InitLogging(); - - GS_LOG_MSG(info, "Golf Sim Launch Monitor Started"); - - GolfSimOptions::GetCommandLineOptions().Print(); - - std::string config_file_name = "golf_sim_config.json"; - - if (!GolfSimOptions::GetCommandLineOptions().config_file_.empty()) { - config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - } - - if (!GolfSimConfiguration::Initialize(config_file_name)) { - GS_LOG_MSG(error, "Could not intialize configuration module using config file: " + config_file_name + ". Exiting."); - return 0; - } - - LoggingTools::logging_tool_wait_for_keypress_ = GolfSimOptions::GetCommandLineOptions().wait_for_key_on_images_; - - // TBD - consider if there is a better place for this? - GolfSimGlobals::golf_sim_running_ = true; - - test_function(argc, argv); - - GS_LOG_MSG(trace, "Finished test_function."); - - // GS_LOG_MSG(trace, "Waiting for any keypress to end program."); - // cv::waitKey(0); - - GS_LOG_MSG(trace, "Tests Complete"); -} diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp index c09bd8a0..690c5b1c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.cpp @@ -6,13 +6,20 @@ #ifdef __unix__ // Ignore in Windows environment +#include +#include +#include +#include +#include + +#include + #include "logging_tools.h" #include "gs_result_types.h" #include "gs_options.h" #include "gs_clubs.h" #include "gs_ui_system.h" -#include "gs_sim_interface.h" #include "gs_camera.h" #include "gs_http_client.h" #include "cv_utils.h" @@ -27,6 +34,28 @@ namespace golf_sim { std::string GsUISystem::kWebServerErrorExposuresImage; std::string GsUISystem::kWebServerBallSearchAreaImage; + long GsUISystem::current_shot_id_ = 0; + std::vector GsUISystem::current_shot_image_paths_; + std::mutex GsUISystem::shot_images_mutex_; + std::atomic GsUISystem::sim_armed_{ true }; + std::atomic GsUISystem::sim_club_{ GolfSimClubs::kNotSelected }; + long GsUISystem::shot_counter_ = 0; + + std::string GsUISystem::CurrentShotRelativePath(const std::string& file_name) { + std::lock_guard lock(shot_images_mutex_); + if (current_shot_id_ == 0) { + current_shot_id_ = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + return "shots/" + std::to_string(current_shot_id_) + "/" + file_name; + } + + void GsUISystem::ResetCurrentShot() { + std::lock_guard lock(shot_images_mutex_); + current_shot_id_ = 0; + current_shot_image_paths_.clear(); + } + static std::string EscapeJson(const std::string& s) { std::string out; out.reserve(s.size() + 16); @@ -47,7 +76,8 @@ namespace golf_sim { float speed_mps = 0, float launch_deg = 0, float side_deg = 0, int back_spin = 0, int side_spin = 0, int carry_m = 0, - const std::vector& images = {}) { + const std::vector& images = {}, + long shot_id = 0) { std::string json = "{"; json += "\"result_type\":" + std::to_string(result_type); json += ",\"speed_mps\":" + std::to_string(speed_mps); @@ -62,10 +92,61 @@ namespace golf_sim { if (i > 0) json += ","; json += "\"" + EscapeJson(images[i]) + "\""; } - json += "]}"; + json += "]"; + if (shot_id != 0) { + json += ",\"shot_id\":" + std::to_string(shot_id); + } + json += "}"; return json; } + bool GsUISystem::SimArmed() { + return sim_armed_; + } + + GolfSimClubs::GsClubType GsUISystem::SimClub() { + return sim_club_; + } + + void GsUISystem::IncrementShotCounter() { + shot_counter_++; + } + + long GsUISystem::GetShotCounter() { + return shot_counter_; + } + + void GsUISystem::PostResult(const std::string& json) { + std::string reply = GsHttpClient::PostResult(json); + + // A missing field, an unreadable reply or a server that is down all leave the FSM armed. + bool armed = true; + std::string club; + if (!reply.empty()) { + try { + boost::property_tree::ptree pt; + std::istringstream reply_stream(reply); + boost::property_tree::read_json(reply_stream, pt); + armed = pt.get("armed", "true") != "false"; + club = pt.get("club", ""); + } + catch (const std::exception& e) { + GS_LOG_MSG(warning, "Could not read the web server reply: " + std::string(e.what())); + } + } + sim_armed_ = armed; + + if (club == "putter") { + sim_club_ = GolfSimClubs::GsClubType::kPutter; + } + else if (club == "driver") { + sim_club_ = GolfSimClubs::GsClubType::kDriver; + } + else { + sim_club_ = GolfSimClubs::GsClubType::kNotSelected; + } + } + void GsUISystem::SendIPCErrorStatusMessage(const std::string& error_message) { std::string msg; @@ -76,9 +157,22 @@ namespace golf_sim { msg = error_message; } + // Flush whatever images this failed attempt accumulated so they attach to this + // shot's result and don't bleed into the next shot. + std::vector images; + long shot_id; + { + std::lock_guard lock(shot_images_mutex_); + images = current_shot_image_paths_; + shot_id = current_shot_id_; + current_shot_image_paths_.clear(); + current_shot_id_ = 0; + } + GS_LOG_TRACE_MSG(trace, "Sending error result: " + msg); - GsHttpClient::PostResult(BuildResultJson( - static_cast(GsIPCResultType::kError), msg)); + PostResult(BuildResultJson( + static_cast(GsIPCResultType::kError), msg, + 0, 0, 0, 0, 0, 0, images, shot_id)); } @@ -92,7 +186,7 @@ namespace golf_sim { case GsIPCResultType::kWaitingForBallToAppear: if (GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera1Calibrate || GolfSimOptions::GetCommandLineOptions().system_mode_ == SystemMode::kCamera2Calibrate) { - msg = "Waiting for ball to be teed up at " + std::to_string(GolfSimCamera::kCamera1CalibrationDistanceToBall) + "cm in order to perform calibration."; + msg = "Waiting for ball to be teed up in order to perform calibration."; } else { msg = "Waiting for ball to be teed up."; } @@ -115,6 +209,9 @@ namespace golf_sim { case GsIPCResultType::kCalibrationResults: msg = "Returning Camera Calibration Results - see message."; break; + case GsIPCResultType::kControlMessage: + msg = "Control message."; + break; default: GS_LOG_TRACE_MSG(trace, "SendIPCStatusMessage received unknown GsIPCResultType : " + std::to_string((int)message_type)); return false; @@ -125,7 +222,7 @@ namespace golf_sim { } GS_LOG_TRACE_MSG(trace, "Sending status result: " + msg); - GsHttpClient::PostResult(BuildResultJson(static_cast(message_type), msg)); + PostResult(BuildResultJson(static_cast(message_type), msg)); return true; } @@ -135,13 +232,21 @@ namespace golf_sim { float side = result_ball.angles_ball_perspective_[0]; int back_spin = static_cast(result_ball.rotation_speeds_RPM_[2]); int side_spin = static_cast(result_ball.rotation_speeds_RPM_[0]); - int carry = 100 + rand() % 150; + int carry = 0; std::vector images; + long shot_id; + { + std::lock_guard lock(shot_images_mutex_); + images = current_shot_image_paths_; + shot_id = current_shot_id_; + current_shot_image_paths_.clear(); + current_shot_id_ = 0; + } std::string msg = "Ball Hit - Results returned." + secondary_message; - GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GsSimInterface::GetShotCounter()) + GS_LOG_MSG(info, "BALL_HIT_CSV, " + std::to_string(GetShotCounter()) + ", (carry - NA), (Total - NA), (Side Dest - NA), (Smash Factor - NA), (Club Speed - NA), " + std::to_string(CvUtils::MetersPerSecondToMPH(speed)) + ", " + std::to_string(back_spin) + ", " @@ -150,9 +255,9 @@ namespace golf_sim { + std::to_string(side) + ", (Descent Angle-NA), (Apex-NA), (Flight Time-NA), (Type-NA)"); - GsHttpClient::PostResult(BuildResultJson( + PostResult(BuildResultJson( static_cast(GsIPCResultType::kHit), msg, - speed, launch, side, back_spin, side_spin, carry, images)); + speed, launch, side, back_spin, side_spin, carry, images, shot_id)); } @@ -170,7 +275,7 @@ namespace golf_sim { std::string file_name(input_file_name); if (GolfSimCamera::kLogDiagnosticImagesToUniqueFiles && !suppress_diagnostic_saving) { - LoggingTools::LogImage(file_name + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GsSimInterface::GetShotCounter())); + LoggingTools::LogImage(file_name + "_", img, std::vector < cv::Point >{}, false, "", "_Shot_" + std::to_string(GetShotCounter())); } if (!GolfSimCamera::kLogWebserverImagesToFile) { @@ -181,11 +286,25 @@ namespace golf_sim { file_name += ".png"; } - std::string fname = kWebServerShareDirectory + file_name; + // Route every shot image into its own per-shot directory so shot history survives. + // NOTE: CurrentShotRelativePath takes shot_images_mutex_, so call it before taking + // the lock below to record the path (otherwise we'd deadlock). + std::string relative = CurrentShotRelativePath(file_name); + std::string fname = kWebServerShareDirectory + relative; + std::filesystem::create_directories(std::filesystem::path(fname).parent_path()); try { if (cv::imwrite(fname, img)) { GS_LOG_TRACE_MSG(trace, "Logged image to file: " + fname); + + // Record the relative path once per shot. Some images (e.g. the ball + // search-area image) are re-saved every wait-loop cycle; overwriting the + // file on disk is fine, but we must not flood the POST/DB with dupes. + std::lock_guard lock(shot_images_mutex_); + if (std::find(current_shot_image_paths_.begin(), current_shot_image_paths_.end(), relative) + == current_shot_image_paths_.end()) { + current_shot_image_paths_.push_back(relative); + } } else { GS_LOG_MSG(warning, "GsUISystem::SaveWebserverImage - could not save to file name: " + fname); diff --git a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h index c8d706b4..0bfd801c 100644 --- a/Software/LMSourceCode/ImageProcessing/gs_ui_system.h +++ b/Software/LMSourceCode/ImageProcessing/gs_ui_system.h @@ -10,8 +10,14 @@ #ifdef __unix__ // Ignore in Windows environment +#include +#include +#include +#include + #include "logging_tools.h" #include "golf_ball.h" +#include "gs_clubs.h" #include "gs_result_types.h" @@ -30,7 +36,21 @@ namespace golf_sim { static std::string kWebServerResultBallRotatedByBestAngles; static std::string kWebServerErrorExposuresImage; static std::string kWebServerBallSearchAreaImage; - + + // Per-shot image bookkeeping. shot_id is an epoch-ms id minted once per shot + // (lazily, when the first image of the shot is saved) and reset when the shot's + // result is flushed to the web server. Image paths are relative to the share dir. + static long current_shot_id_; + static std::vector current_shot_image_paths_; + static std::mutex shot_images_mutex_; + + // Returns "shots//", minting the shot id if not already set. + static std::string CurrentShotRelativePath(const std::string& file_name); + + // Clears the current shot id and accumulated image paths so the next shot starts + // fresh. Used when a shot is abandoned (timeout/trigger failure) without a result POST. + static void ResetCurrentShot(); + static void SendIPCErrorStatusMessage(const std::string& error_message); @@ -38,6 +58,17 @@ namespace golf_sim { static void SendIPCHitMessage(const GolfBall& result_ball, const std::string& secondary_message = ""); + // Whether the web server's sims are ready for a shot, as of its last reply. + static bool SimArmed(); + + // The club the web server's sims last asked for, kNotSelected when none has. + // The FSM applies it only at startup and just before Camera2 is armed. + static GolfSimClubs::GsClubType SimClub(); + + // Numbers each shot for the log lines and image names. + static void IncrementShotCounter(); + static long GetShotCounter(); + // Save the image into the shared web-server directory so that the web-based // golf-sim user interface can access it. // Also save a uniquely-named copy to the usual images directory unless suppressed. @@ -46,6 +77,15 @@ namespace golf_sim { static bool SaveWebserverImage(const std::string& file_name, const cv::Mat& img, const std::vector& balls, bool suppress_diagnostic_saving = false); static void ClearWebserverImages(); + + private: + // Posts to the web server and records the armed state and club in its reply. + static void PostResult(const std::string& json); + + static std::atomic sim_armed_; + static std::atomic sim_club_; + + static long shot_counter_; }; } diff --git a/Software/LMSourceCode/ImageProcessing/gs_web_api.cpp b/Software/LMSourceCode/ImageProcessing/gs_web_api.cpp deleted file mode 100644 index aa5df84b..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_web_api.cpp +++ /dev/null @@ -1,137 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#include "gs_web_api.h" -#include "logging_tools.h" -#include -#include -#include - -#ifdef _WIN32 -#define popen _popen -#define pclose _pclose -#endif - - -namespace golf_sim { - -bool WebApi::UpdateCalibration(const std::string& key, double value) { - std::string url = GetWebServerUrl() + "/api/config/" + key; - std::string payload = "{\"value\": " + FormatAsJson(value) + "}"; - std::string response; - - bool success = ExecuteCurl(url, "PUT", payload, response); - - if (success) { - GS_LOG_MSG(info, "Successfully updated calibration: " + key + " = " + std::to_string(value)); - } else { - GS_LOG_MSG(warning, "Failed to update calibration via web API: " + key + - ". Web server may not be running. Calibration saved locally to golf_sim_config.json"); - } - - return success; -} - -bool WebApi::UpdateCalibration(const std::string& key, const std::vector& values) { - std::string url = GetWebServerUrl() + "/api/config/" + key; - std::string payload = "{\"value\": " + FormatAsJson(values) + "}"; - std::string response; - - bool success = ExecuteCurl(url, "PUT", payload, response); - - if (success) { - GS_LOG_MSG(info, "Successfully updated calibration array: " + key); - } else { - GS_LOG_MSG(warning, "Failed to update calibration via web API: " + key + - ". Web server may not be running. Calibration saved locally to golf_sim_config.json"); - } - - return success; -} - -bool WebApi::IsWebServerAvailable() { - std::string url = GetWebServerUrl() + "/health"; - std::string response; - - return ExecuteCurl(url, "GET", "", response); -} - -std::string WebApi::GetWebServerUrl() { - const char* env_url = std::getenv("PITRAC_WEB_SERVER_URL"); - if (env_url != nullptr) { - return std::string(env_url); - } - return kDefaultWebServerUrl; -} -std::string WebApi::ShellEscape(const std::string& input) { - std::stringstream escaped; - - escaped << "'"; - - for (char c : input) { - if (c == '\'') { - escaped << "'\\''"; - } else { - escaped << c; - } - } - - escaped << "'"; - return escaped.str(); -} - -bool WebApi::ExecuteCurl(const std::string& url, const std::string& method, - const std::string& payload, std::string& response) { - std::stringstream cmd; - - // Build curl command with timeout and silent mode - cmd << "curl -s -m 2 -X " << ShellEscape(method); - - if (!payload.empty()) { - cmd << " -H 'Content-Type: application/json'"; - cmd << " -d " << ShellEscape(payload); - - } - cmd << " " << ShellEscape(url) << " 2>/dev/null"; - - // Execute curl command - FILE* pipe = popen(cmd.str().c_str(), "r"); - if (!pipe) { - return false; - } - - // Read response - char buffer[128]; - response.clear(); - while (!feof(pipe)) { - if (fgets(buffer, 128, pipe) != nullptr) { - response += buffer; - } - } - - int exit_code = pclose(pipe); - - // Check if curl succeeded (exit code 0) - return exit_code == 0 && !response.empty(); -} - -std::string WebApi::FormatAsJson(double value) { - std::stringstream ss; - ss << std::setprecision(10) << value; - return ss.str(); -} - -std::string WebApi::FormatAsJson(const std::vector& values) { - std::stringstream ss; - ss << "["; - for (size_t i = 0; i < values.size(); ++i) { - if (i > 0) ss << ", "; - ss << std::setprecision(10) << values[i]; - } - ss << "]"; - return ss.str(); -} - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/gs_web_api.h b/Software/LMSourceCode/ImageProcessing/gs_web_api.h deleted file mode 100644 index 8a247077..00000000 --- a/Software/LMSourceCode/ImageProcessing/gs_web_api.h +++ /dev/null @@ -1,41 +0,0 @@ -/* SPDX-License-Identifier: GPL-2.0-only */ -/* - * Copyright (C) 2022-2025, Verdant Consultants, LLC. - */ - -#pragma once - -#include -#include - -namespace golf_sim { - -class WebApi { -public: - // Send calibration update to web server - // Returns true if successful, false otherwise - static bool UpdateCalibration(const std::string& key, double value); - static bool UpdateCalibration(const std::string& key, const std::vector& values); - - // Check if web server is available - static bool IsWebServerAvailable(); - -private: - // Get web server URL from environment or use default - static std::string GetWebServerUrl(); - - // Execute curl command and return response - static bool ExecuteCurl(const std::string& url, const std::string& method, - const std::string& payload, std::string& response); - - static std::string ShellEscape(const std::string& input); - - // Format value as JSON - static std::string FormatAsJson(double value); - static std::string FormatAsJson(const std::vector& values); - - // Default web server URL - static constexpr const char* kDefaultWebServerUrl = "http://localhost:8080"; -}; - -} // namespace golf_sim \ No newline at end of file diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp index cc500857..934b181e 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.cpp @@ -57,22 +57,19 @@ namespace golf_sim { double LibCameraInterface::kCamera1Saturation = 1.0; double LibCameraInterface::kCamera1HighFPSGain = 15.0; double LibCameraInterface::kCamera1Contrast = 1.0; - double LibCameraInterface::kCamera2Gain = 6.0; + double LibCameraInterface::kCamera2Gain = 3.0; double LibCameraInterface::kCamera2Saturation = 1.0; double LibCameraInterface::kCamera2ComparisonGain = 0.8; - double LibCameraInterface::kCamera2StrobedEnvironmentGain = 0.8; - double LibCameraInterface::kCamera2Contrast = 1.0; + double LibCameraInterface::kCamera2Contrast = 1.2; double LibCameraInterface::kCamera2CalibrateOrLocationGain = 1.0; double LibCameraInterface::kCamera2PuttingGain = 4.0; - double LibCameraInterface::kCamera2PuttingContrast = 1.0; - std::string LibCameraInterface::kCameraMotionDetectSettings = "./assets/motion_detect.json"; + double LibCameraInterface::kCamera2PuttingContrast = 1.2; - long LibCameraInterface::kCamera1StillShutterTimeuS = 15000; + long LibCameraInterface::kCamera1StillShutterTimeuS = 40000; long LibCameraInterface::kCamera2StillShutterTimeuS = 15000; - // Default values are based on empirical measurements using a 6mm lens - int kCroppedImagePixelOffsetLeft = -5; - int kCroppedImagePixelOffsetUp = -13; + int kCroppedImagePixelOffsetLeft = 0; + int kCroppedImagePixelOffsetUp = -3; // The system will start in a full-screen watching mode, but ensure // we set it up once just in case @@ -665,13 +662,13 @@ bool SendCameraCroppingCommand(const GolfSimCamera& camera, cv::Vec2i& cropping_ bool ConfigurePostProcessing(const cv::Vec2i& roi_size, const cv::Vec2i& roi_offset ) { - float kDifferenceM = 0.; - float kDifferenceC = 0.; - float kRegionThreshold = 0.; - float kMaxRegionThreshold = 0.; + float kDifferenceM = 0.6; + float kDifferenceC = 3.; + float kRegionThreshold = 0.05; + float kMaxRegionThreshold = 0.05; uint kFramePeriod = 0; - uint kHSkip = 0; - uint kVSkip = 0; + uint kHSkip = 2; + uint kVSkip = 2; GolfSimConfiguration::SetConstant("gs_config.motion_detect_stage.kDifferenceM", kDifferenceM); @@ -795,7 +792,6 @@ bool ConfigureLibCameraOptions(const GolfSimCamera& camera, RPiCamEncoder& app, // TBD - We are switching away from having the post_process_file trigger the // dynamic loading of the motion_detection module. Instead, hop[efully for speed, // we will use a statically-bound motion_detection module. See ball_watcher.cpp - // options->Set().post_process_file = LibCameraInterface::kCameraMotionDetectSettings; if (cropping_window_size[0] > 0 && cropping_window_size[1] > 0) { options->Set().width = cropping_window_size[0]; diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_interface.h b/Software/LMSourceCode/ImageProcessing/libcamera_interface.h index 17543b76..f0ee91b0 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_interface.h +++ b/Software/LMSourceCode/ImageProcessing/libcamera_interface.h @@ -58,11 +58,9 @@ namespace golf_sim { static double kCamera2Saturation; static double kCamera2ComparisonGain; // 0.0 to TBD?? static double kCamera2CalibrateOrLocationGain; - static double kCamera2StrobedEnvironmentGain; static double kCamera2Contrast; // 0.0 to 32.0 static double kCamera2PuttingGain; // 0.0 to TBD?? static double kCamera2PuttingContrast; // 0.0 to 32.0 - static std::string kCameraMotionDetectSettings; static long kCamera1StillShutterTimeuS; static long kCamera2StillShutterTimeuS; diff --git a/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp b/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp index 22d9275a..9217106a 100644 --- a/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp +++ b/Software/LMSourceCode/ImageProcessing/libcamera_jpeg.cpp @@ -133,11 +133,6 @@ bool cam2_run_event_loop(LibcameraJpegApp& app, cv::Mat& returnImg, bool send_pr // processed. FlightCameraState state = kWaitingForFirstPrimingPulseGroup; - // Check here, once, to see if we are going to expect to produce a pre-image for later subtraction - golf_sim::GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kUsePreImageSubtraction", - golf_sim::GolfSimCamera::kUsePreImageSubtraction); - - // True if the InnoMaker camera external trigger script has not been called yet // Note - The InnoMaker camera needs its trigger script to be called AFTER the camera // has already started up. No idea why. diff --git a/Software/LMSourceCode/ImageProcessing/lm_main.cpp b/Software/LMSourceCode/ImageProcessing/lm_main.cpp index 18f672fa..e5c59cc4 100644 --- a/Software/LMSourceCode/ImageProcessing/lm_main.cpp +++ b/Software/LMSourceCode/ImageProcessing/lm_main.cpp @@ -19,7 +19,6 @@ #include "gs_globals.h" #include "golf_ball.h" #include "gs_options.h" -#include "gs_gspro_results.h" #include "gs_ui_system.h" #include "gs_config.h" #include "gs_results.h" @@ -34,11 +33,6 @@ #include "pulse_strobe.h" -#include "gs_gspro_results.h" -#include "gs_gspro_test_server.h" -#include "gs_gspro_response.h" -#include "gs_sim_interface.h" -#include "gs_e6_interface.h" #include "gs_calibration.h" #include "gs_automated_testing.h" @@ -59,12 +53,8 @@ static constexpr std::string_view TEST_IMAGE_PREFIX = "TEST_RESULT_GetBall_"; std::string kBaseTestDir = "Will be set from the .json configuration file"; - -BallImageProc *get_ball_image_processor() { - BallImageProc *ip = new BallImageProc; - // TBD - Setup as necessary - return ip; -} +// The web server only learns that an auto calibration failed from the exit code. +static int run_exit_code = 0; void test_image(std::string_view subdir, std::string_view filename) { @@ -107,7 +97,7 @@ void test_image(std::string_view subdir, std::string_view filename) { } } - BallImageProc *ip = get_ball_image_processor(); + BallImageProc *ip = BallImageProc::get_ball_image_processor(); cv::Mat img = cv::imread(fname, cv::IMREAD_COLOR); ip->image_name_ = fname; @@ -522,7 +512,7 @@ bool testAnalyzeStrobedBalls() { #ifdef __unix__ // Ignore in Windows environment GsUISystem::SaveWebserverImage("kCameraXBallLocation_", exposures_image, exposure_balls); #endif - GsGSProResults results(result_ball); + GsResults results(result_ball); GS_LOG_TRACE_MSG(trace, "Results are: " + results.Format()); PulseStrobe::DeinitGPIOSystem(); @@ -551,7 +541,7 @@ bool test_strobed_balls_detection() { return false; } - BallImageProc *ip = get_ball_image_processor(); + BallImageProc *ip = BallImageProc::get_ball_image_processor(); ip->image_name_ = kBaseTestDir + kCam1BallOnTee; @@ -706,100 +696,6 @@ bool test_hit_trigger() { return true; } -void WaitForSimArmed() { - - // Wait until the system is armed. - while (true) { - if (GsSimInterface::GetAllSystemsArmed()) - break; - - GS_LOG_TRACE_MSG(info, "Waiting for interface armed..."); -#ifdef __unix__ - sleep(1); -#endif - } -} - -bool WaitAndSendShotToSim(int shot_number, GsGSProResults& test_result) { - try { - GS_LOG_TRACE_MSG(trace, "Sending test shot " + std::to_string(shot_number)); - - if (!GsSimInterface::SendResultsToGolfSims(test_result)) { - GS_LOG_MSG(error, "Failed to SendResultsToGolfSim (the Golf Simulator Interface)."); - return false; - } - - GS_LOG_TRACE_MSG(trace, "Sent test shot " + std::to_string(shot_number)); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProServer - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -bool TestExternalSimMessage() { - - if (!GsSimInterface::InitializeSims()) { - GS_LOG_MSG(error, "Failed to Initialize the Golf Simulator Interface."); - return false; - } - -#ifdef __unix__ - // Brief settle time for simulator connections - sleep(2); -#endif - GolfBall ball; - ball.velocity_ = 123.6; - GsGSProResults test_result(ball); - test_result.speed_mph_ = 99; - test_result.vla_deg_ = 23.4F; - test_result.hla_deg_ = 1.23F; - test_result.back_spin_rpm_ = 3456; - test_result.side_spin_rpm_ = -567; - -#ifdef __unix__ - - // If we are interfacing with a TruGolf/E6 system, then we need to make sure that it is armed before - // sending shot information. For GSPro, the arming is not important. - - if (GsE6Interface::InterfaceIsPresent()) { - GS_LOG_TRACE_MSG(trace, "Waiting for E6 simulator arm message."); - sleep(2); - } - else { - sleep(1); - } -#endif - GsSimInterface::IncrementShotCounter(); - - WaitForSimArmed(); - - if (!WaitAndSendShotToSim(GsSimInterface::GetShotCounter(), test_result)) { - GS_LOG_MSG(error, "Failed to WaitAndSendShotToSim (the Golf Simulator Interface)."); - } - - test_result.speed_mph_ = 55; - test_result.vla_deg_ = 12.3F; - - GsSimInterface::IncrementShotCounter(); - - WaitForSimArmed(); - - if (!WaitAndSendShotToSim(GsSimInterface::GetShotCounter(), test_result)) { - GS_LOG_MSG(error, "Failed to WaitAndSendShotToSim (the Golf Simulator Interface)."); - - } - return true; - - GS_LOG_TRACE_MSG(trace, "De-initializing GSPro interface."); - GsSimInterface::DeInitializeSims(); - - return true; -} - bool TestBallDeltaCalculations() { // Setup a couple of test balls in specific locations. Each ball needs the same information it would have if // the GolfSimCamera::ComputeXyzDistanceFromOrthoCamPerspective function had been called on it @@ -877,36 +773,6 @@ bool TestBallDeltaCalculations() { } -bool TestGSProServer() { - try - { - int kGSProConnectPort; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort", kGSProConnectPort); - - boost::asio::io_context io_context; - GsGSProTestServer server(io_context, kGSProConnectPort); - GS_LOG_TRACE_MSG(trace, "About to call io_context.run()"); - io_context.run(); - } - catch (std::exception& e) - { - GS_LOG_MSG(error, "Failed TestGSProServer - Error was: " + std::string(e.what())); - return false; - } - - return true; -} - -void test_gspro_communication() { - - GolfBall ball; - ball.rotation_speeds_RPM_[2] = 5000.; - ball.rotation_speeds_RPM_[0] = 100.; - GsGSProResults results(ball); - std::string json = results.Format(); - GS_LOG_MSG(debug, json); -} - static std::atomic g_shutdown_requested(false); void signal_handler(int sig) { @@ -972,68 +838,6 @@ void run_main(int argc, char* argv[]) - if (GolfSimOptions::GetCommandLineOptions().send_test_results_) { - - GS_LOG_TRACE_MSG(trace, "Running in send_test_results mode."); - if (!PerformSystemStartupTasks()) { - GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); - return; - } - - // Send as many test shots as we have to whatever golf sim we are connected to - std::vector shots; - - int kInterShotInjectionPauseSeconds = 0; - if (!GolfSimConfiguration::ReadShotInjectionData(shots, kInterShotInjectionPauseSeconds)) { - GS_LOG_MSG(error, "Failed to kInterShotInjectionPauseSeconds."); - return; - } - else { - GS_LOG_MSG(info, "About to inject " + std::to_string(shots.size()) + " shots."); - } - - for (GsResults& result : shots) { - GS_LOG_MSG(info, "******** READY FOR SHOT NO. " + std::to_string(result.shot_number_) + " ********"); - - GS_LOG_MSG(info, "******** PLEASE RE-ARM THE SIMULATOR TO ACCEPT ANOTHER SHOT ********"); - - sleep(kInterShotInjectionPauseSeconds); - - if (!GolfSimOptions::GetCommandLineOptions().skip_wait_armed_) { - while(!GsSimInterface::GetAllSystemsArmed()) { - sleep(2); - GS_LOG_MSG(info, " Waiting for Simulator to Arm."); - } - } else { - GS_LOG_MSG(info, " Skipping wait for simulator armed (hardware-less testing mode)."); - } - - GsSimInterface::IncrementShotCounter(); - - // Get the result to the golf simulator ASAP - if (!GsSimInterface::SendResultsToGolfSims(result)) { - GS_LOG_MSG(error, "Could not SendResultsToGolfSim. Continuing"); - } - - // Also send to Python webserver via IPC for monitoring - GolfBall test_ball; - test_ball.velocity_ = result.speed_mph_ * 0.44704; // Convert mph to m/s - test_ball.angles_ball_perspective_[0] = result.hla_deg_; - test_ball.angles_ball_perspective_[1] = result.vla_deg_; - test_ball.rotation_speeds_RPM_[0] = result.side_spin_rpm_; - test_ball.rotation_speeds_RPM_[2] = result.back_spin_rpm_; - - std::string message = "Test shot #" + std::to_string(result.shot_number_); - GsUISystem::SendIPCHitMessage(test_ball, message); - } - - - PerformSystemShutdownTasks(); - - return; - } - - // In this mode, we just take a single picture and save it. if (GolfSimOptions::GetCommandLineOptions().camera_still_mode_) { @@ -1127,9 +931,6 @@ void run_main(int argc, char* argv[]) return; } - std::string address; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", address); - if (kStartInPuttingMode) { GS_LOG_MSG(info, "Starting in Putting Mode."); GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); @@ -1151,6 +952,7 @@ void run_main(int argc, char* argv[]) // Initialize cameras and system components if (!PerformSystemStartupTasks()) { GS_LOG_MSG(error, "Failed to PerformSystemStartupTasks."); + run_exit_code = 1; return; } @@ -1161,7 +963,8 @@ void run_main(int argc, char* argv[]) // We will want to send a calibration message to any monitor UIs if (!calibrator.AutoCalibrateCamera(camera_number)) { - GS_LOG_MSG(info, "Failed to AutoCalibrateCamera."); + GS_LOG_MSG(error, "Failed to AutoCalibrateCamera."); + run_exit_code = 1; return; } @@ -1174,14 +977,14 @@ void run_main(int argc, char* argv[]) GS_LOG_MSG(info, "Running in kCamera1Calibrate or kCamera2Calibrate mode."); // We will want to send a calibration message to any monitor UIs - GsHttpClient::Init(); + GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); GolfBall ball; cv::Mat img; // In addition to checking for the ball, this method will send results // to the web server if we are in calibration mode. - GS_LOG_MSG(info, "Calibration Results (Distance of kCamera (1 OR 2) CalibrationDistanceToBall):"); + GS_LOG_MSG(info, "Calibration Results (distance from kCamera (1 OR 2) PositionsFromExpectedBallMeters):"); double average_focal_length = 0.0; const int number_attempts = 20; int number_samples = 0; @@ -1208,24 +1011,6 @@ void run_main(int argc, char* argv[]) } break; - case SystemMode::kTestExternalSimMessage: - { - if (!TestExternalSimMessage()) { - GS_LOG_MSG(info, "Failed to TestExternalSimMessage."); - return; - } - } - break; - - case SystemMode::kTestGSProServer: - { - if (!TestGSProServer()) { - GS_LOG_MSG(info, "Failed to TestGSProSever."); - return; - } - } - break; - case SystemMode::kAutomatedTesting: { if (!GsAutomatedTesting::TestBallPosition()) { @@ -1319,10 +1104,6 @@ void run_main(int argc, char* argv[]) } #else - // TBD - REMOVE -Just for testing - std::string address; - GolfSimConfiguration::SetConstant("gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress", address); - if (kStartInPuttingMode) { GS_LOG_MSG(info, "Starting in Putting Mode."); GolfSimClubs::SetCurrentClubType(GolfSimClubs::GsClubType::kPutter); @@ -1355,7 +1136,8 @@ void run_main(int argc, char* argv[]) // We will want to send a calibration message to any monitor UIs if (!GolfSimCalibration::AutoCalibrateCamera(camera_number)) { - GS_LOG_MSG(info, "Failed to AutoCalibrateCamera."); + GS_LOG_MSG(error, "Failed to AutoCalibrateCamera."); + run_exit_code = 1; return; } @@ -1418,7 +1200,6 @@ void run_main(int argc, char* argv[]) testAnalyzeStrobedBalls(); // test_strobed_balls_detection(); // TestBallPosition(); - // test_gspro_communication(); // testSpinDetection(); break; } @@ -1486,20 +1267,32 @@ int main(int argc, char *argv[]) GolfSimOptions::GetCommandLineOptions().Print(); - std::string config_file_name = ".pitrac/config/generated_golf_sim_config.json"; - - if (!GolfSimOptions::GetCommandLineOptions().config_file_.empty()) { - config_file_name = GolfSimOptions::GetCommandLineOptions().config_file_; - } + GS_LOG_MSG(info, "Loading configuration from the web server over HTTP"); - GS_LOG_MSG(info, "Loading configuration from: " + config_file_name); + // Initialize fetches config through the HTTP client, so point it at the web server first. + GsHttpClient::Init("localhost", GolfSimOptions::GetCommandLineOptions().web_server_port_); - if (!GolfSimConfiguration::Initialize(config_file_name)) { - GS_LOG_MSG(error, "Could not initialize configuration module using config file: " + config_file_name + ". Exiting."); + if (!GolfSimConfiguration::Initialize()) { + GS_LOG_MSG(error, "Could not initialize configuration module. Exiting."); return 0; } - LoggingTools::logging_tool_wait_for_keypress_ = GolfSimOptions::GetCommandLineOptions().wait_for_key_on_images_; + LoggingTools::ApplyLogLevel(); + + const GolfSimOptions& options = GolfSimOptions::GetCommandLineOptions(); + GS_LOG_MSG(info, "Options from config: golfer_orientation=" + options.golfer_orientation_string_ + + ", logging_level=" + options.logging_level_string_ + + ", artifact_save_level=" + options.artifact_save_level_string_ + + ", show_images=" + std::to_string(options.show_images_) + + ", wait_keys=" + std::to_string(options.wait_for_key_on_images_) + + ", practice_ball=" + std::to_string(options.practice_ball_) + + ", search_center=" + std::to_string(options.search_center_x_) + "," + std::to_string(options.search_center_y_) + + ", slot1 type/lens/orientation=" + std::to_string(GolfSimCamera::kSystemSlot1CameraType) + "/" + + std::to_string(GolfSimCamera::kSystemSlot1LensType) + "/" + std::to_string(GolfSimCamera::kSystemSlot1CameraOrientation) + + ", slot2 type/lens/orientation=" + std::to_string(GolfSimCamera::kSystemSlot2CameraType) + "/" + + std::to_string(GolfSimCamera::kSystemSlot2LensType) + "/" + std::to_string(GolfSimCamera::kSystemSlot2CameraOrientation)); + + LoggingTools::logging_tool_wait_for_keypress_ = options.wait_for_key_on_images_; // We prefer the command-line setting even if there's one in the .json config file if (!GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_.empty()) { @@ -1517,18 +1310,20 @@ int main(int argc, char *argv[]) if (!kBaseTestDir.empty() && kBaseTestDir.back() != '/') { kBaseTestDir += '/'; } + LoggingTools::kBaseImageLoggingDir = kBaseTestDir; // TBD - consider if there is a better place for this? GolfSimGlobals::golf_sim_running_ = true; - // Load BallImageProc configuration values after JSON config is loaded + // Load class configuration values after JSON config is loaded BallImageProc::LoadConfigurationValues(); + GolfSimCamera::LoadConfigurationValues(); // If we have a version 3 Connector Board, then we want to ensure // that it has been properly calibrated before we let the system // run. - int kConnectionBoardVersionIntValue = 0; + int kConnectionBoardVersionIntValue = 3; GolfSimConfiguration::SetConstant("gs_config.strobing.kConnectionBoardVersion", kConnectionBoardVersionIntValue); GolfSimConfiguration::ConnectionBoardType kConnectionBoardVersion = (GolfSimConfiguration::ConnectionBoardType)kConnectionBoardVersionIntValue; @@ -1536,10 +1331,12 @@ int main(int argc, char *argv[]) GS_LOG_MSG(trace, "PiTrac is using a Version 3 Control Board, checking calibration."); // If the board has been calibrated, there will be a value - // in the user_settings.json file + // in the config - int connector_board_dac_setting = -1; - GolfSimConfiguration::SetConstant("gs_config.strobing.kDAC_setting", connector_board_dac_setting); + int connector_board_dac_setting = -1; + if (GolfSimConfiguration::PropertyExists("gs_config.strobing.kDAC_setting")) { + GolfSimConfiguration::SetConstant("gs_config.strobing.kDAC_setting", connector_board_dac_setting); + } if (connector_board_dac_setting < 0) { GS_LOG_MSG(error, "PiTrac is using a Version 3 Control Board, but the board does not appear to have been calibrated. Shutting down."); @@ -1581,5 +1378,5 @@ int main(int argc, char *argv[]) // cv::waitKey(0); GS_LOG_TRACE_MSG(trace, "Tests Complete"); - return 0; + return run_exit_code; } diff --git a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp index e32fec3f..0ec4b614 100644 --- a/Software/LMSourceCode/ImageProcessing/logging_tools.cpp +++ b/Software/LMSourceCode/ImageProcessing/logging_tools.cpp @@ -69,30 +69,12 @@ namespace golf_sim { boost::circular_buffer LoggingTools::RecentLogMessages(20); -#ifdef __unix__ std::string LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#else - std::string LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#endif - void LoggingTools::InitLogging() + void LoggingTools::ApplyLogLevel() { - boost::log::add_common_attributes(); - boost::log::core::get()->add_global_attribute("Scope", boost::log::attributes::named_scope()); - - if (!GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_.empty()) { - kBaseImageLoggingDir = GolfSimOptions::GetCommandLineOptions().base_image_logging_dir_; - } - else { -#ifdef __unix__ - LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#else - LoggingTools::kBaseImageLoggingDir = "VALUE_NOT_SET"; -#endif - } - switch (GolfSimOptions::GetCommandLineOptions().logging_level_) { case kTrace: { boost::log::core::get()->set_filter( @@ -131,6 +113,14 @@ namespace golf_sim { break; } }; + } + + void LoggingTools::InitLogging() + { + boost::log::add_common_attributes(); + boost::log::core::get()->add_global_attribute("Scope", boost::log::attributes::named_scope()); + + ApplyLogLevel(); /* log formatter: * [TimeStamp] [ThreadId] [Severity Level] [Scope] Log message diff --git a/Software/LMSourceCode/ImageProcessing/logging_tools.h b/Software/LMSourceCode/ImageProcessing/logging_tools.h index b5cf0dde..a854b446 100644 --- a/Software/LMSourceCode/ImageProcessing/logging_tools.h +++ b/Software/LMSourceCode/ImageProcessing/logging_tools.h @@ -48,6 +48,8 @@ struct LoggingTools static void InitLogging(); + static void ApplyLogLevel(); + // Lowest-level logging function to allow for additional filtering, sinking, etc. static void InternalLog(boost::log::trivial::severity_level level, const std::string& msg); diff --git a/Software/LMSourceCode/ImageProcessing/meson.build b/Software/LMSourceCode/ImageProcessing/meson.build index eda8b061..bcd02523 100644 --- a/Software/LMSourceCode/ImageProcessing/meson.build +++ b/Software/LMSourceCode/ImageProcessing/meson.build @@ -31,7 +31,7 @@ add_global_arguments('-DHAS_NCNN', language : 'cpp') fs = import('fs') # TBD FIX - The following may be redundant of the above options -cpp_arguments = ['-pedantic', '-Wno-unused-parameter', '-faligned-new', '-DBOOST_LOG_DYN_LINK', '-Wno-error', '-Wno-deprecated-enum-enum-conversion', '-Wno-decprecated-declarations'] +cpp_arguments = ['-pedantic', '-Wno-unused-parameter', '-faligned-new', '-DBOOST_LOG_DYN_LINK', '-Wno-error'] # Needed for file sizes > 32-bits. cpp_arguments += '-D_FILE_OFFSET_BITS=64' @@ -74,7 +74,6 @@ fmt_dep = dependency('fmt', required : true) boost_dep = dependency('boost', method: 'pkg-config', version : '>=1.74.0', modules : ['timer', 'log', 'thread', 'filesystem', 'regex']) -yamlcpp_dep = dependency('yaml-cpp', required : true) ncnn_dep = dependency('ncnn', required : true) omp_dep = dependency('openmp', language : 'cpp', required : false) if not omp_dep.found() @@ -95,7 +94,10 @@ conf_data = configuration_data() pitrac_lm_module_deps = [ libcamera_dep, thread_dep, opencv_dep, lgpio_dep, rpicam_app_dep, - fmt_dep, boost_dep, ssl_dep, yamlcpp_dep, ncnn_dep, omp_dep,] + fmt_dep, boost_dep, ssl_dep, ncnn_dep, omp_dep,] + +# Before subdir('core'), which rebinds boost_dep to program_options only +e6_auth_deps = [ boost_dep, ssl_dep, thread_dep ] subdir('core') @@ -139,37 +141,20 @@ endif closed_source_object_files = [] pitrac_lm_sources = [] +e6_auth_sources = [ 'e6_auth_shim.cpp' ] if enable_recompile_closed_source message('Recompiling closed source code') run_command('cp', '/home/USERNAME/Dev/ClosedSourceCode/gs_e6_response.cpp.FULL', 'gs_e6_response.cpp', check:true) - pitrac_lm_sources += ([ 'gs_e6_response.cpp' ]) -endif - -if enable_recompile_closed_source - message('Recompiling closed source code') run_command('cp', '/home/USERNAME/Dev/ClosedSourceCode/obfuscate.h', 'obfuscate.h', check:true) - pitrac_lm_sources += ([ 'gs_e6_response.cpp' ]) + e6_auth_sources += ([ 'gs_e6_response.cpp' ]) + e6_auth_deps += ([ opencv_dep.partial_dependency(compile_args : true, includes : true) ]) endif -# Move the pre-compiled, closed-source object file(s) into place -# This is a workaround to the fact that meson will not allow paths in the -# output and input parameters of a custom_target -create_closed_source_objects = find_program('create_closed_source_objects.sh') -closed_source_target = custom_target('copy_closed_source_object', - input : 'gs_e6_results.cpp', - output : 'gs_e6_response.cpp.o', - command : [ create_closed_source_objects, '@INPUT@', '@OUTPUT@'], -) - if not enable_recompile_closed_source message('Using pre-compiled closed object_code') - pitrac_lm_sources += ([ closed_source_target, ]) - closed_source_object_files += (['gs_e6_response.cpp.o']) -endif - -if not enable_recompile_closed_source r = run_command ( 'cp', '-f', './ClosedSourceObjectFiles/gs_e6_response.cpp.o', '.', check:true) + closed_source_object_files += (['gs_e6_response.cpp.o']) endif libav_dep_names = ['libavcodec', 'libavdevice', 'libavformat', 'libavutil', 'libswresample'] @@ -187,7 +172,6 @@ pitrac_lm_sources += ([ 'libcamera_jpeg.cpp', 'ball_watcher.cpp', 'ball_watcher_image_buffer.cpp', - 'libcamera_jpeg.cpp', 'ball_image_proc.cpp', 'ncnn_detector.cpp', 'spin_predictor.cpp', @@ -200,27 +184,16 @@ pitrac_lm_sources += ([ 'gs_automated_testing.cpp', 'gs_calibration.cpp', 'gs_camera.cpp', - 'gs_web_api.cpp', 'gs_clubs.cpp', 'gs_club_data.cpp', 'gs_options.cpp', 'gs_config.cpp', 'gs_shot_parameters.cpp', - 'configuration_manager.cpp', - 'gs_sim_interface.cpp', - 'gs_gspro_interface.cpp', - 'gs_gspro_response.cpp', - 'gs_gspro_test_server.cpp', - 'gs_sim_socket_interface.cpp', - 'gs_e6_interface.cpp', - 'gs_e6_results.cpp', 'logging_tools.cpp', 'gs_events.cpp', 'worker_thread.cpp', 'camera_hardware.cpp', - 'gs_control_msg.cpp', 'gs_results.cpp', - 'gs_gspro_results.cpp', 'gs_ui_system.cpp', 'gs_result_types.cpp', 'gs_http_client.cpp', @@ -246,28 +219,38 @@ exec = executable('pitrac_lm', pitrac_lm_sources, include_directories : pitrac_lm_include_dirs, install : true, - objects : closed_source_object_files, dependencies : pitrac_lm_module_deps, cpp_args : cpp_arguments ) +e6_auth_lib = shared_library('pitrac_e6', + e6_auth_sources, + install : true, + install_dir : '/usr/lib/pitrac', + objects : closed_source_object_files, + dependencies : e6_auth_deps, + cpp_args : cpp_arguments + ) + # Hacky two targets, because can't figure out how to execute more than one command # per target. TBD -custom_target('post_build1', - depends: exec, # Ensure the executable is built first - input: exec, - output: 'dummy_file1', # NOTE - command will execute in the build directory - command: [ 'cp', '-f', './pitrac_lm.p/gs_e6_response.cpp.o', '../ClosedSourceObjectFiles'], - build_by_default : true -) - -custom_target('post_build2', - depends: exec, # Ensure the executable is built first - input: exec, - output: 'dummy_file2', # NOTE - command will execute in the build directory - command: [ 'cp', '-f', './pitrac_lm.p/gs_e6_response.cpp.o', '..'], - build_by_default : true -) +if enable_recompile_closed_source + custom_target('post_build1', + depends: e6_auth_lib, # Ensure the library is built first + input: e6_auth_lib, + output: 'dummy_file1', # NOTE - command will execute in the build directory + command: [ 'cp', '-f', './libpitrac_e6.so.p/gs_e6_response.cpp.o', '../ClosedSourceObjectFiles'], + build_by_default : true + ) + + custom_target('post_build2', + depends: e6_auth_lib, # Ensure the library is built first + input: e6_auth_lib, + output: 'dummy_file2', # NOTE - command will execute in the build directory + command: [ 'cp', '-f', './libpitrac_e6.so.p/gs_e6_response.cpp.o', '..'], + build_by_default : true + ) +endif summary({ 'libav encoder' : enable_libav, diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp index e340fde8..bb3479bb 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.cpp @@ -33,10 +33,10 @@ namespace golf_sim { std::vector PulseStrobe::pulse_intervals_fast_ms_; - int PulseStrobe::number_bits_for_fast_on_pulse_ = 0; + int PulseStrobe::number_bits_for_fast_on_pulse_ = 1; std::vector PulseStrobe::pulse_intervals_slow_ms_; - int PulseStrobe::number_bits_for_slow_on_pulse_ = 0; + int PulseStrobe::number_bits_for_slow_on_pulse_ = 1; // Currently true for both Pi and InnoMaker cameras // Should be set false if we are using the OG V1 Connector board, @@ -62,14 +62,13 @@ namespace golf_sim { bool PulseStrobe::spiOpen_ = false; bool PulseStrobe::kRecordAllImages = true; bool PulseStrobe::gpio_system_initialized_ = false; - int PulseStrobe::kPuttingStrobeDelayMs = 0; + int PulseStrobe::kPuttingStrobeDelayMs = 50; long PulseStrobe::kCam2SetupPeriodMilliseconds = 2000; int PulseStrobe::kNumberPrimingPulses = 12; int PulseStrobe::kPrimingPulseFPS = 15; - long PulseStrobe::kPauseBeforeReadyForTriggerMicroSeconds = 100; - int PulseStrobe::kPauseToSetUpInnoMakerExternalTriggerMilliseconds = 1000; - int PulseStrobe::kPauseBeforeReadyForFinalPrimingPulseMs = 100; + int PulseStrobe::kPauseToSetUpInnoMakerExternalTriggerMilliseconds = 500; + int PulseStrobe::kPauseBeforeReadyForFinalPrimingPulseMs = 400; int PulseStrobe::kLastPulsePutterRepeats = 5; @@ -437,7 +436,6 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.strobing.kCam2SetupPeriodMilliseconds", kCam2SetupPeriodMilliseconds); GolfSimConfiguration::SetConstant("gs_config.strobing.kNumberPrimingPulses", kNumberPrimingPulses); GolfSimConfiguration::SetConstant("gs_config.strobing.kPrimingPulseFPS", kPrimingPulseFPS); - GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeReadyForTriggerMicroSeconds", kPauseBeforeReadyForTriggerMicroSeconds); GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseToSetUpInnoMakerExternalTriggerMilliseconds", kPauseToSetUpInnoMakerExternalTriggerMilliseconds); GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeReadyForFinalPrimingPulseMs", kPauseBeforeReadyForFinalPrimingPulseMs); @@ -488,7 +486,7 @@ namespace golf_sim { // The active-high setting will depend on which board the user sets - int kConnectionBoardVersionIntValue = 0; + int kConnectionBoardVersionIntValue = 3; GolfSimConfiguration::SetConstant("gs_config.strobing.kConnectionBoardVersion", kConnectionBoardVersionIntValue); GolfSimConfiguration::ConnectionBoardType kConnectionBoardVersion = (GolfSimConfiguration::ConnectionBoardType)kConnectionBoardVersionIntValue; @@ -506,11 +504,6 @@ namespace golf_sim { lgGpioWrite(lggpio_chip_handle_, kPulseTriggerOutputPin, kOFF); } - // The cnclosure version will affect where the ball on the auto-calibration target is located relative to the cameras - int kEnclosureVersionIntValue = 0; - GolfSimConfiguration::SetConstant("gs_config.strobing.kEnclosureVersion", kEnclosureVersionIntValue); - GolfSimConfiguration::EnclosureType knclosureVersion = (GolfSimConfiguration::EnclosureType)kEnclosureVersionIntValue; - if (callback_function != nullptr) { /* TBD gpioSetSignalFunc(SIGUSR1, callback_function); @@ -605,7 +598,7 @@ namespace golf_sim { GolfSimConfiguration::SetConstant("gs_config.strobing.kPuttingStrobeDelayMs", kPuttingStrobeDelayMs); // Make sure we are sending pulses at a known speed. In this case, in the "fast" setting - unsigned int baud_rate = 0; + unsigned int baud_rate = 115200; GolfSimConfiguration::SetConstant("gs_config.strobing.kBaudRateForFastPulses", baud_rate); @@ -660,20 +653,17 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "Sent final priming pulse. Camera 2 should be primed at this point."); // Deal with a pre-image exposure if we need to (mostly deprecated - didn't work well) - GolfSimConfiguration::SetConstant("gs_config.ball_exposure_selection.kUsePreImageSubtraction", - GolfSimCamera::kUsePreImageSubtraction); - if (GolfSimCamera::kUsePreImageSubtraction) { GS_LOG_TRACE_MSG(trace, "Sent last priming pulse before pre-image."); - long kPauseBeforeSendingPreImageTriggerMs = 0; + long kPauseBeforeSendingPreImageTriggerMs = 300; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeSendingPreImageTriggerMs", kPauseBeforeSendingPreImageTriggerMs); usleep(kPauseBeforeSendingPreImageTriggerMs * 1000); SendCameraStrobeTriggerAndShutter(lggpio_chip_handle_); GS_LOG_TRACE_MSG(trace, "Sent pre-image trigger."); - long kPauseBeforeSendingImageFlushMs = 0; + long kPauseBeforeSendingImageFlushMs = 300; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeSendingImageFlushMs", kPauseBeforeSendingImageFlushMs); usleep(kPauseBeforeSendingImageFlushMs * 1000); @@ -683,7 +673,7 @@ namespace golf_sim { // It will take the camera2 system a moment to package up the pre-image and send it to the object broker and to the // camera1 system (the one executing this code). Give it a chance - long kPauseAfterSendingPreImageTriggerMs = 0; + long kPauseAfterSendingPreImageTriggerMs = 2000; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseAfterSendingPreImageTriggerMs", kPauseAfterSendingPreImageTriggerMs); usleep(kPauseAfterSendingPreImageTriggerMs * 1000); } @@ -722,7 +712,7 @@ namespace golf_sim { GS_LOG_TRACE_MSG(trace, "Waiting a moment to send flush trigger."); - long kPauseBeforeSendingImageFlushMs = 0; + long kPauseBeforeSendingImageFlushMs = 300; GolfSimConfiguration::SetConstant("gs_config.strobing.kPauseBeforeSendingImageFlushMs", kPauseBeforeSendingImageFlushMs); usleep(kPauseBeforeSendingImageFlushMs * 1000); diff --git a/Software/LMSourceCode/ImageProcessing/pulse_strobe.h b/Software/LMSourceCode/ImageProcessing/pulse_strobe.h index 3d36d1ac..3a682cde 100644 --- a/Software/LMSourceCode/ImageProcessing/pulse_strobe.h +++ b/Software/LMSourceCode/ImageProcessing/pulse_strobe.h @@ -29,7 +29,6 @@ namespace golf_sim { static long kCam2SetupPeriodMilliseconds; static int kNumberPrimingPulses; static int kPrimingPulseFPS; - static long kPauseBeforeReadyForTriggerMicroSeconds; static int kPauseToSetUpInnoMakerExternalTriggerMilliseconds; static int kPauseBeforeReadyForFinalPrimingPulseMs; diff --git a/Software/web-server/API_DOCUMENTATION.md b/Software/web-server/API_DOCUMENTATION.md index 84b58f16..ac343797 100644 --- a/Software/web-server/API_DOCUMENTATION.md +++ b/Software/web-server/API_DOCUMENTATION.md @@ -74,15 +74,8 @@ Currently, the API does not implement authentication. Future versions may add se ```json { "status": "healthy", // Overall system status - "activemq_connected": true, // ActiveMQ broker connection - "activemq_running": true, // ActiveMQ service status "pitrac_running": true, // Main PiTrac service status - "websocket_clients": 3, // Active WebSocket connections - "listener_stats": { // ActiveMQ listener metrics - "connected": true, - "messages_processed": 42, - "errors": 0 - } + "websocket_clients": 3 // Active WebSocket connections } ``` @@ -93,46 +86,27 @@ Currently, the API does not implement authentication. Future versions may add se ```json { "websocket_connections": 3, - "listener": { - "connected": true, - "messages_processed": 42, - "errors": 0 - }, + "transport": "http", "shot_history_count": 25 } ``` -## ActiveMQ Message Format +## Shot Result Ingest -### Message Topics -- Primary Topic: `/topic/Golf.Sim` -- Message Encoding: MsgPack -- Supported Formats: - 1. Array Format: `[speed, launch_angle, side_angle, ...]` - 2. Dictionary Format: Partial or complete shot data update - -### Message Processing -- Base64 encoding supported -- Validation performed on each incoming message -- Errors logged but do not interrupt message processing +### Internal Endpoint +- **POST** `/api/internal/shot-result` +- **Description**: `pitrac_lm` posts each result here as JSON; the server stores it and broadcasts it to WebSocket clients +- **Fields**: `result_type` (int), `speed_mps`, `carry`, `launch_angle`, `side_angle`, `back_spin`, `side_spin`, `message`, `shot_id`, `images` +- Status messages keep the previous shot's numbers and only update `result_type` and `message` +- Hits are saved to shot history ## Error Handling - Most errors are logged internally - API endpoints return appropriate HTTP status codes - WebSocket connections automatically handle disconnects -## Configuration -Configuration is loaded from `pitrac.yaml` with the following key network settings: -```yaml -network: - broker_address: tcp://localhost:61616 # ActiveMQ broker - username: pitrac_user # Optional credentials - password: secure_password # Optional credentials -``` - ## Recommended Clients - WebSocket support required -- MsgPack decoding library recommended - Supports both real-time and polling access patterns ## Limitations diff --git a/Software/web-server/CONFIGURATIONS.md b/Software/web-server/CONFIGURATIONS.md index e5babdd6..7fa366a6 100644 --- a/Software/web-server/CONFIGURATIONS.md +++ b/Software/web-server/CONFIGURATIONS.md @@ -23,10 +23,6 @@ Each configuration entry is a key-value pair where the key is a dot-notation pat | `showInBasic` | boolean | Whether to show in basic/simple configuration view | | `basicSubcategory` | string | Subcategory for basic view organization | | `requiresRestart` | boolean | Whether changing this setting requires system restart | -| `passedVia` | string | How parameter is passed: `cli`, `environment`, or omitted (config file) | -| `passedTo` | string | Target component(s): `camera1`, `camera2`, `both`, `system` | -| `cliArgument` | string | Command-line argument name (when `passedVia: "cli"`) | -| `envVariable` | string | Environment variable name (when `passedVia: "environment"`) | | `visibleWhen` | object | Conditional visibility based on other settings | | `affectsSettings` | array | List of other settings affected by this one | @@ -59,34 +55,6 @@ Available categories for organizing settings: - `Spin Analysis` - Spin calculation settings - `Advanced` - Expert-level parameters -## Parameter Passing Methods - -### 1. CLI Arguments (`passedVia: "cli"`) -Parameters passed as command-line arguments to the pitrac_lm binary. -- Requires `cliArgument` field -- Format: `--parameter_name=value` or `--flag` (for booleans) -- Target specified by `passedTo` field - -### 2. Environment Variables (`passedVia: "environment"`) -Parameters set as environment variables before process startup. -- Requires `envVariable` field -- Format: `PITRAC_VARIABLE_NAME=value` -- Target specified by `passedTo` field - -### 3. Config File (no `passedVia` field) -Parameters written to `/etc/pitrac/golf_sim_config.json`. -- Default method when `passedVia` is omitted -- Accessible to all components - -## Target Components (`passedTo`) - -| Value | Description | Usage | -|-------|-------------|--------| -| `camera1` | Camera 1 process only | Settings specific to first camera | -| `camera2` | Camera 2 process only | Settings specific to second camera (single Pi mode) | -| `both` | Both camera processes | Settings that apply to both cameras | -| `system` | System-wide | Non-camera settings (simulators, storage, etc.) | - ## Conditional Visibility Use `visibleWhen` to show/hide settings based on other configuration values: @@ -118,10 +86,7 @@ Use `affectsSettings` to indicate which other settings are impacted: "max": 1920, "step": 10, "default": 850, - "requiresRestart": true, - "passedVia": "cli", - "passedTo": "camera1", - "cliArgument": "--search_center_x" + "requiresRestart": true } ``` @@ -140,7 +105,5 @@ Settings shown in basic view are organized into subcategories with display order ## Notes -- Settings without `passedVia` are stored in the config file and accessible to all components -- Boolean CLI arguments are passed as flags without values (e.g., `--practice_ball`) -- Environment variables are set per-process when `passedTo` specifies a target +- pitrac_lm reads every setting from the merged config served at `/api/internal/config` - The `requiresRestart` flag triggers automatic process restart when changed via web UI \ No newline at end of file diff --git a/Software/web-server/calibration_manager.py b/Software/web-server/calibration_manager.py index 9839bc26..313d081a 100644 --- a/Software/web-server/calibration_manager.py +++ b/Software/web-server/calibration_manager.py @@ -21,16 +21,64 @@ from pathlib import Path from typing import Any, Dict, List, Optional +from constants import SERVER_PORT + logger = logging.getLogger(__name__) CAMERA1_CALIBRATION_TIMEOUT = 40.0 # Camera1 has faster hardware detection CAMERA2_CALIBRATION_TIMEOUT = 140.0 # Camera2 needs background process initialization CAMERA2_BACKGROUND_INIT_WAIT = 4.0 # Time to wait for background process to initialize +_BALL_NOT_FOUND = ( + "The camera could not find the calibration ball in enough pictures. Check that the ball is in place, " + "in view, well lit, and near the camera's Search Center setting." +) +_BAD_POSITION_SETTINGS = ( + "The calibration ball position settings are missing or invalid. Check the Enclosure Version setting." +) + +# Terminal pitrac_lm error lines (gs_calibration.cpp, lm_main.cpp) mapped to what the user should check. +# Per-frame misses are left out on purpose: calibration retries those. +AUTO_CALIBRATION_FAILURE_REASONS = [ + ("Too many failures - giving up", _BALL_NOT_FOUND), + ("All focal length samples failed", _BALL_NOT_FOUND), + ( + "FAILED to TakeStillPicture", + "The camera did not return a picture. Check the camera cable and that nothing else is using the camera.", + ), + ( + "invalid focal length", + "The ball looked the wrong size for its expected distance. Check that the ball is at the calibration " + "position for your Enclosure Version and that the lens setting matches the camera.", + ), + ( + "invalid camera angles", + "The ball gave impossible camera angles. Check that the ball is at the calibration position for your " + "Enclosure Version.", + ), + ( + "Could not DetermineCameraAngles", + "The ball was found while measuring focal length but not while measuring the camera angles. Check that " + "the ball did not move and is well lit.", + ), + ("Could not RetrieveAutoCalibrationConstants", _BAD_POSITION_SETTINGS), + ("kFinalAutoCalibrationBallPositionFromCameraMeters", _BAD_POSITION_SETTINGS), + ("valid distance_direct_to_ball", _BAD_POSITION_SETTINGS), + ("invalid expected ball radius", _BAD_POSITION_SETTINGS), + ("invalid max_ball_radius", _BAD_POSITION_SETTINGS), + ( + "Failed to PerformSystemStartupTasks", + "PiTrac could not start the cameras. Check that PiTrac is stopped and both cameras are detected.", + ), +] + DISTORTION_DEFAULT_TARGET_IMAGES = 40 # Hagemann 2021: 40+ images for sub-0.2% std_fx/fx DISTORTION_MAX_ATTEMPTS_MULTIPLIER = 5 # max_attempts = target * this DISTORTION_CAPTURE_INTERVAL = 2.0 # seconds between captures DISTORTION_CAPTURE_TIMEOUT = 10.0 # timeout for rpicam-still +DISTORTION_MIN_PIXEL_COVERAGE = 0.80 # 10×10 pixel grid; reachable in ~40 frames per coupon-collector +DISTORTION_MIN_TILT_FRACTION = 0.40 +DISTORTION_SIZE_BIN_MIN_SAMPLES = 3 RPICAM_TUNING_FILE = "/usr/share/libcamera/ipa/rpi/pisp/imx296_noir.json" RPICAM_CAL_SHUTTER_US = 11000 @@ -39,6 +87,28 @@ IMX296_I2C_BUS = "4" +def _distortion_requirements(pixel_coverage: float, tilted: int, good: int, size_bins: Dict[str, int]) -> Dict[str, Any]: + return { + "coverage": pixel_coverage, + "coverage_target": DISTORTION_MIN_PIXEL_COVERAGE, + "tilt": tilted / good if good > 0 else 0.0, + "tilt_target": DISTORTION_MIN_TILT_FRACTION, + "bins": dict(size_bins), + "bin_target": DISTORTION_SIZE_BIN_MIN_SAMPLES, + } + + +def _idle_distortion_status() -> Dict[str, Any]: + return { + "status": "idle", + "message": "", + "progress": 0, + "images_captured": 0, + "target_images": DISTORTION_DEFAULT_TARGET_IMAGES, + "requirements": _distortion_requirements(0.0, 0, 0, {"small": 0, "medium": 0, "large": 0}), + } + + class CalibrationManager: """Manages calibration processes for PiTrac cameras""" @@ -58,6 +128,7 @@ def __init__(self, config_manager, pitrac_binary: str = "/usr/lib/pitrac/pitrac_ "camera1": {"status": "idle", "message": "", "progress": 0, "last_run": None}, "camera2": {"status": "idle", "message": "", "progress": 0, "last_run": None}, } + self.distortion_status = {"camera1": _idle_distortion_status(), "camera2": _idle_distortion_status()} self.log_dir = Path.home() / ".pitrac" / "logs" self.log_dir.mkdir(parents=True, exist_ok=True) @@ -393,38 +464,8 @@ async def check_ball_location(self, camera: str = "camera1") -> Dict[str, Any]: "last_run": datetime.now().isoformat(), } - config = self.config_manager.get_config() - cmd = [self.pitrac_binary, f"--system_mode={camera}_ball_location"] - - if camera == "camera1": - search_x = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterX") - if search_x is None: - search_x = 850 # Default from configurations.json - search_y = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterY") - if search_y is None: - search_y = 500 # Default from configurations.json - else: - search_x = 700 - search_y = 500 - - logging_level = config.get("logging", {}).get("level", "warn") - - camera_gain_key = "kCamera1Gain" if camera == "camera1" else "kCamera2Gain" - camera_gain = self.config_manager.get_config(f"gs_config.cameras.{camera_gain_key}") - if camera_gain is None: - camera_gain = 6.0 - - cmd.extend( - [ - f"--search_center_x={search_x}", - f"--search_center_y={search_y}", - f"--logging_level={logging_level}", - "--artifact_save_level=all", - f"--camera_gain={camera_gain}", - ] - ) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.extend(self._build_web_server_args()) try: result = await self._run_calibration_command(cmd, camera, timeout=30) @@ -460,9 +501,6 @@ async def run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any]: """ - generated_config_path = self.config_manager.generate_golf_sim_config() - logger.info(f"Generated config file at: {generated_config_path}") - return await self._run_auto_calibration(camera) async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any]: @@ -485,34 +523,8 @@ async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any] self._active_calibrations[session_id] = session_data logger.info(f"Pre-registered calibration session {session_id} for {camera}") - config = self.config_manager.get_config() cmd = [self.pitrac_binary, f"--system_mode={camera}AutoCalibrate"] - - search_x = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterX") - if search_x is None: - search_x = 850 # Default from configurations.json - - search_y = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterY") - if search_y is None: - search_y = 500 # Default from configurations.json - - logging_level = config.get("logging", {}).get("level", "warn") - - camera_gain = self.config_manager.get_config("gs_config.cameras.kCamera1Gain") - if camera_gain is None: - camera_gain = 6.0 - - cmd.extend( - [ - f"--search_center_x={search_x}", - f"--search_center_y={search_y}", - f"--logging_level={logging_level}", - "--artifact_save_level=all", - "--show_images=0", - f"--camera_gain={camera_gain}", - ] - ) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.extend(self._build_web_server_args()) log_file = self.log_dir / f"calibration_{camera}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" @@ -527,11 +539,11 @@ async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any] if camera in self.current_processes: raise Exception(f"A calibration process is already running for {camera}") - log_fh = open(log_file, "w") try: - process = await asyncio.create_subprocess_exec( - *cmd, stdout=log_fh, stderr=asyncio.subprocess.STDOUT, env=env - ) + with open(log_file, "w") as log_fh: + process = await asyncio.create_subprocess_exec( + *cmd, stdout=log_fh, stderr=asyncio.subprocess.STDOUT, env=env + ) self.current_processes[camera] = process logger.info(f"{camera}: Process started (PID {process.pid})") @@ -540,9 +552,10 @@ async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any] logger.error(f"Failed to start calibration process: {e}") async with self._calibration_lock: self._active_calibrations.pop(session_id, None) - raise - finally: - log_fh.close() + message = f"Could not start calibration: {e}" + self.calibration_status[camera]["status"] = "failed" + self.calibration_status[camera]["message"] = message + return {"status": "error", "message": message} try: completion_result = await self.wait_for_calibration_completion(process, session_id, timeout=timeout) @@ -565,13 +578,6 @@ async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any] except OSError: output = "" - if output and self._check_calibration_failed(output): - logger.warning( - f"{camera}: Detected calibration failure in output despite exit code {process.returncode}" - ) - completion_result["completed"] = False - completion_result["method"] = "output_parse" - with open(log_file, "a") as f: f.write(f"\n--- Completion ---\n") f.write(f"Completion method: {completion_result['method']}\n") @@ -597,11 +603,12 @@ async def _run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any] "log_file": str(log_file), } else: + message = self._auto_calibration_failure_message(completion_result, output, timeout) self.calibration_status[camera]["status"] = "failed" - self.calibration_status[camera]["message"] = f"Calibration failed ({completion_result['method']})" + self.calibration_status[camera]["message"] = message return { "status": "failed", - "message": f"Calibration failed via {completion_result['method']}", + "message": message, "completion_result": completion_result, "output": output, "log_file": str(log_file), @@ -660,37 +667,8 @@ async def run_manual_calibration(self, camera: str = "camera1") -> Dict[str, Any "last_run": datetime.now().isoformat(), } - config = self.config_manager.get_config() cmd = [self.pitrac_binary, f"--system_mode={camera}Calibrate"] - - if camera == "camera1": - search_x = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterX") - if search_x is None: - search_x = 850 # Default from configurations.json - search_y = self.config_manager.get_config("gs_config.cameras.kCamera1SearchCenterY") - if search_y is None: - search_y = 500 # Default from configurations.json - else: - search_x = 700 - search_y = 500 - - logging_level = config.get("logging", {}).get("level", "warn") - - camera_gain_key = "kCamera1Gain" if camera == "camera1" else "kCamera2Gain" - camera_gain = self.config_manager.get_config(f"gs_config.cameras.{camera_gain_key}") - if camera_gain is None: - camera_gain = 6.0 - - cmd.extend( - [ - f"--search_center_x={search_x}", - f"--search_center_y={search_y}", - f"--logging_level={logging_level}", - "--artifact_save_level=all", - f"--camera_gain={camera_gain}", - ] - ) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.extend(self._build_web_server_args()) try: result = await self._run_calibration_command(cmd, camera, timeout=180) @@ -735,7 +713,6 @@ async def capture_still_image(self, camera: str = "camera1") -> Dict[str, Any]: """ logger.info(f"Capturing still image for {camera}") - config = self.config_manager.get_config() cmd = [self.pitrac_binary, f"--system_mode={camera}", "--cam_still_mode"] timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -744,8 +721,8 @@ async def capture_still_image(self, camera: str = "camera1") -> Dict[str, Any]: images_dir.mkdir(parents=True, exist_ok=True) output_path = images_dir / output_file - cmd.extend([f"--output_filename={output_path}", "--artifact_save_level=final_results_only"]) - cmd.extend(self._build_cli_args_from_metadata(camera)) + cmd.append(f"--output_filename={output_path}") + cmd.extend(self._build_web_server_args()) try: await self._run_calibration_command(cmd, camera, timeout=10) @@ -760,96 +737,50 @@ async def capture_still_image(self, camera: str = "camera1") -> Dict[str, Any]: return {"status": "error", "message": str(e)} def get_status(self) -> Dict[str, Any]: - """Get current calibration status for all cameras""" - return self.calibration_status + """Ball run status per camera, plus lens calibration runs under "distortion".""" + return {**self.calibration_status, "distortion": self.distortion_status} + + def busy_reason(self, camera: str) -> Optional[str]: + label = camera.replace("camera", "Camera ") + if camera in self.current_processes or self.calibration_status[camera]["status"] in ("calibrating", "checking_ball"): + return f"{label} is already running a calibration" + if self.distortion_status[camera]["status"] in ("distortion_calibrating", "stopping"): + return f"{label} is running lens calibration" + return None def get_calibration_data(self) -> Dict[str, Any]: """Get current calibration data from config Returns calibration data including focal length, camera angles, - and distortion calibration (matrix + distortion vector). + and distortion calibration (matrix + distortion vector). The + *_calibrated flags are true only when the value was saved by a + calibration, not taken from the factory defaults. """ - config = self.config_manager.get_config() - cameras = config.get("gs_config", {}).get("cameras", {}) + cameras = self.config_manager.get_config().get("gs_config", {}).get("cameras", {}) + saved = self.config_manager.calibration_data.get("gs_config", {}).get("cameras", {}) return { - "camera1": { - "focal_length": cameras.get("kCamera1FocalLength"), - "angles": cameras.get("kCamera1Angles"), - "calibration_matrix": cameras.get("kCamera1CalibrationMatrix"), - "distortion_vector": cameras.get("kCamera1DistortionVector"), - }, - "camera2": { - "focal_length": cameras.get("kCamera2FocalLength"), - "angles": cameras.get("kCamera2Angles"), - "calibration_matrix": cameras.get("kCamera2CalibrationMatrix"), - "distortion_vector": cameras.get("kCamera2DistortionVector"), - }, + f"camera{n}": { + "focal_length": cameras.get(f"kCamera{n}FocalLength"), + "angles": cameras.get(f"kCamera{n}Angles"), + "calibration_matrix": cameras.get(f"kCamera{n}CalibrationMatrix"), + "distortion_vector": cameras.get(f"kCamera{n}DistortionVector"), + "lens_calibrated": f"kCamera{n}CalibrationMatrix" in saved, + "position_calibrated": f"kCamera{n}FocalLength" in saved and f"kCamera{n}Angles" in saved, + } + for n in (1, 2) } - def _build_cli_args_from_metadata(self, camera: str = "camera1") -> list: - """Build CLI arguments using metadata from configurations.json + def _build_web_server_args(self) -> list: + """Args telling the binary where to fetch its merged config over HTTP. - This method uses the passedVia metadata to automatically - build CLI arguments, similar to pitrac_manager.py + Everything else (search center, gain, logging level, etc.) now lives + in the served config tree, so we no longer pass it on the command line. """ - args = [] - merged_config = self.config_manager.get_config() - - cli_params = self.config_manager.get_cli_parameters() - - # Skip args that we handle separately or need special handling - skip_args = { - "--system_mode", - "--search_center_x", - "--search_center_y", - "--logging_level", - "--artifact_save_level", - "--cam_still_mode", - "--output_filename", - "--show_images", - "--config_file", - } # We handle config_file specially below - - for param in cli_params: - key = param["key"] - cli_arg = param["cliArgument"] - param_type = param["type"] - - if cli_arg in skip_args: - continue - - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - - if value is None: - continue - - # Skip empty string values for non-boolean parameters - if param_type != "boolean" and value == "": - continue - - if param_type == "boolean": - if value: - args.append(cli_arg) - else: - if param_type == "path" and value: - value = str(value).replace("~", str(Path.home())) - # Use --key=value format for consistency - args.append(f"{cli_arg}={value}") - - # Always add the generated config file path - args.append(f"--config_file={self.config_manager.generated_config_path}") - - return args + return [f"--web_server_port={SERVER_PORT}"] def _build_environment(self, camera: str = "camera1") -> dict: - """Build environment variables from config + """Build environment variables for the pitrac_lm subprocess Args: camera: Which camera is being calibrated @@ -858,7 +789,6 @@ def _build_environment(self, camera: str = "camera1") -> dict: Environment dictionary with required variables """ env = os.environ.copy() - config = self.config_manager.get_config() # Set PITRAC_ROOT if not already set (required by camera discovery) if "PITRAC_ROOT" not in env: @@ -867,34 +797,6 @@ def _build_environment(self, camera: str = "camera1") -> dict: env.setdefault("LIBPISP_LOG_LEVEL", "4") env.setdefault("LIBCAMERA_LOG_LEVELS", "*:ERROR") - # Camera types come from cameras.slot1.type and cameras.slot2.type (default 5 = InnoMaker IMX296) - slot1_type = config.get("cameras", {}).get("slot1", {}).get("type", 5) - slot2_type = config.get("cameras", {}).get("slot2", {}).get("type", 5) - env["PITRAC_SLOT1_CAMERA_TYPE"] = str(slot1_type) - env["PITRAC_SLOT2_CAMERA_TYPE"] = str(slot2_type) - - # Lens types come from cameras.slot1.lens and cameras.slot2.lens (default 1 = 6mm) - slot1_lens = config.get("cameras", {}).get("slot1", {}).get("lens", 1) - slot2_lens = config.get("cameras", {}).get("slot2", {}).get("lens", 1) - env["PITRAC_SLOT1_LENS_TYPE"] = str(slot1_lens) - env["PITRAC_SLOT2_LENS_TYPE"] = str(slot2_lens) - - # Orientation types come from cameras.slot1.orientation and cameras.slot2.orientation (default 1 = UpsideUp) - slot1_orientation = config.get("cameras", {}).get("slot1", {}).get("orientation", 1) - slot2_orientation = config.get("cameras", {}).get("slot2", {}).get("orientation", 1) - env["PITRAC_SLOT1_CAMERA_ORIENTATION"] = str(slot1_orientation) - env["PITRAC_SLOT2_CAMERA_ORIENTATION"] = str(slot2_orientation) - - base_dir = config.get("gs_config", {}).get("logging", {}).get("kPCBaseImageLoggingDir", "~/LM_Shares/Images/") - env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = str(base_dir).replace("~", str(Path.home())) - - web_share_dir = ( - config.get("gs_config", {}) - .get("ipc_interface", {}) - .get("kWebServerShareDirectory", "~/LM_Shares/WebShare/") - ) - env["PITRAC_WEBSERVER_SHARE_DIR"] = str(web_share_dir).replace("~", str(Path.home())) - return env async def _run_calibration_command(self, cmd: List[str], camera: str, timeout: int = 60) -> Dict[str, Any]: @@ -1003,6 +905,21 @@ def _parse_calibration_results(self, output: str) -> Optional[Dict[str, Any]]: return results if results else None + def _auto_calibration_failure_message(self, completion_result: Dict[str, Any], output: str, timeout: float) -> str: + if completion_result["method"] == "timeout": + return f"Calibration timed out after {timeout:.0f} seconds." + + for marker, reason in AUTO_CALIBRATION_FAILURE_REASONS: + if marker in output: + return reason + + errors = [line.split("[error]", 1)[1].strip() for line in output.splitlines() if "[error]" in line] + if errors: + return f"Last error: {errors[-1]}" + + exit_code = completion_result["process_exit_code"] + return f"Stopped with exit code {exit_code} and no logged reason. Open the logs for details." + def _check_calibration_failed(self, output: str) -> bool: """Check if output contains calibration failure messages @@ -1025,6 +942,20 @@ def _check_calibration_failed(self, output: str) -> bool: return False + def distortion_precheck(self, camera: str) -> Optional[str]: + if camera not in ["camera1", "camera2"]: + return "Invalid camera" + if reason := self.busy_reason(camera): + return reason + try: + import cv2 + import numpy + import charuco_detector + except ImportError as e: + logger.error(f"Missing dependency for distortion calibration: {e}") + return f"Missing dependency: {e}" + return None + async def run_distortion_calibration( self, camera: str, target_images: int = DISTORTION_DEFAULT_TARGET_IMAGES ) -> Dict[str, Any]: @@ -1042,19 +973,11 @@ async def run_distortion_calibration( Returns: Dict with calibration results or error """ - if camera not in ["camera1", "camera2"]: - return {"status": "error", "message": "Invalid camera"} + if error := self.distortion_precheck(camera): + return {"status": "error", "message": error} - if self.calibration_status[camera]["status"] in ["distortion_calibrating", "calibrating"]: - return {"status": "error", "message": "Calibration already running for this camera"} - - try: - import cv2 - import numpy as np - from charuco_detector import CompatibleCharucoDetector, CoverageTracker - except ImportError as e: - logger.error(f"Missing dependency for distortion calibration: {e}") - return {"status": "error", "message": f"Missing dependency: {e}"} + import cv2 + from charuco_detector import CompatibleCharucoDetector, CoverageTracker detector = CompatibleCharucoDetector( squares_x=8, squares_y=11, @@ -1076,21 +999,20 @@ def log_msg(msg: str) -> None: def friendly_rejection(reasons: str) -> str: r = reasons.lower() if "blurry" in r: - return "Too blurry -- hold the board steadier or improve lighting" + return "Too blurry. Hold the board steadier or add more light." if "too small" in r or "coverage" in r: - return "Board is too far away -- move it closer to the camera" + return "The board is too far away. Move it closer to the camera." if "edge" in r or "margin" in r: - return "Board is partially out of frame -- move it inward" + return "Part of the board is out of frame. Move it inward." if "corners" in r or "insufficient" in r: - return "Board not fully visible -- make sure the full pattern is in frame and well-lit" + return "The board is not fully visible. Keep the whole pattern in frame and well lit." return reasons - self.calibration_status[camera] = { + self.distortion_status[camera] = { + **_idle_distortion_status(), "status": "distortion_calibrating", "message": "Starting distortion calibration...", - "progress": 0, "last_run": datetime.now().isoformat(), - "images_captured": 0, "images_rejected": 0, "target_images": target_images, "coverage": None, @@ -1099,9 +1021,6 @@ def friendly_rejection(reasons: str) -> str: camera_index = 0 if camera == "camera1" else 1 await self.request_free_running(camera_index) - config = self.config_manager.get_config() - slot_key = "slot1" if camera == "camera1" else "slot2" - slot_config = config.get("cameras", {}).get(slot_key, {}) gain_key = "kCamera1Gain" if camera == "camera1" else "kCamera2Gain" camera_gain = self.config_manager.get_config(f"gs_config.cameras.{gain_key}") if camera_gain is None: @@ -1119,9 +1038,6 @@ def friendly_rejection(reasons: str) -> str: log_msg(f"Target: {target_images} images, camera index: {camera_index}") - min_pixel_coverage = 0.80 # 10×10 pixel grid; reachable in ~40 frames per coupon-collector - min_tilt_fraction = 0.40 - size_bin_min_samples = 3 size_bin_edges = (0.10, 0.16) # standard ball-tracking distance gives ~0.12 = medium size_bins = {"small": 0, "medium": 0, "large": 0} @@ -1138,20 +1054,22 @@ def bin_for(size: float) -> str: coverage_tracker.get_pixel_coverage_fraction() if coverage_tracker else 0.0 ) - coverage_ok = pixel_cov >= min_pixel_coverage - tilt_ok = good_count > 0 and (tilted_count / good_count) >= min_tilt_fraction - size_ok = all(n >= size_bin_min_samples for n in size_bins.values()) + coverage_ok = pixel_cov >= DISTORTION_MIN_PIXEL_COVERAGE + tilt_ok = good_count > 0 and (tilted_count / good_count) >= DISTORTION_MIN_TILT_FRACTION + size_ok = all(n >= DISTORTION_SIZE_BIN_MIN_SAMPLES for n in size_bins.values()) if good_count >= target_images and coverage_ok and tilt_ok and size_ok: break # Check if calibration was stopped - if self.calibration_status[camera]["status"] != "distortion_calibrating": + if self.distortion_status[camera]["status"] != "distortion_calibrating": log_msg("Calibration stopped by user") + self.distortion_status[camera]["status"] = "stopped" + self.distortion_status[camera]["message"] = "Stopped" return {"status": "stopped", "message": "Calibration stopped"} hint = "" - short_bin = next((b for b, n in size_bins.items() if n < size_bin_min_samples), None) + short_bin = next((b for b, n in size_bins.items() if n < DISTORTION_SIZE_BIN_MIN_SAMPLES), None) if not coverage_ok and coverage_tracker: suggested = coverage_tracker.get_suggested_region() hint = f"Move the board toward the {suggested} of the frame." @@ -1167,28 +1085,29 @@ def bin_for(size: float) -> str: hint = "Hold the board steady and visible." image_progress = min(good_count / target_images, 1.0) - cov_progress = min(pixel_cov / min_pixel_coverage, 1.0) - tilt_prog = min((tilted_count / good_count) / min_tilt_fraction, 1.0) if good_count > 0 else 0 + cov_progress = min(pixel_cov / DISTORTION_MIN_PIXEL_COVERAGE, 1.0) + tilt_prog = min((tilted_count / good_count) / DISTORTION_MIN_TILT_FRACTION, 1.0) if good_count > 0 else 0 size_prog = min( - sum(min(n, size_bin_min_samples) for n in size_bins.values()) / - (size_bin_min_samples * 3), 1.0 + sum(min(n, DISTORTION_SIZE_BIN_MIN_SAMPLES) for n in size_bins.values()) / + (DISTORTION_SIZE_BIN_MIN_SAMPLES * 3), 1.0 ) progress = int( (image_progress * 0.35 + cov_progress * 0.35 + tilt_prog * 0.15 + size_prog * 0.15) * 80 ) - self.calibration_status[camera]["progress"] = progress - self.calibration_status[camera]["hint"] = hint - self.calibration_status[camera]["tilt_fraction"] = tilted_count / good_count if good_count > 0 else 0 - self.calibration_status[camera]["pixel_coverage"] = pixel_cov - self.calibration_status[camera]["size_bins"] = dict(size_bins) + self.distortion_status[camera]["progress"] = progress + self.distortion_status[camera]["hint"] = hint + self.distortion_status[camera]["tilt_fraction"] = tilted_count / good_count if good_count > 0 else 0 + self.distortion_status[camera]["requirements"] = _distortion_requirements( + pixel_cov, tilted_count, good_count, size_bins + ) if good_count >= target_images and not (coverage_ok and tilt_ok and size_ok): - self.calibration_status[camera]["message"] = ( - f"All {target_images} images captured -- collecting a few more for better accuracy." + self.distortion_status[camera]["message"] = ( + f"All {target_images} images captured. Collecting a few more for better accuracy." ) else: - self.calibration_status[camera]["message"] = ( + self.distortion_status[camera]["message"] = ( f"Captured {good_count} of {target_images} images" ) @@ -1214,10 +1133,10 @@ def bin_for(size: float) -> str: rejected_count += 1 reasons = ", ".join(quality["reasons"]) log_msg(f"Attempt {attempt + 1}: Rejected - {reasons}") - self.calibration_status[camera]["message"] = ( + self.distortion_status[camera]["message"] = ( f"Skipped: {friendly_rejection(reasons)}" ) - self.calibration_status[camera]["images_rejected"] = rejected_count + self.distortion_status[camera]["images_rejected"] = rejected_count await asyncio.sleep(DISTORTION_CAPTURE_INTERVAL) continue @@ -1230,10 +1149,10 @@ def bin_for(size: float) -> str: if not detector.is_good_sample(params, sample_params): rejected_count += 1 log_msg(f"Attempt {attempt + 1}: Too similar to existing sample") - self.calibration_status[camera]["message"] = ( - "Skipped: board position too similar -- move it to a new area" + self.distortion_status[camera]["message"] = ( + "Skipped: the board is in a spot already covered. Move it to a new area." ) - self.calibration_status[camera]["images_rejected"] = rejected_count + self.distortion_status[camera]["images_rejected"] = rejected_count await asyncio.sleep(DISTORTION_CAPTURE_INTERVAL) continue @@ -1250,10 +1169,12 @@ def bin_for(size: float) -> str: coverage_tracker.update(corners) coverage_data = coverage_tracker.to_dict() - self.calibration_status[camera]["coverage"] = coverage_data - self.calibration_status[camera]["images_captured"] = good_count - self.calibration_status[camera]["images_rejected"] = rejected_count - self.calibration_status[camera]["size_bins"] = dict(size_bins) + self.distortion_status[camera]["coverage"] = coverage_data + self.distortion_status[camera]["images_captured"] = good_count + self.distortion_status[camera]["images_rejected"] = rejected_count + self.distortion_status[camera]["requirements"] = _distortion_requirements( + coverage_data["pixel_coverage"], tilted_count, good_count, size_bins + ) tilt_label = "tilted" if quality["tilt_score"] > 0.20 else "flat" log_msg( @@ -1264,11 +1185,10 @@ def bin_for(size: float) -> str: f"pixel coverage: {coverage_data['pixel_coverage']:.0%})" ) - # Positive feedback with coaching hint if good_count < target_images and coverage_tracker: - suggested = coverage_tracker.get_suggested_region() - self.calibration_status[camera]["message"] = ( - f"Got it! ({good_count}/{target_images}) -- Try the {suggested} area next." + suggested = coverage_tracker.get_suggested_region().replace("-", " ") + self.distortion_status[camera]["message"] = ( + f"Captured {good_count} of {target_images}. Try the {suggested} area next." ) await asyncio.sleep(DISTORTION_CAPTURE_INTERVAL) @@ -1276,8 +1196,8 @@ def bin_for(size: float) -> str: if good_count < 3: msg = "Could not capture enough usable images. Check lighting and board visibility." log_msg(f"FAILED: Only captured {good_count} good images (need at least 3)") - self.calibration_status[camera]["status"] = "failed" - self.calibration_status[camera]["message"] = msg + self.distortion_status[camera]["status"] = "failed" + self.distortion_status[camera]["message"] = msg self._write_distortion_log(log_file, log_lines) return {"status": "failed", "message": msg, "log_file": str(log_file)} @@ -1292,8 +1212,8 @@ def bin_for(size: float) -> str: log_msg(f"Proceeding with calibration despite unmet conditions: {', '.join(unmet)}") # Run calibration with outlier rejection - self.calibration_status[camera]["message"] = "Processing calibration..." - self.calibration_status[camera]["progress"] = 85 + self.distortion_status[camera]["message"] = "Processing calibration..." + self.distortion_status[camera]["progress"] = 85 log_msg(f"Running calibration on {good_count} images...") # k3 is non-trivial on this lens (~-0.13); fixing it biases k1/k2. @@ -1329,14 +1249,14 @@ def bin_for(size: float) -> str: msg = (f"Calibration quality too low (RMS {rms:.2f}px). " "Try again with the board in more varied positions and angles.") log_msg(f"REJECTED: {msg}") - self.calibration_status[camera]["status"] = "failed" - self.calibration_status[camera]["message"] = msg + self.distortion_status[camera]["status"] = "failed" + self.distortion_status[camera]["message"] = msg self._write_distortion_log(log_file, log_lines) return {"status": "failed", "message": msg, "rms_error": float(rms), "log_file": str(log_file)} - self.calibration_status[camera]["message"] = "Saving calibration results..." - self.calibration_status[camera]["progress"] = 95 + self.distortion_status[camera]["message"] = "Saving calibration results..." + self.distortion_status[camera]["progress"] = 95 self._save_distortion_results(camera, camera_matrix, dist_coeffs, rms) log_msg("Calibration results saved to configuration") @@ -1347,11 +1267,11 @@ def bin_for(size: float) -> str: "Acceptable" if rms <= 0.9 else "Poor" ) - self.calibration_status[camera]["status"] = "completed" - self.calibration_status[camera]["message"] = ( - f"Calibration complete -- accuracy: {quality_label} (error: {rms:.2f}px)" + self.distortion_status[camera]["status"] = "completed" + self.distortion_status[camera]["message"] = ( + f"Calibration complete. Accuracy: {quality_label} (error {rms:.2f}px)." ) - self.calibration_status[camera]["progress"] = 100 + self.distortion_status[camera]["progress"] = 100 self._write_distortion_log(log_file, log_lines) @@ -1370,8 +1290,8 @@ def bin_for(size: float) -> str: except Exception as e: logger.error(f"Distortion calibration failed: {e}", exc_info=True) log_msg(f"ERROR: {e}") - self.calibration_status[camera]["status"] = "error" - self.calibration_status[camera]["message"] = str(e) + self.distortion_status[camera]["status"] = "error" + self.distortion_status[camera]["message"] = str(e) self._write_distortion_log(log_file, log_lines) return {"status": "error", "message": str(e), "log_file": str(log_file)} finally: @@ -1556,71 +1476,46 @@ def _write_distortion_log(self, log_file: Path, log_lines: List[str]) -> None: except Exception as e: logger.error(f"Failed to write distortion log: {e}") - async def stop_calibration(self, camera: Optional[str] = None) -> Dict[str, Any]: - """Stop running calibration process(es) - - Handles both ball-based calibration (process termination) and - distortion calibration (status flag to break capture loop). + async def stop_calibration(self, camera: Optional[str] = None, kind: Optional[str] = None) -> Dict[str, Any]: + """Stop running calibration jobs, optionally limited to one camera and one kind. - Args: - camera: Specific camera to stop, or None to stop all - - Returns: - Dict with stop status + Ball jobs ("ball") are pitrac_lm processes and get terminated. Lens jobs + ("distortion") are flagged and their capture loop exits on its next pass. """ - # Signal distortion calibration to stop via status flag + cameras = [camera] if camera else ["camera1", "camera2"] + distortion_stopped = [] - if camera: - if self.calibration_status[camera]["status"] == "distortion_calibrating": - self.calibration_status[camera]["status"] = "stopping" - self.calibration_status[camera]["message"] = "Stopping..." - distortion_stopped.append(camera) - logger.info(f"Signaled distortion calibration stop for {camera}") - else: - for cam in ["camera1", "camera2"]: - if self.calibration_status[cam]["status"] == "distortion_calibrating": - self.calibration_status[cam]["status"] = "stopping" - self.calibration_status[cam]["message"] = "Stopping..." + if kind in (None, "distortion"): + for cam in cameras: + if self.distortion_status[cam]["status"] == "distortion_calibrating": + self.distortion_status[cam]["status"] = "stopping" + self.distortion_status[cam]["message"] = "Stopping..." distortion_stopped.append(cam) logger.info(f"Signaled distortion calibration stop for {cam}") - async with self._process_lock: - if camera: - if camera in self.current_processes: - try: - process = self.current_processes[camera] - await self._terminate_process_gracefully(process, camera) - del self.current_processes[camera] - logger.info(f"Calibration process stopped for {camera}") - return {"status": "stopped", "camera": camera} - except Exception as e: - logger.error(f"Failed to stop calibration for {camera}: {e}") - return {"status": "error", "message": str(e), "camera": camera} - if distortion_stopped: - return {"status": "stopping", "cameras": distortion_stopped} - return {"status": "not_running", "camera": camera} - else: - if not self.current_processes: - if distortion_stopped: - return {"status": "stopping", "cameras": distortion_stopped} - return {"status": "not_running"} - - stopped_cameras = [] - errors = [] - - for cam, process in list(self.current_processes.items()): + stopped = [] + errors = [] + if kind in (None, "ball"): + async with self._process_lock: + for cam in cameras: + process = self.current_processes.pop(cam, None) + if process is None: + continue try: await self._terminate_process_gracefully(process, cam) - stopped_cameras.append(cam) + stopped.append(cam) + logger.info(f"Calibration process stopped for {cam}") except Exception as e: logger.error(f"Failed to stop calibration for {cam}: {e}") errors.append(f"{cam}: {e}") - self.current_processes.clear() - - if errors: - return {"status": "partial", "stopped": stopped_cameras, "errors": errors} - return {"status": "stopped", "cameras": stopped_cameras} + if errors: + return {"status": "partial", "stopped": stopped, "errors": errors} + if stopped: + return {"status": "stopped", "cameras": stopped + distortion_stopped} + if distortion_stopped: + return {"status": "stopping", "cameras": distortion_stopped} + return {"status": "not_running"} async def _terminate_process_gracefully(self, process: asyncio.subprocess.Process, camera: str) -> None: """Terminate a process gracefully with fallback to kill diff --git a/Software/web-server/camera_detector.py b/Software/web-server/camera_detector.py index e93a85e1..7d756267 100644 --- a/Software/web-server/camera_detector.py +++ b/Software/web-server/camera_detector.py @@ -527,13 +527,13 @@ def get_camera_types(self) -> List[Dict]: { "value": 4, "label": "Pi Global Shutter", - "description": "IMX296 Color (RECOMMENDED)", + "description": "IMX296 Color", "status": "supported", }, { "value": 5, "label": "InnoMaker IMX296", - "description": "IMX296 Mono", + "description": "IMX296 Mono (RECOMMENDED)", "status": "supported", }, ] @@ -662,9 +662,9 @@ def main(): print(f" Color Mode: {cam['cfa']}") print() - print("Recommended Configuration:") - print(f" export PITRAC_SLOT1_CAMERA_TYPE={result['configuration']['slot1']['type']}") - print(f" export PITRAC_SLOT2_CAMERA_TYPE={result['configuration']['slot2']['type']}") + print("Recommended Configuration (web UI Config page, Cameras section, or its Detect button):") + print(f" cameras.slot1.type = {result['configuration']['slot1']['type']}") + print(f" cameras.slot2.type = {result['configuration']['slot2']['type']}") else: print("\nNo cameras detected!") print("\nTroubleshooting:") diff --git a/Software/web-server/config_manager.py b/Software/web-server/config_manager.py index 6561daab..c6512f7f 100644 --- a/Software/web-server/config_manager.py +++ b/Software/web-server/config_manager.py @@ -1,27 +1,172 @@ """Configuration Manager for PiTrac Web Server -Handles reading and writing JSON configuration files with a three-tier system: +Builds configuration from a three-tier system: 1. Generated defaults: From configurations.json metadata -2. Calibration data: ~/.pitrac/config/calibration_data.json (preserved across regenerations) -3. User overrides: ~/.pitrac/config/user_settings.json (read-write, sparse) +2. Calibration data: calibration table in the SQLite database +3. User overrides: settings table in the SQLite database (sparse) + +~/.pitrac/config/user_settings.json and calibration_data.json are imported once at +startup into whichever of those tables is still empty, then renamed to *.imported. """ import copy -import fcntl +import ipaddress import json import logging +import math import os +import re +from datetime import datetime from pathlib import Path from threading import RLock from typing import Any, Callable, Dict, List, Optional, Tuple +from constants import DB_PATH +from db.database import Database +from db.repositories import KeyValueRepository + logger = logging.getLogger(__name__) +_OLD_SIM = "gs_config.golf_simulator_interfaces" +_RENAMED_SETTINGS = { + f"{_OLD_SIM}.GSPro.kGSProConnectAddress": "simulators.gspro.host", + f"{_OLD_SIM}.GSPro.kGSProConnectPort": "simulators.gspro.port", + f"{_OLD_SIM}.E6.kE6ConnectAddress": "simulators.e6.host", + f"{_OLD_SIM}.E6.kE6ConnectPort": "simulators.e6.port", + f"{_OLD_SIM}.E6.kE6InterMessageDelayMs": "simulators.e6.inter_message_delay_ms", + f"{_OLD_SIM}.kLaunchMonitorIdString": None, +} +_MODEL_KINDS = { + "gs_config.ball_identification.kModelPath": "ball", + "gs_config.spin_analysis.kSpinModelPath": "spin", +} + + +def _deep_merge(base: Dict, override: Dict) -> Dict: + """Recursively merge override into base""" + result = base.copy() + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = _deep_merge(result[key], value) + else: + result[key] = value + return result + + +def _set_in_dict(d: Dict[str, Any], key: str, value: Any) -> bool: + """Set value in nested dictionary using dot notation""" + parts = key.split(".") + current = d + + for part in parts[:-1]: + if part not in current: + current[part] = {} + elif not isinstance(current[part], dict): + return False + current = current[part] + + current[parts[-1]] = value + return True + + +def _flatten(nested: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: + flat = {} + for key, value in nested.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + flat.update(_flatten(value, full_key)) + else: + flat[full_key] = value + return flat + + +def _without_dotted_keys(nested: Dict[str, Any], dropped: List[str], prefix: str = "") -> Dict[str, Any]: + clean = {} + for key, value in nested.items(): + full_key = f"{prefix}.{key}" if prefix else key + if "." in key: + dropped.append(full_key) + elif isinstance(value, dict): + clean[key] = _without_dotted_keys(value, dropped, full_key) + else: + clean[key] = value + return clean + + +def _as_integer(value: Any) -> Any: + """Truncate toward zero like stoi did; boost's integer parse rejects "35.5" outright.""" + try: + return int(float(value)) + except (TypeError, ValueError, OverflowError): + return value + + +_HOSTNAME_LABEL = re.compile(r"[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?") +HOST_ERROR = "Enter an IP address or hostname without a port" + + +def _is_valid_host(host: str) -> bool: + if host == "": + return True + try: + ipaddress.IPv4Address(host) + return True + except ValueError: + pass + labels = host.split(".") + return len(host) <= 253 and not labels[-1].isdigit() and all(_HOSTNAME_LABEL.fullmatch(label) for label in labels) + + +def coerce(setting_type: str, value: Any) -> Any: + """Convert a submitted value to a metadata type; raises ValueError with a plain message.""" + if setting_type in ("select", "string", "text", "path", "ip_address"): + if value is None or isinstance(value, (dict, list)): + raise ValueError("Must be text") + return str(value).strip() if setting_type == "ip_address" else str(value) + + if setting_type == "boolean": + # The generated config writes booleans as "1" and "0" + if isinstance(value, str) and value.lower() in ("true", "false", "1", "0"): + return value.lower() in ("true", "1") + if value in (True, False): + return bool(value) + raise ValueError("Must be true or false") + + if setting_type in ("integer", "number", "float"): + if isinstance(value, bool) or value is None: + raise ValueError("Must be a number") + try: + number = float(value) + if not math.isfinite(number): + raise ValueError + except (TypeError, ValueError, OverflowError): + raise ValueError("Must be a number") from None + if setting_type == "integer" and not number.is_integer(): + raise ValueError("Must be a whole number") + if setting_type == "integer" or (setting_type == "number" and number.is_integer()): + return int(number) + return number + + if setting_type == "array" and isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + raise ValueError("Invalid JSON array format") from None + + return value + + +def _unflatten(flat: Dict[str, Any]) -> Dict[str, Any]: + nested: Dict[str, Any] = {} + for key, value in flat.items(): + _set_in_dict(nested, key, value) + return nested + class ConfigurationManager: - """Manages PiTrac configuration with JSON-based system""" + """Manages PiTrac configuration stored in SQLite""" - def __init__(self): + def __init__(self, db: Optional[Database] = None): self._lock = RLock() self._metadata_cache = None @@ -31,21 +176,29 @@ def __init__(self): def expand_path(path_str: str) -> Path: return Path(path_str).expanduser() - # Configuration paths for three-tier system + # Legacy JSON files, read only to import them into the database self.user_settings_path = expand_path( sys_paths.get("userSettingsPath", {}).get("default", "~/.pitrac/config/user_settings.json") ) self.calibration_data_path = expand_path("~/.pitrac/config/calibration_data.json") - self.generated_config_path = self.user_settings_path.parent / "generated_golf_sim_config.json" self.user_settings: Dict[str, Any] = {} self.calibration_data: Dict[str, Any] = {} self.merged_config: Dict[str, Any] = {} + self.transient_overrides: Dict[str, Any] = {} self.restart_required_params = self._load_restart_required_params() self._config_callbacks: Dict[str, List[Callable[[str, Any], None]]] = {} + self._db = db or Database(DB_PATH) + self._settings = KeyValueRepository(self._db, "settings") + self._calibration = KeyValueRepository(self._db, "calibration") + + self.found_legacy_json = self.user_settings_path.exists() or self.calibration_data_path.exists() + self._import_json(self._settings, self.user_settings_path) + self._import_json(self._calibration, self.calibration_data_path) + self._move_renamed_keys() self.reload() def _load_raw_metadata(self) -> Dict[str, Any]: @@ -75,8 +228,8 @@ def reload(self) -> None: """Reload configuration from metadata, calibration data, and user settings""" with self._lock: self._metadata_cache = None - self.user_settings = self._load_json(self.user_settings_path) - self.calibration_data = self._load_json(self.calibration_data_path) + self.user_settings = _unflatten(self._settings.load()) + self.calibration_data = _unflatten(self._calibration.load()) # Build merged config from metadata defaults + calibration + user overrides self.merged_config = self._build_config_from_metadata() self.restart_required_params = self._load_restart_required_params() @@ -90,44 +243,75 @@ def _rebuild_merged_config(self) -> None: self.merged_config = self._build_config_from_metadata() self.restart_required_params = self._load_restart_required_params() - def _load_json(self, path: Path) -> Dict[str, Any]: - """Load JSON file safely""" - if not path.exists(): - return {} - + def _load_json(self, path: Path) -> Optional[Dict[str, Any]]: + """Load a JSON object, or None if the file is unreadable or holds something else""" try: with open(path, "r") as f: - return json.load(f) - except (json.JSONDecodeError, IOError) as e: + data = json.load(f) + except (OSError, ValueError) as e: logger.error(f"Failed to load {path}: {e}") - return {} + return None + return data if isinstance(data, dict) else None + + def _import_json(self, repo: KeyValueRepository, path: Path) -> None: + """Import a legacy JSON file into an empty table, then rename it""" + if not path.exists(): + return + if repo.load(): + logger.info(f"Not importing {path}: the {repo.table} table already has values") + return + + data = self._load_json(path) + if data is None: + logger.warning(f"Not importing {path}: it is not a readable JSON object, keeping the stored values") + return - def _save_json(self, path: Path, data: Dict[str, Any]) -> bool: - """Save JSON file with proper formatting and file locking""" + # get_config never resolved dotted keys, so importing them would change behavior + dotted: List[str] = [] + data = _without_dotted_keys(data, dotted) + if dotted: + logger.info(f"Dropping dotted keys from {path}: {', '.join(dotted)}") + + repo.replace_all(_flatten(data)) + + target = path.with_name(path.name + ".imported") + if target.exists(): + stamped = f"{target.name}.{datetime.now():%Y%m%d-%H%M%S}" + target = path.with_name(stamped) + n = 0 + while target.exists(): + n += 1 + target = path.with_name(f"{stamped}.{n}") try: - path.parent.mkdir(parents=True, exist_ok=True) + path.rename(target) + except OSError as e: + # The rows are written and a re-import is idempotent, so failing here would only crash-loop the service + logger.error(f"Imported {path} but could not rename it to {target}, will import it again next start: {e}") + return + logger.info(f"Imported {path} into the {repo.table} table, renamed to {target.name}") - data_copy = copy.deepcopy(data) + def _move_renamed_keys(self) -> None: + """Move stored settings to their renamed keys in one write; an old sim address also meant the sim was on""" + stored = self._settings.load() + old_keys = [key for key in _RENAMED_SETTINGS if key in stored] + if not old_keys: + return - lock_path = path.with_suffix(".lock") - temp_path = path.with_suffix(".tmp") - with open(lock_path, "w") as lock_f: - fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) - try: - with open(temp_path, "w") as f: - json.dump(data_copy, f, indent=2, sort_keys=True) - f.flush() - os.fsync(f.fileno()) - temp_path.replace(path) - finally: - fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) - - logger.info(f"Saved configuration to {path}") - return True + moved = {key: value for key, value in stored.items() if key not in _RENAMED_SETTINGS} + for old in old_keys: + new, value = _RENAMED_SETTINGS[old], stored[old] + if new is None: + logger.info(f"Dropping stored setting {old}={value!r}, nothing reads it any more") + continue + if new in stored: + logger.info(f"Dropping stored setting {old}={value!r}, {new} is already set") + continue + moved[new] = value + logger.info(f"Moved stored setting {old} to {new}") + if new.endswith(".host") and isinstance(value, str) and value.strip(): + moved.setdefault(f"{new.rsplit('.', 1)[0]}.enabled", True) - except (IOError, OSError) as e: - logger.error(f"Failed to save {path}: {e}") - return False + self._settings.replace_all(moved) def _build_config_from_metadata(self) -> Dict[str, Any]: """Build configuration from metadata defaults, calibration data, and user overrides""" @@ -150,22 +334,11 @@ def _build_config_from_metadata(self) -> Dict[str, Any]: # Set the default value current[parts[-1]] = setting_info["default"] - # Helper function for deep merging - def deep_merge(base: Dict, override: Dict) -> Dict: - """Recursively merge override into base""" - result = base.copy() - for key, value in override.items(): - if key in result and isinstance(result[key], dict) and isinstance(value, dict): - result[key] = deep_merge(result[key], value) - else: - result[key] = value - return result - # Apply calibration data (persistent layer) - config = deep_merge(config, self.calibration_data) + config = _deep_merge(config, self.calibration_data) # Then apply user overrides (highest priority) - return deep_merge(config, self.user_settings) + return _deep_merge(config, self.user_settings) def register_callback(self, key_pattern: str, callback: Callable[[str, Any], None]) -> None: """Register callback for configuration updates matching pattern @@ -218,6 +391,12 @@ def _notify_callbacks(self, key: str, value: Any) -> None: except Exception as e: logger.error(f"Callback error for {key}: {e}", exc_info=True) + def _notify_changed(self, before: Dict[str, Any], after: Dict[str, Any]) -> None: + before, after = _flatten(before), _flatten(after) + for key in sorted(before.keys() | after.keys()): + if before.get(key) != after.get(key): + self._notify_callbacks(key, after.get(key)) + def get_config(self, key: Optional[str] = None) -> Any: """Get configuration value or entire config @@ -246,6 +425,14 @@ def get_merged_with_metadata_defaults(self) -> Dict[str, Any]: with self._lock: return copy.deepcopy(self.merged_config) + def get_calibrated_keys(self) -> List[str]: + with self._lock: + return sorted(_flatten(self.calibration_data)) + + def calibration_updated_at(self, key: str) -> Optional[str]: + """When a saved calibration value last changed, or None if it was never saved""" + return self._calibration.updated_at(key) + def get_default(self, key: Optional[str] = None) -> Any: """Get default value from metadata""" if key is None: @@ -286,6 +473,14 @@ def get_user_settings(self) -> Dict[str, Any]: with self._lock: return copy.deepcopy(self.user_settings) + def coerce_value(self, key: str, value: Any) -> Any: + """Convert a submitted value to the type its metadata declares. + + Raises ValueError with a plain message when it cannot be converted. + Keys without metadata pass through unchanged. + """ + return coerce(self.load_configurations_metadata().get("settings", {}).get(key, {}).get("type", ""), value) + def set_config(self, key: str, value: Any) -> Tuple[bool, str, bool]: """Set configuration value @@ -304,71 +499,61 @@ def set_config(self, key: str, value: Any) -> Tuple[bool, str, bool]: default_value = self.get_default(key) is_calibration = self._is_calibration_field(key) - # Auto-convert JSON string to array if setting type is array - metadata = self.load_configurations_metadata() - settings_metadata = metadata.get("settings", {}) - if key in settings_metadata: - setting_type = settings_metadata[key].get("type", "") - if setting_type == "array" and isinstance(value, str): - try: - value = json.loads(value) - except json.JSONDecodeError: - return False, "Invalid JSON array format", False + is_valid, error_msg = self.validate_config(key, value) + if not is_valid: + return False, error_msg, False + value = self.coerce_value(key, value) if value == default_value: if is_calibration: calibration_copy = copy.deepcopy(self.calibration_data) if self._delete_from_dict(calibration_copy, key): - if self._save_json(self.calibration_data_path, calibration_copy): - self.calibration_data = calibration_copy - self._rebuild_merged_config() - notify_key = key - notify_value = default_value - result = ( - True, - f"Reset calibration {key} to default value", - key in self.restart_required_params, - ) + self._calibration.replace_all(_flatten(calibration_copy)) + self.calibration_data = calibration_copy + self._rebuild_merged_config() + notify_key = key + notify_value = default_value + result = ( + True, + f"Reset calibration {key} to default value", + key in self.restart_required_params, + ) else: settings_copy = copy.deepcopy(self.user_settings) if self._delete_from_dict(settings_copy, key): - if self._save_json(self.user_settings_path, settings_copy): - self.user_settings = settings_copy - self._rebuild_merged_config() - notify_key = key - notify_value = default_value - result = ( - True, - f"Reset {key} to default value", - key in self.restart_required_params, - ) + self._settings.replace_all(_flatten(settings_copy)) + self.user_settings = settings_copy + self._rebuild_merged_config() + notify_key = key + notify_value = default_value + result = ( + True, + f"Reset {key} to default value", + key in self.restart_required_params, + ) if result is None: result = (True, "Value already at default", False) elif is_calibration: calibration_copy = copy.deepcopy(self.calibration_data) - if self._set_in_dict(calibration_copy, key, value): - if self._save_json(self.calibration_data_path, calibration_copy): - self.calibration_data = calibration_copy - self._rebuild_merged_config() - notify_key = key - notify_value = value - requires_restart = key in self.restart_required_params - result = (True, f"Set calibration {key} = {value}", requires_restart) - else: - result = (False, "Failed to save calibration data", False) + if _set_in_dict(calibration_copy, key, value): + self._calibration.replace_all(_flatten(calibration_copy), touched=[key]) + self.calibration_data = calibration_copy + self._rebuild_merged_config() + notify_key = key + notify_value = value + requires_restart = key in self.restart_required_params + result = (True, f"Set calibration {key} = {value}", requires_restart) else: settings_copy = copy.deepcopy(self.user_settings) - if self._set_in_dict(settings_copy, key, value): - if self._save_json(self.user_settings_path, settings_copy): - self.user_settings = settings_copy - self._rebuild_merged_config() - notify_key = key - notify_value = value - requires_restart = key in self.restart_required_params - result = (True, f"Set {key} = {value}", requires_restart) - else: - result = (False, "Failed to save configuration", False) + if _set_in_dict(settings_copy, key, value): + self._settings.replace_all(_flatten(settings_copy)) + self.user_settings = settings_copy + self._rebuild_merged_config() + notify_key = key + notify_value = value + requires_restart = key in self.restart_required_params + result = (True, f"Set {key} = {value}", requires_restart) if result is None: result = (False, "Failed to set value", False) @@ -390,12 +575,10 @@ def set_calibration_batch(self, updates: Dict[str, Any]) -> Tuple[bool, str]: calibration_copy = copy.deepcopy(self.calibration_data) for key, value in updates.items(): - if not self._set_in_dict(calibration_copy, key, value): + if not _set_in_dict(calibration_copy, key, value): return False, f"Failed to set {key}" - if not self._save_json(self.calibration_data_path, calibration_copy): - return False, "Failed to save calibration data" - + self._calibration.replace_all(_flatten(calibration_copy), touched=updates) self.calibration_data = calibration_copy self._rebuild_merged_config() @@ -404,21 +587,6 @@ def set_calibration_batch(self, updates: Dict[str, Any]) -> Tuple[bool, str]: return True, f"Saved {len(updates)} calibration values" - def _set_in_dict(self, d: Dict[str, Any], key: str, value: Any) -> bool: - """Set value in nested dictionary using dot notation""" - parts = key.split(".") - current = d - - for part in parts[:-1]: - if part not in current: - current[part] = {} - elif not isinstance(current[part], dict): - return False - current = current[part] - - current[parts[-1]] = value - return True - def _delete_from_dict(self, d: Dict[str, Any], key: str) -> bool: """Delete value from nested dictionary using dot notation""" parts = key.split(".") @@ -464,12 +632,13 @@ def _cleanup_empty_dicts(self, d: Dict[str, Any], max_depth: int = 100, current_ def reset_all(self) -> Tuple[bool, str]: """Reset all user settings to defaults""" with self._lock: - if self._save_json(self.user_settings_path, {}): - self.user_settings = {} - self._rebuild_merged_config() - return True, "Reset all settings to defaults" - - return False, "Failed to reset configuration" + before = self.merged_config + self._settings.replace_all({}) + self.user_settings = {} + self._rebuild_merged_config() + after = self.merged_config + self._notify_changed(before, after) + return True, "Reset all settings to defaults" def get_diff(self) -> Dict[str, Any]: """Get differences between user settings and defaults @@ -506,6 +675,11 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: Returns: Tuple of (is_valid, error_message) """ + try: + value = self.coerce_value(key, value) + except ValueError as e: + return False, str(e) + with self._lock: metadata = self.load_configurations_metadata() settings_metadata = metadata.get("settings", {}) @@ -516,8 +690,8 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: setting_type = setting_info.get("type", "") if setting_type == "select" and "options" in setting_info: - if key in ("gs_config.ball_identification.kModelPath", "gs_config.spin_analysis.kSpinModelPath"): - available_models = self.get_available_models() + if key in _MODEL_KINDS: + available_models = self.get_available_models(_MODEL_KINDS[key]) if available_models: valid_options = list(available_models.values()) str_value = str(value) @@ -530,11 +704,11 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: if str_value not in valid_options: return False, f"Must be one of: {', '.join(valid_options)}" - elif setting_type == "boolean": - if not isinstance(value, bool) and value not in [True, False, "true", "false"]: - return False, "Must be true or false" + elif setting_type == "ip_address": + if not _is_valid_host(value): + return False, HOST_ERROR - elif setting_type == "number": + elif setting_type in ("number", "integer", "float"): try: num_val = float(value) if "min" in setting_info and num_val < setting_info["min"]: @@ -545,15 +719,7 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: return False, "Must be a number" elif setting_type == "array": - # Handle JSON string representation of arrays - if isinstance(value, str): - try: - parsed = json.loads(value) - if not isinstance(parsed, list): - return False, "Must be a valid array" - except json.JSONDecodeError: - return False, "Must be a valid JSON array" - elif not isinstance(value, list): + if not isinstance(value, list): return False, "Must be an array" return True, "" @@ -574,57 +740,28 @@ def validate_config(self, key: str, value: Any) -> Tuple[bool, str]: return True, "" - def generate_golf_sim_config(self) -> Path: - """Generate golf_sim_config.json from configurations metadata and user settings - - This method creates a complete golf_sim_config.json file by: - 1. Taking all settings marked with passedVia: "json" - 2. Getting their values (default + user overrides) - 3. Building the nested JSON structure expected by pitrac_lm - - Returns: - Path to the generated configuration file - - Raises: - RuntimeError: If generation fails - """ - try: - config = {} - metadata = self.load_configurations_metadata() - settings_metadata = metadata.get("settings", {}) - - if not settings_metadata: - raise RuntimeError("No settings found in configurations metadata") - - # Process all settings and build the JSON structure - json_settings_count = 0 - for key, setting_info in settings_metadata.items(): - # Skip non-JSON routed settings - passed_via = setting_info.get("passedVia", "json") # Default to json if not specified - if passed_via in ["cli", "environment"]: - continue - - # Get the merged value (default + calibration + user override) - value = self.get_config(key) - if value is not None: - # Build nested structure from dot notation key - self._set_nested_json(config, key, value) - json_settings_count += 1 + def build_generated_config(self) -> Dict[str, Any]: + """Build the merged config dict served to pitrac_lm (defaults + calibration + user settings).""" + config = {} + metadata = self.load_configurations_metadata() + settings_metadata = metadata.get("settings", {}) - if json_settings_count == 0: - raise RuntimeError("No JSON settings found to generate config") + if not settings_metadata: + raise RuntimeError("No settings found in configurations metadata") - # Save to generated location - generated_path = self.user_settings_path.parent / "generated_golf_sim_config.json" - if not self._save_json(generated_path, config): - raise RuntimeError(f"Failed to save generated config to {generated_path}") + json_settings_count = 0 + for key, setting_info in settings_metadata.items(): + value = self.get_config(key) + if setting_info.get("type") == "integer": + value = _as_integer(value) + if value is not None: + self._set_nested_json(config, key, value) + json_settings_count += 1 - logger.info(f"Generated golf_sim_config.json with {json_settings_count} settings at {generated_path}") - return generated_path + if json_settings_count == 0: + raise RuntimeError("No JSON settings found to generate config") - except Exception as e: - logger.error(f"Failed to generate golf_sim_config.json: {e}") - raise RuntimeError(f"Config generation failed: {e}") + return _deep_merge(config, self.transient_overrides) def _set_nested_json(self, config: dict, key: str, value: Any): """Set value in nested JSON structure based on dot notation key @@ -662,9 +799,10 @@ def _set_nested_json(self, config: dict, key: str, value: Any): str_value = str(Path(str_value).expanduser()) current[final_key] = str_value - def get_available_models(self) -> Dict[str, str]: + def get_available_models(self, kind: str) -> Dict[str, str]: """ - Discover available YOLO models from the models directory. + Discover available YOLO models of one kind ("ball" or "spin") from the models directory. + A directory whose name contains "spin" holds a spin model; every other one holds a ball model. Returns a dict of {display_name: model_dir_path} for dropdown options. The C++ backend loads NCNN model files from the selected directory. """ @@ -685,7 +823,7 @@ def get_available_models(self) -> Dict[str, str]: continue for model_dir in base_dir.iterdir(): - if model_dir.is_dir(): + if model_dir.is_dir() and ("spin" in model_dir.name.lower()) == (kind == "spin"): for pattern in model_file_patterns: if (model_dir / pattern).exists(): display_name = model_dir.name @@ -707,11 +845,10 @@ def load_configurations_metadata(self): with open(config_path, "r") as f: metadata = json.load(f) - model_options = self.get_available_models() - if model_options and "settings" in metadata: - for model_key in ("gs_config.ball_identification.kModelPath", "gs_config.spin_analysis.kSpinModelPath"): - if model_key in metadata["settings"]: - metadata["settings"][model_key]["options"] = model_options + for model_key, kind in _MODEL_KINDS.items(): + model_options = self.get_available_models(kind) + if model_options and model_key in metadata.get("settings", {}): + metadata["settings"][model_key]["options"] = model_options self._metadata_cache = metadata return metadata @@ -719,49 +856,6 @@ def load_configurations_metadata(self): logger.error(f"Error loading configurations.json: {e}") return {"settings": {}} - def get_cli_parameters(self) -> List[Dict[str, Any]]: - """Get all CLI parameters to pass to the pitrac_lm process.""" - metadata = self.load_configurations_metadata() - settings = metadata.get("settings", {}) - - cli_params = [] - for key, info in settings.items(): - if info.get("passedVia") == "cli": - cli_params.append({ - "key": key, - "cliArgument": info.get("cliArgument"), - "type": info.get("type"), - "default": info.get("default"), - }) - return cli_params - - def get_environment_parameters(self) -> List[Dict[str, Any]]: - """Get all environment parameters to set for the pitrac_lm process.""" - metadata = self.load_configurations_metadata() - settings = metadata.get("settings", {}) - - env_params = [] - for key, info in settings.items(): - if info.get("passedVia") == "environment": - env_params.append({ - "key": key, - "envVariable": info.get("envVariable"), - "type": info.get("type"), - "default": info.get("default"), - }) - return env_params - - def flatten_config(self, config: Dict[str, Any], prefix: str = "") -> Dict[str, Any]: - """Flatten nested config dict into dot-notation keys.""" - result = {} - for key, value in config.items(): - full_key = f"{prefix}.{key}" if prefix else key - if isinstance(value, dict): - result.update(self.flatten_config(value, full_key)) - else: - result[full_key] = value - return result - def get_categories(self) -> Dict[str, Dict[str, List[str]]]: """Get configuration organized by categories with basic/advanced subcategories @@ -774,7 +868,6 @@ def get_categories(self) -> Dict[str, Dict[str, List[str]]]: "categoryList", [ "Cameras", - "Simulators", "Ball Detection", "AI Detection", "Storage", @@ -803,7 +896,7 @@ def get_categories(self) -> Dict[str, Dict[str, List[str]]]: # Determine if this is a basic or advanced setting subcategory = setting_info.get("subcategory", "advanced") - if category in categories: + if category in categories and not setting_info.get("internal"): categories[category][subcategory].append(key) # No auto-categorization - all items must have explicit categories @@ -856,59 +949,64 @@ def export_config(self) -> Dict[str, Any]: } return export_data - def import_config(self, import_data: Dict[str, Any]) -> Tuple[bool, str]: + def _coerce_imported(self, nested: Dict[str, Any], skipped: Dict[str, str]) -> Dict[str, Any]: + """Coerce every valid key of an imported tree; invalid keys are left out and recorded in skipped.""" + flat = {} + for key, value in _flatten(nested).items(): + # Simulators live in their own table; an old export's keys would migrate into a duplicate instance + if key.startswith("simulators."): + logger.info(f"Not importing {key}, simulators are added from the navbar now") + continue + is_valid, error = self.validate_config(key, value) + if not is_valid: + logger.warning(f"Not importing {key}: {error}") + skipped[key] = error + continue + flat[key] = self.coerce_value(key, value) + return _unflatten(flat) + + def import_config(self, import_data: Dict[str, Any]) -> Tuple[bool, str, Dict[str, str]]: """Import configuration from exported data Args: import_data: Dictionary with user_settings and optional calibration_data Returns: - Tuple of (success, message) + Tuple of (success, message, skipped keys with the reason each was not imported) """ + skipped: Dict[str, str] = {} with self._lock: try: if not isinstance(import_data, dict): - return False, "Import data must be a dictionary" + return False, "Import data must be a dictionary", skipped new_user = None new_cal = None - if "user_settings" in import_data: - val = import_data["user_settings"] - if isinstance(val, dict): - new_user = copy.deepcopy(val) + if isinstance(import_data.get("user_settings"), dict): + new_user = self._coerce_imported(import_data["user_settings"], skipped) - if "calibration_data" in import_data: - val = import_data["calibration_data"] - if isinstance(val, dict): - new_cal = copy.deepcopy(val) + if isinstance(import_data.get("calibration_data"), dict): + new_cal = self._coerce_imported(import_data["calibration_data"], skipped) - # Snapshot originals for rollback - orig_user = copy.deepcopy(self.user_settings) - orig_cal = copy.deepcopy(self.calibration_data) + with self._db.transaction() as conn: + if new_user is not None: + self._settings.replace_all_in(conn, _flatten(new_user)) + if new_cal is not None: + self._calibration.replace_all_in(conn, _flatten(new_cal)) - if new_user is not None: - if not self._save_json(self.user_settings_path, new_user): - return False, "Failed to save imported user settings" - - if new_cal is not None: - if not self._save_json(self.calibration_data_path, new_cal): - # Roll back user_settings file if it was already written - if new_user is not None: - if not self._save_json(self.user_settings_path, orig_user): - logger.error("Failed to roll back user_settings after calibration save failure") - return False, "Failed to save imported calibration data" - - # Both saves succeeded, update in-memory state if new_user is not None: self.user_settings = new_user if new_cal is not None: self.calibration_data = new_cal + before = self.merged_config self._rebuild_merged_config() - - return True, "Configuration imported successfully" + after = self.merged_config except Exception as e: logger.error(f"Error importing configuration: {e}") - return False, f"Import failed: {e}" + return False, f"Import failed: {e}", skipped + + self._notify_changed(before, after) + return True, "Configuration imported successfully", skipped diff --git a/Software/web-server/configurations.json b/Software/web-server/configurations.json index 39d0de38..114e9e5d 100644 --- a/Software/web-server/configurations.json +++ b/Software/web-server/configurations.json @@ -5,14 +5,12 @@ "camera1": { "displayName": "Camera 1", "slot": "slot1", - "defaultIndex": 0, - "envPrefix": "PITRAC_SLOT1" + "defaultIndex": 0 }, "camera2": { "displayName": "Camera 2", "slot": "slot2", - "defaultIndex": 1, - "envPrefix": "PITRAC_SLOT2" + "defaultIndex": 1 } }, "systemDefaults": { @@ -150,6 +148,36 @@ "internal": true } }, + "storage": { + "sessionTimeoutMinutes": { + "displayName": "Session Timeout Minutes", + "description": "Minutes of inactivity before a new session starts", + "type": "number", + "default": 30, + "internal": true + }, + "imageStorageCapMB": { + "displayName": "Image Storage Cap MB", + "description": "Maximum disk space for stored shot images", + "type": "number", + "default": 5000, + "internal": true + }, + "logDirectoryCapMB": { + "displayName": "Log Directory Cap MB", + "description": "Maximum total disk space for all per-run log files", + "type": "number", + "default": 30, + "internal": true + }, + "runLogCapMB": { + "displayName": "Run Log Cap MB", + "description": "Maximum size for a single per-run log file before truncation; kept under the directory cap so the live file can't exceed it", + "type": "number", + "default": 20, + "internal": true + } + }, "environmentDefaults": { "ldLibraryPath": { "displayName": "LD Library Path", @@ -206,6 +234,7 @@ "displayName": "Pi Configuration Mode", "description": "Single Pi with dual cameras (most common) or Dual Pi setup (one camera per Pi)", "type": "select", + "setup": true, "options": { "single": "Single Pi (2 cameras on 1 Pi)", "dual": "Dual Pi (1 camera per Pi)" @@ -216,8 +245,7 @@ "cameras.slot2.type", "cameras.slot2.lens", "gs_config.cameras.kCamera2Gain" - ], - "passedVia": "json" + ] }, "gs_config.strobing.kConnectionBoardVersion": { "category": "System", @@ -226,21 +254,21 @@ "displayName": "Connection Board Version", "description": "The original Ver. 1.0 board inverts the external shutter signal, so this setting tells the system whether to pre-invert the signal or not.", "type": "select", + "setup": true, "options": { "1": "Version 1.0", "2": "Version 2.0", "3": "Version 3.0" }, "default": "3", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kDAC_setting": { "category": "Strobing", "subcategory": "advanced", "displayName": "Strobe DAC Setting", "description": "Calibrated DAC value for strobe LED current control. Set by the strobe calibration tool.", - "type": "number", + "type": "integer", "min": 0, "max": 255, "default": null, @@ -251,32 +279,15 @@ "subcategory": "basic", "basicSubcategory": "System", "displayName": "Enclosure Version", - "description": "The enclosure version (type) can affect various aspects of the system. For example, the distance of the reference ball in the calibration rig, as different enclosures position the cameras at different locations.", + "description": "Version 2.0 (2025) has a flat base under a tower with triangular supports on the sides of the floors. Version 3.0 (2026) is a streamlined tower with stacked modules. The enclosure sets where the cameras sit, which changes things like the reference ball distance in the calibration rig.", "type": "select", + "setup": true, "options": { - "2": "Version 2.0 (Circa 2025 - Flat base bottom under a tower with triangular supports on the sides of the floors)", - "3": "Version 3.0 (Circa 2026 - Streamlined tower with stacked modules)" + "2": "Version 2.0 (2025, flat base)", + "3": "Version 3.0 (2026, stacked modules)" }, "default": "2", - "requiresRestart": true, - "passedVia": "json" - }, - "system.camera_role": { - "category": "System", - "subcategory": "advanced", - "displayName": "Camera Role (Dual Pi Mode)", - "description": "Which camera this Pi controls in dual Pi mode", - "type": "select", - "options": { - "camera1": "Camera 1 (Primary)", - "camera2": "Camera 2 (Secondary)" - }, - "default": "camera1", - "requiresRestart": true, - "visibleWhen": { - "system.mode": "dual" - }, - "passedVia": "json" + "requiresRestart": true }, "gs_config.modes.kStartInPuttingMode": { "category": "System", @@ -285,37 +296,35 @@ "displayName": "Start in Putting Mode", "description": "Start the system in putting mode for short game practice", "type": "boolean", + "setup": true, "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.player.kGolferOrientation": { "category": "System", "subcategory": "basic", "basicSubcategory": "System", "displayName": "Golfer Orientation", - "description": "Golfer handedness - affects ball detection and swing analysis (CLI parameter)", + "description": "Golfer handedness - affects ball detection and swing analysis", "type": "select", + "setup": true, "options": { "right_handed": "Right Handed", "left_handed": "Left Handed" }, "default": "right_handed", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--golfer_orientation" + "requiresRestart": true }, "gs_config.player.kUsePracticeBalls": { "category": "System", "subcategory": "basic", "basicSubcategory": "System", "displayName": "Practice Ball Mode", - "description": "Enable for lightweight practice/foam balls - adjusts detection parameters (CLI parameter)", + "description": "Enable for lightweight practice/foam balls - adjusts detection parameters", "type": "boolean", + "setup": true, "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--practice_ball" + "requiresRestart": true }, "cameras.slot1.type": { "category": "Cameras", @@ -324,6 +333,7 @@ "displayName": "Camera 1 Type", "description": "Type of camera connected to slot 1 (CAM0 port)", "type": "select", + "setup": true, "options": { "1": "Pi Camera v1.3 - OV5647 sensor (DEPRECATED)", "2": "Pi Camera v2 - IMX219 sensor (DEPRECATED)", @@ -332,25 +342,23 @@ "5": "InnoMaker IMX296 - Mono sensor (RECOMMENDED)" }, "default": "5", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT1_CAMERA_TYPE" + "requiresRestart": true }, "cameras.slot1.lens": { "category": "Cameras", - "subcategory": "advanced", + "subcategory": "basic", + "basicSubcategory": "Hardware", "displayName": "Camera 1 Lens", "description": "Lens type for camera 1", "type": "select", + "setup": true, "options": { "1": "6mm - Standard lens", "2": "3.6mm M12 - Wide angle lens", "3": "Custom - Use custom lens setting (below)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT1_LENS_TYPE" + "requiresRestart": true }, "cameras.kSlot1CustomLensFocalLength": { "category": "Cameras", @@ -358,7 +366,6 @@ "default": 4.0, "description": "Focal length when using custom lens in Slot 1 (mm)", "ui_name": "Custom Lens Focal Length Slot 1", - "passedVia": "json", "requiresRestart": true }, "cameras.slot1.orientation": { @@ -372,9 +379,7 @@ "2": "UpsideDown - Upside down orientation (system will flip)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT1_CAMERA_ORIENTATION" + "requiresRestart": true }, "cameras.slot2.type": { "category": "Cameras", @@ -386,6 +391,7 @@ "system.mode": "single" }, "type": "select", + "setup": true, "options": { "1": "Pi Camera v1.3 - OV5647 sensor (DEPRECATED)", "2": "Pi Camera v2 - IMX219 sensor (DEPRECATED)", @@ -394,28 +400,26 @@ "5": "InnoMaker IMX296 - Mono sensor (RECOMMENDED)" }, "default": "5", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT2_CAMERA_TYPE" + "requiresRestart": true }, "cameras.slot2.lens": { "category": "Cameras", - "subcategory": "advanced", + "subcategory": "basic", + "basicSubcategory": "Hardware", "displayName": "Camera 2 Lens", "description": "Lens type for camera 2 - Only used in single Pi mode", "visibleWhen": { "system.mode": "single" }, "type": "select", + "setup": true, "options": { "1": "6mm - Standard lens", "2": "3.6mm M12 - Wide angle lens", "3": "Custom - Use custom lens setting (below)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT2_LENS_TYPE" + "requiresRestart": true }, "cameras.kSlot2CustomLensFocalLength": { "category": "Cameras", @@ -423,7 +427,6 @@ "default": 4.0, "description": "Focal length when using custom lens in Slot 2 (mm)", "ui_name": "Custom Lens Focal Length Slot 2", - "passedVia": "json", "requiresRestart": true }, "cameras.slot2.orientation": { @@ -437,9 +440,7 @@ "2": "UpsideDown - Upside down orientation (system will flip)" }, "default": "1", - "requiresRestart": true, - "passedVia": "environment", - "envVariable": "PITRAC_SLOT2_CAMERA_ORIENTATION" + "requiresRestart": true }, "logging.level": { "category": "Logging", @@ -451,21 +452,19 @@ "options": { "trace": "Trace - Most verbose, for deep debugging", "debug": "Debug - Detailed debugging information", - "info": "Info - Normal operational messages", - "warn": "Warning - Important notices (default)", + "info": "Info - Normal operational messages (default)", + "warn": "Warning - Important notices", "error": "Error - Only errors" }, "default": "info", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--logging_level" + "requiresRestart": true }, "gs_config.logging.kArtifactSaveLevel": { "category": "Logging", "subcategory": "basic", "basicSubcategory": "Storage", "displayName": "Diagnostic Image Level", - "description": "Control how many diagnostic images are saved (CLI parameter)", + "description": "Control how many diagnostic images are saved", "type": "select", "options": { "none": "None - No images saved", @@ -473,31 +472,25 @@ "all": "All - Save all intermediate images (may slow system)" }, "default": "final_results_only", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--artifact_save_level" + "requiresRestart": true }, "gs_config.debug.kShowDebugImages": { "category": "Debugging", "subcategory": "advanced", "displayName": "Show Debug Images", - "description": "Display debug/trace images on screen for troubleshooting (CLI parameter)", + "description": "Display debug/trace images on screen for troubleshooting", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--show_images" + "requiresRestart": true }, "gs_config.debug.kWaitForKeyOnImages": { "category": "Debugging", "subcategory": "advanced", "displayName": "Pause on Debug Images", - "description": "Wait for keypress after showing debug images (CLI parameter)", + "description": "Wait for keypress after showing debug images", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--wait_keys" + "requiresRestart": true }, "gs_config.ball_identification.kStrobedBallDetectionMethod": { "category": "Ball Detection", @@ -511,8 +504,7 @@ "experimental": "YOLO Neural Network" }, "default": "experimental", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kBallPlacementDetectionMethod": { "category": "Ball Detection", @@ -526,8 +518,7 @@ "experimental": "YOLO (Fast & Accurate)" }, "default": "experimental", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kModelPath": { "category": "AI Detection", @@ -538,8 +529,7 @@ "type": "select", "options": {}, "default": "/etc/pitrac/models/yolo26-ball-detector", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kImageTypeToProcessWithYOLO": { "category": "Ball Detection", @@ -553,8 +543,7 @@ "1": "YOLO will process monochrome images (and convert as necessary)" }, "default": "0", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1Gain": { "category": "Cameras", @@ -567,8 +556,7 @@ "max": 16.0, "step": 0.1, "default": 6.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2Gain": { "category": "Cameras", @@ -584,64 +572,57 @@ "max": 16.0, "step": 0.1, "default": 3.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1Saturation": { "category": "Cameras", "subcategory": "advanced", "advancedSubcategory": "Cameras", "displayName": "Camera 1 Saturation", - "description": "Brightness/gain setting for Camera 1", + "description": "Color saturation for camera 1. 1.0 is neutral.", "type": "float", "min": 0.0, "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2Saturation": { "category": "Cameras", "subcategory": "advanced", "advancedSubcategory": "Cameras", "displayName": "Camera 2 Saturation", - "description": "Brightness/gain setting for Camera 2", + "description": "Color saturation for camera 2. 1.0 is neutral.", "type": "float", "min": 0.0, "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1SearchCenterX": { "category": "Cameras", "subcategory": "advanced", "displayName": "Camera 1 Search Center X", "description": "X coordinate for ball search center in Camera 1 (calibration parameter)", - "type": "number", + "type": "integer", "min": 0, "max": 1920, "step": 10, "default": 850, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--search_center_x" + "requiresRestart": true }, "gs_config.cameras.kCamera1SearchCenterY": { "category": "Cameras", "subcategory": "advanced", "displayName": "Camera 1 Search Center Y", "description": "Y coordinate for ball search center in Camera 1 (calibration parameter)", - "type": "number", + "type": "integer", "min": 0, "max": 1080, "step": 10, "default": 500, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--search_center_y" + "requiresRestart": true }, "gs_config.cameras.kHLAOffset": { "category": "Cameras", @@ -654,8 +635,7 @@ "max": 45.0, "step": 1.0, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.cameras.kVLAOffset": { "category": "Cameras", @@ -668,58 +648,7 @@ "max": 45.0, "step": 1.0, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.golf_simulator_interfaces.E6.kE6ConnectAddress": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "E6 Connect Address", - "description": "IP address of E6 Connect simulator", - "type": "ip_address", - "default": "", - "requiresRestart": false, - "passedVia": "cli", - "cliArgument": "--e6_host_address" - }, - "gs_config.golf_simulator_interfaces.E6.kE6ConnectPort": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "E6 Connect Port", - "description": "Port number for E6 Connect simulator", - "type": "number", - "min": 1, - "max": 65535, - "default": 2483, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectAddress": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "GSPro Address", - "description": "IP address of GSPro simulator (e.g., 192.168.1.100)", - "type": "ip_address", - "default": "", - "requiresRestart": false, - "passedVia": "cli", - "cliArgument": "--gspro_host_address" - }, - "gs_config.golf_simulator_interfaces.GSPro.kGSProConnectPort": { - "category": "Simulators", - "subcategory": "basic", - "basicSubcategory": "Simulators", - "displayName": "GSPro Port", - "description": "Port number for GSPro simulator", - "type": "number", - "min": 1, - "max": 65535, - "default": 921, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseCLAHEProcessing": { "category": "Ball Detection", @@ -729,39 +658,51 @@ "description": "Enable Contrast Limited Adaptive Histogram Equalization for better ball detection", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kCLAHEClipLimit": { "category": "Ball Detection", - "subcategory": "basic", - "basicSubcategory": "Ball Detection", + "subcategory": "advanced", "displayName": "CLAHE Clip Limit", "description": "Contrast enhancement strength (1-40)", - "type": "number", + "type": "integer", "min": 1, "max": 40, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kExpectedBallRadiusPixelsAt40cm": { "category": "Ball Detection", - "subcategory": "basic", - "basicSubcategory": "Ball Detection", + "subcategory": "advanced", "displayName": "Expected Ball Radius (pixels)", "description": "Expected ball size at 40cm distance - critical for distance calculations", "type": "number", "min": 10, "max": 200, "default": 87, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false + }, + "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera1": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "Camera 1 Expected Ball Radius at 40cm (px)", + "description": "Expected ball radius in pixels at 40 cm for camera 1. A value below 1 uses the camera model's built-in value.", + "type": "integer", + "default": -1, + "requiresRestart": true + }, + "gs_config.cameras.kExpectedBallRadiusPixelsAt40cmCamera2": { + "category": "Cameras", + "subcategory": "advanced", + "displayName": "Camera 2 Expected Ball Radius at 40cm (px)", + "description": "Expected ball radius in pixels at 40 cm for camera 2. A value below 1 uses the camera model's built-in value.", + "type": "integer", + "default": -1, + "requiresRestart": true }, "gs_config.strobing.kStandardBallSpeedSlowdownPercentage": { "category": "Strobing", - "subcategory": "basic", - "basicSubcategory": "Strobing", + "subcategory": "advanced", "displayName": "Standard Ball Speed Adjustment", "description": "Speed adjustment for standard golf balls (%)", "type": "number", @@ -769,13 +710,11 @@ "max": 10, "step": 0.1, "default": 0.1, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kPracticeBallSpeedSlowdownPercentage": { "category": "Strobing", - "subcategory": "basic", - "basicSubcategory": "Strobing", + "subcategory": "advanced", "displayName": "Practice Ball Speed Adjustment", "description": "Speed adjustment for practice/foam balls (%)", "type": "number", @@ -783,13 +722,11 @@ "max": 20, "step": 0.1, "default": 4.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kPuttingBallSpeedSlowdownPercentage": { "category": "Strobing", - "subcategory": "basic", - "basicSubcategory": "Strobing", + "subcategory": "advanced", "displayName": "Putting Speed Adjustment", "description": "Speed adjustment in putting mode (%)", "type": "number", @@ -797,44 +734,40 @@ "max": 20, "step": 0.1, "default": 5.2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.number_bits_for_fast_on_pulse_": { "category": "Strobing", "subcategory": "advanced", "displayName": "Fast Strobe Pulse Width (bits)", "description": "Number of bits used for fast strobe on-pulse width. Controls the duration of the fast strobe pulse.", - "type": "number", + "type": "integer", "min": 1, "max": 16, "default": 1, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.number_bits_for_slow_on_pulse_": { "category": "Strobing", "subcategory": "advanced", "displayName": "Slow Strobe Pulse Width (bits)", "description": "Number of bits used for slow strobe on-pulse width. Controls the duration of the slow strobe pulse.", - "type": "number", + "type": "integer", "min": 1, "max": 16, "default": 1, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPrimingPulseFPS": { "category": "Strobing", "subcategory": "advanced", "displayName": "Priming Pulse FPS", "description": "Frame rate for priming pulses sent to camera before capture. Used to stabilize camera exposure.", - "type": "number", + "type": "integer", "min": 5, "max": 60, "default": 15, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kCameraRequiresFlushPulse": { "category": "Strobing", @@ -843,64 +776,61 @@ "description": "Send an extra trigger pulse after the shutter pulse to flush the ball-hit frame out of the sensor. Required for some IMX296 boards (e.g. Pi GS) where one trigger only advances the capture FSM. Leave off for InnoMaker — it produces the second frame event natively.", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.logging.kLogIntermediateExposureImagesToFile": { "category": "Logging", - "subcategory": "basic", - "basicSubcategory": "Storage", + "subcategory": "advanced", "displayName": "Log Exposure Images", "description": "Save intermediate exposure images for debugging", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLogIntermediateSpinImagesToFile": { "category": "Logging", - "subcategory": "basic", - "basicSubcategory": "Storage", + "subcategory": "advanced", "displayName": "Log Spin Images", "description": "Save spin analysis images", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLogWebserverImagesToFile": { "category": "Logging", - "subcategory": "basic", - "basicSubcategory": "Storage", + "subcategory": "advanced", "displayName": "Log Web Server Images", "description": "Save images for web server display", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLogDiagnosticImagesToUniqueFiles": { "category": "Logging", - "subcategory": "basic", - "basicSubcategory": "Storage", + "subcategory": "advanced", "displayName": "Use Unique Filenames", "description": "Save diagnostic images with unique names (vs overwriting)", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.logging.kLinuxBaseImageLoggingDir": { "category": "Storage", - "subcategory": "basic", - "basicSubcategory": "Storage", + "subcategory": "advanced", "displayName": "Image Directory", "description": "Base directory for saving images", "type": "path", "default": "~/LM_Shares/Images/", - "requiresRestart": false, - "passedVia": "cli", - "cliArgument": "--base_image_logging_dir" + "requiresRestart": false + }, + "gs_config.logging.kPCBaseImageLoggingDir": { + "category": "Logging", + "subcategory": "advanced", + "displayName": "PC Image Logging Directory", + "description": "Base image logging directory on Windows builds. Linux builds use Image Directory (kLinuxBaseImageLoggingDir).", + "type": "string", + "default": "", + "requiresRestart": true }, "gs_config.ipc_interface.kWebServerShareDirectory": { "category": "Network", @@ -910,32 +840,7 @@ "description": "Directory for web server shared files", "type": "path", "default": "~/LM_Shares/Images/", - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.ipc_interface.kWebServerTomcatShareDirectory": { - "category": "Network", - "subcategory": "basic", - "basicSubcategory": "Storage", - "displayName": "Tomcat Share Directory", - "description": "Directory for Tomcat web server files", - "type": "path", - "default": "~/LM_Shares/WebShare", - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.ipc_interface.kRefreshTimeSeconds": { - "category": "Network", - "subcategory": "basic", - "basicSubcategory": "Network", - "displayName": "Web Refresh Interval", - "description": "Web interface refresh interval in seconds", - "type": "number", - "min": 1, - "max": 60, - "default": 3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.golf_simulator_interfaces.kSkipSpinCalculation": { "category": "Spin Analysis", @@ -945,19 +850,7 @@ "description": "Disable spin calculation (for putting-only or debugging)", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.golf_simulator_interfaces.kWriteSpinAnalysisCsvFiles": { - "category": "Spin Analysis", - "subcategory": "basic", - "basicSubcategory": "Spin", - "displayName": "Write Spin CSV Files", - "description": "Save spin analysis data to CSV files", - "type": "boolean", - "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kModelConfidenceThreshold": { "category": "AI Detection", @@ -969,8 +862,7 @@ "max": 1.0, "step": 0.05, "default": 0.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kModelNMSThreshold": { "category": "AI Detection", @@ -982,47 +874,43 @@ "max": 1.0, "step": 0.05, "default": 0.4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kModelInputWidth": { "category": "AI Detection", "subcategory": "advanced", "displayName": "Model Input Width", "description": "Neural network input width in pixels (must match exported model, multiple of 32)", - "type": "number", + "type": "integer", "min": 320, "max": 2048, "step": 32, "default": 736, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kModelInputHeight": { "category": "AI Detection", "subcategory": "advanced", "displayName": "Model Input Height", "description": "Neural network input height in pixels (must match exported model, multiple of 32)", - "type": "number", + "type": "integer", "min": 320, "max": 2048, "step": 32, "default": 544, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kInferenceThreads": { "category": "AI Detection", "subcategory": "advanced", "displayName": "Inference Threads", "description": "Number of threads for inference (0 = auto)", - "type": "number", + "type": "integer", "min": 0, "max": 8, "step": 1, "default": 4, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_identification.kStrobedBallsCannyLower": { "category": "Ball Detection", @@ -1033,8 +921,7 @@ "min": 0, "max": 255, "default": 33, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsCannyUpper": { "category": "Ball Detection", @@ -1045,50 +932,18 @@ "min": 0, "max": 255, "default": 90, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kCLAHETilesGridSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "CLAHE Tiles Grid Size", "description": "Grid size for CLAHE tiles", - "type": "number", + "type": "integer", "min": 2, "max": 16, "default": 6, - "requiresRestart": false, - "passedVia": "json" - }, - "cli.system_mode": { - "category": "System", - "subcategory": "advanced", - "displayName": "CLI System Mode", - "description": "System mode passed via command line", - "type": "select", - "options": { - "camera1": "Normal Mode", - "test_gspro_server": "Test GSPro Server", - "test_images": "Test Images", - "test_spin": "Test Spin" - }, - "default": "camera1", - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--system_mode", - "internal": true - }, - "cli.run_single_pi": { - "category": "System", - "subcategory": "advanced", - "displayName": "Run Single Pi Mode (CLI)", - "description": "Run both cameras on a single Pi (overrides system.mode setting)", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--run_single_pi", - "internal": true + "requiresRestart": false }, "gs_config.cameras.kCamera1HighFPSGain": { "category": "Cameras", @@ -1100,8 +955,7 @@ "max": 20.0, "step": 0.1, "default": 15.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1Contrast": { "category": "Cameras", @@ -1113,8 +967,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2Contrast": { "category": "Cameras", @@ -1126,8 +979,7 @@ "max": 2.0, "step": 0.1, "default": 1.2, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2ComparisonGain": { "category": "Cameras", @@ -1139,8 +991,7 @@ "max": 16.0, "step": 0.1, "default": 0.8, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2CalibrateOrLocationGain": { "category": "Cameras", @@ -1152,8 +1003,7 @@ "max": 16.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2PuttingGain": { "category": "Cameras", @@ -1165,8 +1015,7 @@ "max": 16.0, "step": 0.1, "default": 4.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2PuttingContrast": { "category": "Cameras", @@ -1178,32 +1027,29 @@ "max": 2.0, "step": 0.1, "default": 1.2, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1StillShutterTimeuS": { "category": "Cameras", "subcategory": "advanced", "displayName": "Camera 1 Still Shutter Time (\u03bcs)", "description": "Shutter time for Camera 1 still images in microseconds", - "type": "number", + "type": "integer", "min": 1000, "max": 100000, "default": 40000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2StillShutterTimeuS": { "category": "Cameras", "subcategory": "advanced", "displayName": "Camera 2 Still Shutter Time (\u03bcs)", "description": "Shutter time for Camera 2 still images in microseconds", - "type": "number", + "type": "integer", "min": 1000, "max": 100000, "default": 15000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.physical_constants.kBallRadiusMeters": { "category": "System", @@ -1216,8 +1062,7 @@ "step": 1e-06, "default": 0.021335, "requiresRestart": false, - "internal": true, - "passedVia": "json" + "internal": true }, "gs_config.ball_position.kMinMovedBallRadiusRatio": { "category": "Ball Detection", @@ -1229,8 +1074,7 @@ "max": 1.0, "step": 0.05, "default": 0.6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMaxMovedBallRadiusRatio": { "category": "Ball Detection", @@ -1242,8 +1086,7 @@ "max": 2.0, "step": 0.05, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMinRadiusRatio": { "category": "Ball Detection", @@ -1255,8 +1098,7 @@ "max": 1.0, "step": 0.05, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMaxRadiusRatio": { "category": "Ball Detection", @@ -1268,32 +1110,29 @@ "max": 2.5, "step": 0.05, "default": 1.7, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMinRadiusOffset": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Min Radius Offset", "description": "Minimum radius offset in pixels", - "type": "number", + "type": "integer", "min": 0, "max": 50, "default": 20, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kMaxRadiusOffset": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Max Radius Offset", "description": "Maximum radius offset in pixels", - "type": "number", + "type": "integer", "min": 0, "max": 50, "default": 20, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_position.kTeedBallSearchAreaMaskRadiusRatio": { "category": "Ball Detection", @@ -1305,44 +1144,29 @@ "max": 2.0, "step": 0.1, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.ball_position.kMinBallRadiusPixelsForProximityWarning": { - "category": "Ball Detection", - "subcategory": "advanced", - "displayName": "Min Ball Radius for Proximity Warning", - "description": "Minimum ball radius in pixels to trigger proximity warning", - "type": "number", - "min": 50, - "max": 300, - "default": 160, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kBaudRateForFastPulses": { "category": "Strobing", "subcategory": "advanced", "displayName": "Baud Rate for Fast Pulses", "description": "Serial baud rate for fast strobe pulses", - "type": "number", + "type": "integer", "min": 9600, "max": 921600, "default": 115200, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kBaudRateForSlowPulses": { "category": "Strobing", "subcategory": "advanced", "displayName": "Baud Rate for Slow Pulses", "description": "Serial baud rate for slow strobe pulses", - "type": "number", + "type": "integer", "min": 9600, "max": 921600, "default": 115200, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kBaudRatePulseMultiplier": { "category": "Strobing", @@ -1354,140 +1178,117 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kCam2SetupPeriodMilliseconds": { "category": "Strobing", "subcategory": "advanced", "displayName": "Camera 2 Setup Period (ms)", "description": "Setup period for Camera 2 in milliseconds", - "type": "number", + "type": "integer", "min": 500, "max": 5000, "default": 2000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kNumberPrimingPulses": { "category": "Strobing", "subcategory": "advanced", "displayName": "Number of Priming Pulses", "description": "Number of priming pulses before capture", - "type": "number", + "type": "integer", "min": 1, "max": 30, "default": 12, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPuttingStrobeDelayMs": { "category": "Strobing", "subcategory": "advanced", "displayName": "Putting Strobe Delay (ms)", "description": "Strobe delay for putting mode in milliseconds", - "type": "number", + "type": "integer", "min": 10, "max": 200, "default": 50, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kPauseBeforeReadyForFinalPrimingPulseMs": { "category": "Strobing", "subcategory": "advanced", "displayName": "Pause Before Final Priming Pulse (ms)", "description": "Pause before ready for final priming pulse in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 1000, "default": 400, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseBeforeSendingPreImageTriggerMs": { "category": "Strobing", "subcategory": "advanced", "displayName": "Pause Before Pre-Image Trigger (ms)", "description": "Pause before sending pre-image trigger in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 1000, "default": 300, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseAfterSendingPreImageTriggerMs": { "category": "Strobing", "subcategory": "advanced", "displayName": "Pause After Pre-Image Trigger (ms)", "description": "Pause after sending pre-image trigger in milliseconds", - "type": "number", + "type": "integer", "min": 500, "max": 5000, "default": 2000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseBeforeSendingImageFlushMs": { "category": "Strobing", "subcategory": "advanced", "displayName": "Pause Before Image Flush (ms)", "description": "Pause before sending image flush in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 1000, "default": 300, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.strobing.kPauseBeforeSendingFinalImageTriggerMs": { - "category": "Strobing", - "subcategory": "advanced", - "displayName": "Pause Before Final Image Trigger (ms)", - "description": "Pause before sending final image trigger in milliseconds", - "type": "number", - "min": 500, - "max": 5000, - "default": 2000, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kPauseToSetUpInnoMakerExternalTriggerMilliseconds": { "category": "Strobing", "subcategory": "advanced", "displayName": "InnoMaker External Trigger Setup (ms)", "description": "Pause to set up InnoMaker external trigger in milliseconds", - "type": "number", + "type": "integer", "min": 100, "max": 2000, "default": 500, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.image_capture.kMaxWatchingCropWidth": { "category": "Cameras", "subcategory": "advanced", "displayName": "Max Watching Crop Width", "description": "Maximum width for watching crop area in pixels", - "type": "number", + "type": "integer", "min": 32, "max": 256, "default": 96, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.image_capture.kMaxWatchingCropHeight": { "category": "Cameras", "subcategory": "advanced", "displayName": "Max Watching Crop Height", "description": "Maximum height for watching crop area in pixels", - "type": "number", + "type": "integer", "min": 32, "max": 256, "default": 88, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.spin_analysis.kSpinDetectionMethod": { "category": "Spin Analysis", @@ -1501,152 +1302,139 @@ "legacy": "Rotation Search (Legacy)" }, "default": "ml", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.spin_analysis.kCoarseSearchResolution": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Search Resolution", "description": "Resolution (pixels) for downsampled dimple images during coarse spin search. Lower is faster, higher preserves more detail.", - "type": "number", + "type": "integer", "min": 45, "max": 120, "default": 90, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kGaborMaxWhitePercent": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Gabor Max White Percent", "description": "Maximum white percentage for Gabor filter", - "type": "number", + "type": "integer", "min": 30, "max": 60, "default": 45, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kGaborMinWhitePercent": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Gabor Min White Percent", "description": "Minimum white percentage for Gabor filter", - "type": "number", + "type": "integer", "min": 20, "max": 50, "default": 39, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseXRotationDegreesIncrement": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse X Rotation Increment", "description": "Increment for coarse X rotation in degrees", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseXRotationDegreesStart": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse X Rotation Start", "description": "Starting angle for coarse X rotation in degrees", - "type": "number", + "type": "integer", "min": -90, "max": 0, "default": -36, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseXRotationDegreesEnd": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse X Rotation End", "description": "Ending angle for coarse X rotation in degrees", - "type": "number", + "type": "integer", "min": 0, "max": 90, "default": 36, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseYRotationDegreesIncrement": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Y Rotation Increment", "description": "Increment for coarse Y rotation in degrees", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseYRotationDegreesStart": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Y Rotation Start", "description": "Starting angle for coarse Y rotation in degrees", - "type": "number", + "type": "integer", "min": -45, "max": 0, "default": -15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseYRotationDegreesEnd": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Y Rotation End", "description": "Ending angle for coarse Y rotation in degrees", - "type": "number", + "type": "integer", "min": 0, "max": 45, "default": 15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseZRotationDegreesIncrement": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Z Rotation Increment", "description": "Increment for coarse Z rotation in degrees", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseZRotationDegreesStart": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Z Rotation Start", "description": "Starting angle for coarse Z rotation in degrees", - "type": "number", + "type": "integer", "min": -90, "max": 0, "default": -10, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kCoarseZRotationDegreesEnd": { "category": "Spin Analysis", "subcategory": "advanced", "displayName": "Coarse Z Rotation End", "description": "Ending angle for coarse Z rotation in degrees", - "type": "number", + "type": "integer", "min": 0, "max": 180, "default": 110, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.spin_analysis.kSpinModelPath": { "category": "Spin Analysis", @@ -1656,8 +1444,7 @@ "type": "select", "options": {}, "default": "/etc/pitrac/models/spin-predictor", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.spin_analysis.kSpinMLZFallbackThreshold": { "category": "Spin Analysis", @@ -1668,42 +1455,18 @@ "min": 10, "max": 180, "default": 60, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ipc_interface.kMaxCam2ImageReceivedTimeMs": { "category": "Network", "subcategory": "advanced", "displayName": "Max Camera 2 Image Receive Time (ms)", "description": "Maximum time to wait for Camera 2 image in milliseconds", - "type": "number", + "type": "integer", "min": 1000, "max": 60000, "default": 40000, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.golf_simulator_interfaces.E6.kE6InterMessageDelayMs": { - "category": "Simulators", - "subcategory": "advanced", - "displayName": "E6 Inter-Message Delay (ms)", - "description": "Delay between E6 messages in milliseconds", - "type": "number", - "min": 0, - "max": 500, - "default": 50, - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.golf_simulator_interfaces.kLaunchMonitorIdString": { - "category": "Simulators", - "subcategory": "advanced", - "displayName": "Launch Monitor ID String", - "description": "Identification string sent to golf simulators", - "type": "text", - "default": "PiTrac LM 0.1", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": true }, "gs_config.club_data.kEnableClubImages": { "category": "Club Data", @@ -1712,66 +1475,51 @@ "description": "Enable capturing club images", "type": "boolean", "default": false, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.club_data.kClubImageOutputDir": { - "category": "Storage", - "subcategory": "advanced", - "displayName": "Club Image Output Directory", - "description": "Directory for saving club images", - "type": "path", - "default": "~/LM_Shares/Images/", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": true }, "gs_config.club_data.kNumberFramesToSaveBeforeHit": { "category": "Club Data", "subcategory": "advanced", "displayName": "Frames Before Hit", "description": "Number of frames to save before club hits ball", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kNumberFramesToSaveAfterHit": { "category": "Club Data", "subcategory": "advanced", "displayName": "Frames After Hit", "description": "Number of frames to save after club hits ball", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageWidthPixels": { "category": "Club Data", "subcategory": "advanced", "displayName": "Club Image Width", "description": "Width of club images in pixels", - "type": "number", + "type": "integer", "min": 100, "max": 1920, "default": 340, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageHeightPixels": { "category": "Club Data", "subcategory": "advanced", "displayName": "Club Image Height", "description": "Height of club images in pixels", - "type": "number", + "type": "integer", "min": 100, "max": 1080, "default": 200, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageCameraGain": { "category": "Club Data", @@ -1782,8 +1530,7 @@ "min": 1, "max": 100, "default": 40, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.club_data.kClubImageShutterSpeedMultiplier": { "category": "Club Data", @@ -1795,87 +1542,29 @@ "max": 2.0, "step": 0.1, "default": 0.4, - "requiresRestart": false, - "passedVia": "json" - }, - "cli.cam_still_mode": { - "category": "Testing", - "displayName": "Camera Still Mode", - "description": "Take single still picture and exit", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--cam_still_mode", - "internal": true - }, - "cli.pulse_test": { - "category": "Testing", - "displayName": "Pulse Test", - "description": "Continuous strobe/shutter test", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--pulse_test", - "internal": true - }, - "cli.send_test_results": { - "category": "Testing", - "displayName": "Send Test Results", - "description": "Send test IPC message and exit", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--send_test_results", - "internal": true - }, - "cli.lm_comparison_mode": { - "category": "Testing", - "displayName": "LM Comparison Mode", - "description": "Configure for other IR launch monitor environment", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--lm_comparison_mode", - "internal": true - }, - "cli.skip_wait_armed": { - "category": "Testing", - "displayName": "Skip Wait Armed", - "description": "Skip simulator armed state wait", - "type": "boolean", - "default": false, - "requiresRestart": true, - "passedVia": "cli", - "cliArgument": "--skip_wait_armed", - "internal": true + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMinHoughReturnCircles": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Strobed Balls Min Hough Circles", "description": "Minimum number of circles to return from HoughCircles for strobed balls", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMaxHoughReturnCircles": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Strobed Balls Max Hough Circles", "description": "Maximum number of circles to return from HoughCircles for strobed balls", - "type": "number", + "type": "integer", "min": 5, "max": 50, "default": 20, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMinParam2": { "category": "Ball Detection", @@ -1886,8 +1575,7 @@ "min": 1, "max": 200, "default": 18, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsMaxParam2": { "category": "Ball Detection", @@ -1898,8 +1586,7 @@ "min": 10, "max": 300, "default": 140, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsStartingParam2": { "category": "Ball Detection", @@ -1910,8 +1597,7 @@ "min": 10, "max": 150, "default": 60, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsParam2Increment": { "category": "Ball Detection", @@ -1922,8 +1608,7 @@ "min": 1, "max": 20, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsCurrentParam1": { "category": "Ball Detection", @@ -1934,8 +1619,7 @@ "min": 50, "max": 300, "default": 120, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsHoughDpParam1": { "category": "Ball Detection", @@ -1947,34 +1631,31 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsPreCannyBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Strobed Pre-Canny Blur Size", "description": "Blur kernel size before Canny edge detection", - "type": "number", + "type": "integer", "min": 1, "max": 15, "step": 2, "default": 3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsPreHoughBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Strobed Pre-Hough Blur Size", "description": "Blur kernel size before HoughCircles (must be odd)", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, "default": 13, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsUseAltHoughAlgorithm": { "category": "Ball Detection", @@ -1983,8 +1664,7 @@ "description": "Use alternative HoughCircles algorithm for strobed balls", "type": "boolean", "default": true, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallMinParam2": { "category": "Ball Detection", @@ -1996,8 +1676,7 @@ "max": 2.0, "step": 0.1, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallMaxParam2": { "category": "Ball Detection", @@ -2009,8 +1688,7 @@ "max": 2.0, "step": 0.1, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallStartingParam2": { "category": "Ball Detection", @@ -2022,8 +1700,7 @@ "max": 1.5, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallParam2Increment": { "category": "Ball Detection", @@ -2035,8 +1712,7 @@ "max": 0.1, "step": 0.01, "default": 0.03, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingBallCurrentParam1": { "category": "Ball Detection", @@ -2047,32 +1723,29 @@ "min": 100, "max": 500, "default": 300, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingMinHoughReturnCircles": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Putting Min Hough Circles", "description": "Minimum circles to return in putting mode", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingMaxHoughReturnCircles": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Putting Max Hough Circles", "description": "Maximum circles to return in putting mode", - "type": "number", + "type": "integer", "min": 10, "max": 50, "default": 25, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingHoughDpParam1": { "category": "Ball Detection", @@ -2084,21 +1757,19 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPuttingPreHoughBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Putting Pre-Hough Blur Size", "description": "Blur kernel size before HoughCircles in putting mode", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, "default": 9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.cameras.kCamera1FocalLength": { "category": "Calibration", @@ -2110,8 +1781,7 @@ "max": 10.0, "step": 0.001, "default": 5.868, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2FocalLength": { "category": "Calibration", @@ -2123,32 +1793,29 @@ "max": 10.0, "step": 0.001, "default": 5.511, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.ball_exposure_selection.kNumberHighQualityBallsToRetain": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "High Quality Balls to Retain", "description": "Number of high quality ball detections to keep", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaximumOffTrajectoryDistance": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Max Off-Trajectory Distance", "description": "Maximum allowed distance from expected trajectory", - "type": "number", + "type": "integer", "min": 1, "max": 50, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxDistanceFromTrajectory": { "category": "Ball Detection", @@ -2159,8 +1826,7 @@ "min": 10, "max": 100, "default": 30, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxStrobedBallColorDifferenceRelaxed": { "category": "Ball Detection", @@ -2171,8 +1837,7 @@ "min": 10000, "max": 100000, "default": 70000, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxPuttingBallColorDifferenceRelaxed": { "category": "Ball Detection", @@ -2183,8 +1848,7 @@ "min": 10000, "max": 100000, "default": 40000, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxStrobedBallColorDifferenceStrict": { "category": "Ball Detection", @@ -2195,8 +1859,7 @@ "min": 10000, "max": 100000, "default": 30000, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kUseOnlyHighQualityBallImagesForHLA": { "category": "Ball Detection", @@ -2205,8 +1868,7 @@ "description": "Because earlier-in-time balls have more overlap and lower radius accuracy, this allows for some of those balls to be ignored for HLA calculations.", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kDifferenceM": { "category": "Ball Detection", @@ -2218,8 +1880,7 @@ "max": 2.0, "step": 0.1, "default": 0.6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kDifferenceC": { "category": "Ball Detection", @@ -2231,8 +1892,7 @@ "max": 10.0, "step": 0.5, "default": 3.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kRegionThreshold": { "category": "Ball Detection", @@ -2244,8 +1904,7 @@ "max": 0.2, "step": 0.01, "default": 0.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.strobing.kStrobePulseVectorDriver": { "category": "Strobing", @@ -2263,8 +1922,7 @@ 4.0, 0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kStrobePulseVectorPutter": { "category": "Strobing", @@ -2280,8 +1938,7 @@ 50.0, 0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.user_interface.kWebServerResultBallExposureCandidates": { "category": "Display", @@ -2290,8 +1947,7 @@ "description": "Image filename for ball exposure candidates", "type": "text", "default": "ball_exposure_candidates", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerResultSpinBall1Image": { "category": "Display", @@ -2300,8 +1956,7 @@ "description": "Image filename for spin ball 1", "type": "text", "default": "spin_ball_1_gray_image1", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerResultSpinBall2Image": { "category": "Display", @@ -2310,8 +1965,7 @@ "description": "Image filename for spin ball 2", "type": "text", "default": "spin_ball_2_gray_image1", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerResultBallRotatedByBestAngles": { "category": "Display", @@ -2320,8 +1974,7 @@ "description": "Image filename for ball rotated by best angles", "type": "text", "default": "ball1_rotated_by_best_angles", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerCamera2Image": { "category": "Display", @@ -2330,8 +1983,7 @@ "description": "Image filename for camera 2", "type": "text", "default": "log_cam2_last_strobed_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerLastTeedBallImage": { "category": "Display", @@ -2340,8 +1992,7 @@ "description": "Image filename for last teed ball", "type": "text", "default": "log_ball_final_found_ball_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerErrorExposuresImage": { "category": "Display", @@ -2350,8 +2001,7 @@ "description": "Image filename for error exposures", "type": "text", "default": "log_cam2_last_strobed_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.user_interface.kWebServerBallSearchAreaImage": { "category": "Display", @@ -2360,8 +2010,7 @@ "description": "Image filename for ball search area", "type": "text", "default": "log_cam1_search_area_img", - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltCannyLower": { "category": "Ball Detection", @@ -2372,8 +2021,7 @@ "min": 10, "max": 100, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltCannyUpper": { "category": "Ball Detection", @@ -2384,8 +2032,7 @@ "min": 30, "max": 150, "default": 70, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltCurrentParam1": { "category": "Ball Detection", @@ -2396,8 +2043,7 @@ "min": 50, "max": 300, "default": 130, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltHoughDpParam1": { "category": "Ball Detection", @@ -2409,8 +2055,7 @@ "max": 2.0, "step": 0.1, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltMinParam2": { "category": "Ball Detection", @@ -2422,8 +2067,7 @@ "max": 1.5, "step": 0.05, "default": 0.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltMaxParam2": { "category": "Ball Detection", @@ -2435,8 +2079,7 @@ "max": 2.0, "step": 0.05, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltStartingParam2": { "category": "Ball Detection", @@ -2448,8 +2091,7 @@ "max": 1.0, "step": 0.05, "default": 0.65, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltParam2Increment": { "category": "Ball Detection", @@ -2461,34 +2103,31 @@ "max": 0.2, "step": 0.01, "default": 0.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltPreCannyBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Alt Algorithm Pre-Canny Blur", "description": "Blur size before Canny edge detection in alternative algorithm", - "type": "number", + "type": "integer", "min": 1, "max": 21, "step": 2, "default": 9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Alt Algorithm Pre-Hough Blur", "description": "Blur size before HoughCircles in alternative algorithm (must be odd)", - "type": "number", + "type": "integer", "min": 1, "max": 31, "step": 2, "default": 15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallCannyLower": { "category": "Ball Detection", @@ -2499,8 +2138,7 @@ "min": 10, "max": 100, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallCannyUpper": { "category": "Ball Detection", @@ -2511,8 +2149,7 @@ "min": 30, "max": 150, "default": 80, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallMinParam2": { "category": "Ball Detection", @@ -2524,8 +2161,7 @@ "max": 1.5, "step": 0.05, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallMaxParam2": { "category": "Ball Detection", @@ -2537,8 +2173,7 @@ "max": 1.5, "step": 0.05, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallStartingParam2": { "category": "Ball Detection", @@ -2550,8 +2185,7 @@ "max": 1.2, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallParam2Increment": { "category": "Ball Detection", @@ -2563,8 +2197,7 @@ "max": 0.1, "step": 0.01, "default": 0.03, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallCurrentParam1": { "category": "Ball Detection", @@ -2575,8 +2208,7 @@ "min": 50, "max": 300, "default": 130, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallHoughDpParam1": { "category": "Ball Detection", @@ -2588,58 +2220,53 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedPreCannyBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Placed Ball Pre-Canny Blur", "description": "Blur size before Canny for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 21, "step": 2, "default": 11, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedPreHoughBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Placed Ball Pre-Hough Blur", "description": "Blur size before HoughCircles for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, "default": 13, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedMinHoughReturnCircles": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Placed Ball Min Circles", "description": "Minimum circles to return for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 1, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedMaxHoughReturnCircles": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Placed Ball Max Circles", "description": "Maximum circles to return for placed ball", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedBallUseLargestBall": { "category": "Ball Detection", @@ -2648,8 +2275,7 @@ "description": "Use the largest detected circle for placed ball", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseBestCircleRefinement": { "category": "Ball Detection", @@ -2658,8 +2284,7 @@ "description": "Enable best circle refinement algorithm", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseBestCircleLargestCircle": { "category": "Ball Detection", @@ -2668,8 +2293,7 @@ "description": "Use largest circle in best circle algorithm", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleCannyLower": { "category": "Ball Detection", @@ -2680,8 +2304,7 @@ "min": 20, "max": 100, "default": 55, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleCannyUpper": { "category": "Ball Detection", @@ -2692,34 +2315,31 @@ "min": 50, "max": 200, "default": 110, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCirclePreCannyBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Best Circle Pre-Canny Blur", "description": "Blur size before Canny in best circle algorithm", - "type": "number", + "type": "integer", "min": 1, "max": 15, "step": 2, "default": 5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCirclePreHoughBlurSize": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Best Circle Pre-Hough Blur", "description": "Blur size before HoughCircles in best circle algorithm", - "type": "number", + "type": "integer", "min": 1, "max": 25, "step": 2, "default": 13, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleParam1": { "category": "Ball Detection", @@ -2730,8 +2350,7 @@ "min": 100, "max": 500, "default": 300, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleParam2": { "category": "Ball Detection", @@ -2743,8 +2362,7 @@ "max": 1.0, "step": 0.05, "default": 0.65, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleHoughDpParam1": { "category": "Ball Detection", @@ -2756,8 +2374,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleIdentificationMinRadiusRatio": { "category": "Ball Detection", @@ -2769,8 +2386,7 @@ "max": 1.0, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kBestCircleIdentificationMaxRadiusRatio": { "category": "Ball Detection", @@ -2782,8 +2398,7 @@ "max": 2.0, "step": 0.05, "default": 1.2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kUseDynamicRadiiAdjustment": { "category": "Ball Detection", @@ -2792,20 +2407,18 @@ "description": "Enable dynamic adjustment of search radii", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kNumberRadiiToAverageForDynamicAdjustment": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Number Radii to Average", "description": "Number of radii to average for dynamic adjustment", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiMinRatio": { "category": "Ball Detection", @@ -2817,8 +2430,7 @@ "max": 1.0, "step": 0.05, "default": 0.7, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiMaxRatio": { "category": "Ball Detection", @@ -2830,8 +2442,7 @@ "max": 2.0, "step": 0.05, "default": 1.6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiDpParam": { "category": "Ball Detection", @@ -2843,8 +2454,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kStrobedNarrowingRadiiParam2": { "category": "Ball Detection", @@ -2856,8 +2466,7 @@ "max": 1.5, "step": 0.05, "default": 0.8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingRadiiMinRatio": { "category": "Ball Detection", @@ -2869,8 +2478,7 @@ "max": 1.0, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingRadiiMaxRatio": { "category": "Ball Detection", @@ -2882,8 +2490,7 @@ "max": 1.5, "step": 0.05, "default": 1.1, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingStartingParam2": { "category": "Ball Detection", @@ -2895,20 +2502,7 @@ "max": 1.2, "step": 0.05, "default": 0.9, - "requiresRestart": false, - "passedVia": "json" - }, - "gs_config.ball_identification.kPlacedNarrowingParam1": { - "category": "Ball Detection", - "subcategory": "advanced", - "displayName": "Placed Narrowing Param1", - "description": "Canny edge threshold for placed narrowing", - "type": "number", - "min": 100, - "max": 500, - "default": 300, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_identification.kPlacedNarrowingRadiiDpParam": { "category": "Ball Detection", @@ -2920,8 +2514,7 @@ "max": 3.0, "step": 0.1, "default": 1.5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kBallProximityMarginPercentRelaxed": { "category": "Ball Detection", @@ -2932,8 +2525,7 @@ "min": 30, "max": 100, "default": 65, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kBallProximityMarginPercentStrict": { "category": "Ball Detection", @@ -2944,20 +2536,18 @@ "min": 5, "max": 50, "default": 15, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kClosestBallPairEdgeBackoffPixels": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Ball Pair Edge Backoff", "description": "Edge backoff in pixels for closest ball pairs", - "type": "number", + "type": "integer", "min": 50, "max": 500, "default": 200, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceRgbPostMultiplierForDarker": { "category": "Ball Detection", @@ -2969,8 +2559,7 @@ "max": 10.0, "step": 0.5, "default": 4.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceRgbPostMultiplierForLighter": { "category": "Ball Detection", @@ -2982,8 +2571,7 @@ "max": 5.0, "step": 0.1, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceStdPostMultiplierForDarker": { "category": "Ball Detection", @@ -2995,8 +2583,7 @@ "max": 10.0, "step": 0.5, "default": 3.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kColorDifferenceStdPostMultiplierForLighter": { "category": "Ball Detection", @@ -3008,20 +2595,18 @@ "max": 10.0, "step": 0.5, "default": 5.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxBallsToRetain": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Max Balls to Retain", "description": "Maximum number of ball detections to keep", - "type": "number", + "type": "integer", "min": 10, "max": 100, "default": 30, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxIntermediateBallRadiusChangePercent": { "category": "Ball Detection", @@ -3032,8 +2617,7 @@ "min": 1, "max": 20, "default": 5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxPuttingIntermediateBallRadiusChangePercent": { "category": "Ball Detection", @@ -3044,8 +2628,7 @@ "min": 1, "max": 20, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxOverlappedBallRadiusChangeRatio": { "category": "Ball Detection", @@ -3057,8 +2640,7 @@ "max": 2.0, "step": 0.1, "default": 1.3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxRadiusDifferencePercentageFromBest": { "category": "Ball Detection", @@ -3069,8 +2651,7 @@ "min": 10, "max": 100, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kUnlikelyAngleMinimumDistancePixels": { "category": "Ball Detection", @@ -3081,8 +2662,7 @@ "min": 10, "max": 100, "default": 40, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3093,8 +2673,7 @@ "min": 20, "max": 60, "default": 35, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMinQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3105,8 +2684,7 @@ "min": -20, "max": 10, "default": -5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMaxPuttingQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3117,8 +2695,7 @@ "min": 0, "max": 20, "default": 8, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kMinPuttingQualityExposureLaunchAngle": { "category": "Ball Detection", @@ -3129,20 +2706,18 @@ "min": -10, "max": 5, "default": -5, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kNumberAngleCheckExposures": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Number Angle Check Exposures", "description": "Number of exposures to check for angle validation", - "type": "number", + "type": "integer", "min": 2, "max": 10, "default": 4, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kUsePreImageSubtraction": { "category": "Ball Detection", @@ -3151,8 +2726,7 @@ "description": "Enable pre-image subtraction for better detection", "type": "boolean", "default": false, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingOverall": { "category": "Ball Detection", @@ -3164,8 +2738,7 @@ "max": 1.0, "step": 0.05, "default": 0.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingRed": { "category": "Ball Detection", @@ -3177,8 +2750,7 @@ "max": 2.0, "step": 0.05, "default": 1.0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingGreen": { "category": "Ball Detection", @@ -3190,8 +2762,7 @@ "max": 2.0, "step": 0.05, "default": 1.2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.ball_exposure_selection.kPreImageWeightingBlue": { "category": "Ball Detection", @@ -3203,8 +2774,7 @@ "max": 2.0, "step": 0.05, "default": 1.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kMaxRegionThreshold": { "category": "Ball Detection", @@ -3216,68 +2786,62 @@ "max": 0.2, "step": 0.01, "default": 0.05, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kFramePeriod": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Motion Frame Period", "description": "Frame period for motion detection", - "type": "number", + "type": "integer", "min": 0, "max": 10, "default": 0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kHSkip": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Motion H Skip", "description": "Horizontal skip for motion detection", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kVSkip": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Motion V Skip", "description": "Vertical skip for motion detection", - "type": "number", + "type": "integer", "min": 1, "max": 10, "default": 2, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kCroppedImagePixelOffsetLeft": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Cropped Image Offset Left", "description": "Left offset for cropped image in motion detection", - "type": "number", + "type": "integer", "min": -50, "max": 50, "default": 0, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.motion_detect_stage.kCroppedImagePixelOffsetUp": { "category": "Ball Detection", "subcategory": "advanced", "displayName": "Cropped Image Offset Up", "description": "Up offset for cropped image in motion detection", - "type": "number", + "type": "integer", "min": -50, "max": 50, "default": -3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.cameras.kCamera1XOffsetForTilt": { "category": "Calibration", @@ -3288,8 +2852,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera1YOffsetForTilt": { "category": "Calibration", @@ -3300,8 +2863,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2XOffsetForTilt": { "category": "Calibration", @@ -3312,8 +2874,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.cameras.kCamera2YOffsetForTilt": { "category": "Calibration", @@ -3324,44 +2885,7 @@ "min": -100, "max": 100, "default": 0, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.strobing.kLastPulsePutterRepeats": { - "category": "Strobing", - "subcategory": "advanced", - "displayName": "Last Pulse Putter Repeats", - "description": "Number of times to repeat last pulse in putting mode", - "type": "number", - "min": 0, - "max": 10, - "default": 0, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.strobing.kLongerStrobePulseVectorDriver": { - "category": "Strobing", - "subcategory": "advanced", - "displayName": "Longer Strobe Pulse Vector Driver", - "description": "Extended strobe timing vector for driver shots", - "type": "array", - "default": [ - 0.175, - 0.7, - 1.4, - 2.45, - 1.26, - 2.8, - 2.1, - 3.15, - 3.85, - 3.85, - 10.4, - 3.5, - 0 - ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.strobing.kDynamicFollowOnPulseVectorPutter": { "category": "Strobing", @@ -3372,44 +2896,29 @@ "default": [ 444.0 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kNumberPicturesForFocalLengthAverage": { "category": "Calibration", "subcategory": "advanced", "displayName": "Pictures for Focal Length Average", "description": "Number of pictures to average for focal length calibration", - "type": "number", + "type": "integer", "min": 1, "max": 20, "default": 6, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": false }, "gs_config.calibration.kNumberOfCalibrationFailuresToTolerate": { "category": "Calibration", "subcategory": "advanced", "displayName": "Number Of Calibration Failures To Tolerate", "description": "Allows a few ball-ID failures to be skipped over and re-tried.", - "type": "number", + "type": "integer", "min": 0, "max": 20, "default": 4, - "requiresRestart": true, - "passedVia": "json" - }, - "gs_config.user_interface.kRefreshTimeSeconds": { - "category": "Network", - "subcategory": "advanced", - "displayName": "UI Refresh Time", - "description": "User interface refresh interval in seconds", - "type": "number", - "min": 1, - "max": 30, - "default": 3, - "requiresRestart": false, - "passedVia": "json" + "requiresRestart": true }, "gs_config.testing.kAutomatedTestExpectedResultsCSV": { "type": "string", @@ -3440,14 +2949,14 @@ "ui_name": "Backspin Tolerance" }, "gs_config.testing.kAutomatedTestToleranceBallSpeedMPH": { - "type": "integer", + "type": "number", "default": 4, "description": "Ball speed tolerance for automated testing (MPH)", "category": "testing", "ui_name": "Ball Speed Tolerance" }, "gs_config.testing.kAutomatedTestToleranceHLA": { - "type": "integer", + "type": "number", "default": 3, "description": "Horizontal launch angle tolerance for automated testing (degrees)", "category": "testing", @@ -3461,7 +2970,7 @@ "ui_name": "Sidespin Tolerance" }, "gs_config.testing.kAutomatedTestToleranceVLA": { - "type": "integer", + "type": "number", "default": 2, "description": "Vertical launch angle tolerance for automated testing (degrees)", "category": "testing", @@ -3474,61 +2983,347 @@ "category": "testing", "ui_name": "Test Image Directory" }, - "gs_config.testing.kExternallyStrobedALTBestCircleCannyLower": { + "gs_config.testing.kExternallyStrobedBestCircleHoughDpParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Hough DP", + "description": "Hough dp for best-circle detection with external strobes", + "type": "number", + "default": 1.5, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Param1", + "description": "Hough param1 for best-circle detection with external strobes", + "type": "number", + "default": 120.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCircleParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Param2", + "description": "Hough param2 for best-circle detection with external strobes", + "type": "number", + "default": 35.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedBestCirclePreHoughBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Best Circle Pre-Hough Blur", + "description": "Pre-Hough blur size for best-circle detection with external strobes", "type": "integer", - "default": 35, - "description": "Canny lower threshold for externally strobed ALT best circle", + "default": 13, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedCLAHEClipLimit": { "category": "testing", - "ui_name": "External Strobe Canny Lower" + "subcategory": "advanced", + "displayName": "Ext Strobe CLAHE Clip Limit", + "description": "CLAHE clip limit with external strobes", + "type": "integer", + "default": 6, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedALTBestCircleCannyUpper": { + "gs_config.testing.kExternallyStrobedCLAHETilesGridSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe CLAHE Tile Grid Size", + "description": "CLAHE tile grid size with external strobes", "type": "integer", - "default": 80, - "description": "Canny upper threshold for externally strobed ALT best circle", + "default": 6, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvBottomIgnoreHeight": { "category": "testing", - "ui_name": "External Strobe Canny Upper" + "subcategory": "advanced", + "displayName": "Ext Strobe Bottom Ignore Height", + "description": "Pixels at the bottom of the image blacked out with external strobes", + "type": "integer", + "default": 70, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedALTBestCircleHoughDpParam1": { - "type": "float", - "default": 1.3, - "description": "Hough DP parameter 1 for externally strobed ALT best circle", + "gs_config.testing.kExternallyStrobedEnvCleanupCannyLower": { "category": "testing", - "ui_name": "External Strobe Hough DP1" + "subcategory": "advanced", + "displayName": "Ext Strobe Cleanup Canny Lower", + "description": "Canny lower threshold for external strobe artifact cleanup", + "type": "integer", + "default": 156, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvCleanupCannyUpper": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Cleanup Canny Upper", + "description": "Canny upper threshold for external strobe artifact cleanup", + "type": "integer", + "default": 337, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedALTBestCircleHoughParam2": { + "gs_config.testing.kExternallyStrobedEnvCleanupPreHoughBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Cleanup Pre-Hough Blur", + "description": "Pre-Hough blur size for external strobe artifact cleanup", "type": "integer", "default": 13, - "description": "Hough parameter 2 for externally strobed ALT best circle", + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvCurrentParam1": { "category": "testing", - "ui_name": "External Strobe Hough P2" + "subcategory": "advanced", + "displayName": "Ext Strobe Hough Param1", + "description": "Hough param1 with external strobes", + "type": "number", + "default": 130.0, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedALTBestCircleMaxIterations": { + "gs_config.testing.kExternallyStrobedEnvFilterHsvLowerH": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Lower H", + "description": "Lower hue for the external strobe HSV filter", "type": "integer", - "default": 25, - "description": "Max iterations for externally strobed ALT best circle", + "default": 14, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvLowerS": { "category": "testing", - "ui_name": "External Strobe Max Iterations" + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Lower S", + "description": "Lower saturation for the external strobe HSV filter", + "type": "integer", + "default": 26, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedALTBestCircleMinInliers": { + "gs_config.testing.kExternallyStrobedEnvFilterHsvLowerV": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Lower V", + "description": "Lower value for the external strobe HSV filter", "type": "integer", - "default": 25, - "description": "Min inliers for externally strobed ALT best circle", + "default": 114, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvUpperH": { "category": "testing", - "ui_name": "External Strobe Min Inliers" + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Upper H", + "description": "Upper hue for the external strobe HSV filter", + "type": "integer", + "default": 48, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedBallIdentificationCannyLower": { + "gs_config.testing.kExternallyStrobedEnvFilterHsvUpperS": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Upper S", + "description": "Upper saturation for the external strobe HSV filter", "type": "integer", - "default": 35, - "description": "Canny lower threshold for externally strobed ball identification", + "default": 255, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvFilterHsvUpperV": { "category": "testing", - "ui_name": "External Ball ID Canny Lower" + "subcategory": "advanced", + "displayName": "Ext Strobe HSV Upper V", + "description": "Upper value for the external strobe HSV filter", + "type": "integer", + "default": 255, + "requiresRestart": true }, - "gs_config.testing.kExternallyStrobedBallIdentificationCannyUpper": { + "gs_config.testing.kExternallyStrobedEnvFilterImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Filter Image", + "description": "Apply the HSV filter to images taken with external strobes", + "type": "boolean", + "default": true, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvHoughDpParam1": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Hough DP", + "description": "Hough dp with external strobes", + "type": "number", + "default": 1.0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvLinesAngleLower": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Lines Angle Lower", + "description": "Lower line angle with external strobes", + "type": "integer", + "default": 140, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvLinesAngleUpper": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Lines Angle Upper", + "description": "Upper line angle with external strobes", + "type": "integer", + "default": 180, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaxHoughReturnCircles": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Hough Circles", + "description": "Maximum Hough circles returned with external strobes", + "type": "integer", + "default": 20, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaxParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Param2", + "description": "Maximum Hough param2 with external strobes", + "type": "number", + "default": 100, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMaximumSearchRadius": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Max Search Radius", + "description": "Maximum ball search radius in pixels with external strobes", "type": "integer", "default": 80, - "description": "Canny upper threshold for externally strobed ball identification", + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMinHoughReturnCircles": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Min Hough Circles", + "description": "Minimum Hough circles returned with external strobes", + "type": "integer", + "default": 3, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMinimumHoughLineLength": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Min Hough Line Length", + "description": "Minimum Hough line length with external strobes", + "type": "integer", + "default": 23, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvMinimumSearchRadius": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Min Search Radius", + "description": "Minimum ball search radius in pixels with external strobes", + "type": "integer", + "default": 60, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNarrowingDpParam": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Narrowing DP", + "description": "Hough dp for the narrowing pass with external strobes", + "type": "number", + "default": 1.1, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNarrowingParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Narrowing Param2", + "description": "Hough param2 for the narrowing pass with external strobes", + "type": "number", + "default": 0.6, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvNumber_bits_for_fast_on_pulse_": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Fast Pulse On Bits", + "description": "Bits per fast strobe on-pulse in comparison mode", + "type": "integer", + "default": 0, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvParam2Increment": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Param2 Increment", + "description": "Hough param2 step with external strobes", + "type": "number", + "default": 4, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvPreCannyBlurSize": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Pre-Canny Blur", + "description": "Pre-Canny blur size with external strobes", + "type": "integer", + "default": 3, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvPreHoughBlurSize": { "category": "testing", - "ui_name": "External Ball ID Canny Upper" + "subcategory": "advanced", + "displayName": "Ext Strobe Pre-Hough Blur", + "description": "Pre-Hough blur size with external strobes", + "type": "integer", + "default": 11, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedEnvStartingParam2": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Starting Param2", + "description": "Starting Hough param2 with external strobes", + "type": "number", + "default": 65, + "requiresRestart": true + }, + "gs_config.testing.kExternallyStrobedUseCLAHEProcessing": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Ext Strobe Use CLAHE", + "description": "Apply CLAHE to images taken with external strobes", + "type": "boolean", + "default": true, + "requiresRestart": true + }, + "gs_config.testing.kTwoImageTestPreImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Two Image Test Pre Image", + "description": "Pre-image file for the two image test, relative to the test image directory", + "type": "string", + "default": "", + "requiresRestart": true + }, + "gs_config.testing.kTwoImageTestStrobedBallImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Two Image Test Strobed Ball Image", + "description": "Strobed ball image file for the two image test, relative to the test image directory", + "type": "string", + "default": "", + "requiresRestart": true + }, + "gs_config.testing.kTwoImageTestTeedBallImage": { + "category": "testing", + "subcategory": "advanced", + "displayName": "Two Image Test Teed Ball Image", + "description": "Teed ball image file for the two image test, relative to the test image directory", + "type": "string", + "default": "", + "requiresRestart": true }, "gs_config.cameras.kCamera1CalibrationMatrix": { "type": "array", @@ -3602,78 +3397,6 @@ "category": "calibration", "ui_name": "Camera 2 Distortion Vector" }, - "gs_config.cameras.3_6Lens_kCamera1CalibrationMatrix": { - "type": "array", - "default": [ - [ - "1748.506644661262953", - "0.0", - "632.5374407393054526" - ], - [ - "0.0", - "1743.341748687922745", - "407.5677927449370941" - ], - [ - "0.0", - "0.0", - "1.0" - ] - ], - "description": "Camera 1 calibration matrix for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 1 3.6mm Lens Matrix" - }, - "gs_config.cameras.3_6Lens_kCamera1DistortionVector": { - "type": "array", - "default": [ - "-0.5323350228082535107", - "0.3171607772519656199", - "0.0007282375091653870043", - "-0.002130459534525419", - "0.0" - ], - "description": "Camera 1 distortion for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 1 3.6mm Lens Distortion" - }, - "gs_config.cameras.3_6Lens_kCamera2CalibrationMatrix": { - "type": "array", - "default": [ - [ - "1065.366451276604266", - "0.0", - "715.3187116996945178" - ], - [ - "0.0", - "1068.642040947899886", - "509.4612905764302582" - ], - [ - "0.0", - "0.0", - "1.0" - ] - ], - "description": "Camera 2 calibration matrix for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 2 3.6mm Lens Matrix" - }, - "gs_config.cameras.3_6Lens_kCamera2DistortionVector": { - "type": "array", - "default": [ - "-0.4854175692996230418", - "0.2637456313607589586", - "0.002679639341176350345", - "-0.001576282874523095", - "0.0" - ], - "description": "Camera 2 distortion for 3.6mm lens", - "category": "calibration", - "ui_name": "Camera 2 3.6mm Lens Distortion" - }, "gs_config.cameras.kCamera1Angles": { "type": "array", "default": [ @@ -3739,8 +3462,7 @@ "3": "Custom rig (user will provide ball positions - see custom parameters below)" }, "default": "1", - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kCustomCalibrationRigPositionFromCamera1": { "category": "Calibration", @@ -3753,8 +3475,7 @@ -0.28, 0.44 ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kCustomCalibrationRigPositionFromCamera2": { "category": "Calibration", @@ -3767,8 +3488,7 @@ "0.095", "0.435" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForStraightOutCamerasV2Enclosure": { "category": "Calibration", @@ -3781,8 +3501,7 @@ "-0.28", "0.44" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForStraightOutCamerasV2Enclosure": { "category": "Calibration", @@ -3795,8 +3514,7 @@ "0.095", "0.435" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForSkewedCamerasV2Enclosure": { "category": "Calibration", @@ -3809,8 +3527,7 @@ "-0.28", "0.44" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForSkewedCamerasV2Enclosure": { "category": "Calibration", @@ -3823,8 +3540,7 @@ "0.095", "0.435" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForStraightOutCamerasV3Enclosure": { "category": "Calibration", @@ -3837,8 +3553,7 @@ "-0.243", "0.481" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForStraightOutCamerasV3Enclosure": { "category": "Calibration", @@ -3851,8 +3566,7 @@ "0.109", "0.481" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam1MetersForSkewedCamerasV3Enclosure": { "category": "Calibration", @@ -3865,8 +3579,7 @@ "-0.243", "0.481" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kAutoCalibrationBallPositionFromCam2MetersForSkewedCamerasV3Enclosure": { "category": "Calibration", @@ -3879,8 +3592,7 @@ "0.109", "0.472" ], - "requiresRestart": true, - "passedVia": "json" + "requiresRestart": true }, "gs_config.calibration.kTestAutoCalibrationFileName": { "type": "string", @@ -3888,19 +3600,11 @@ "description": "Test file for auto calibration", "category": "calibration", "ui_name": "Test Auto Cal File" - }, - "gs_config.ball_identification.kStrobedBallsAltPreHoughBlurSize HAS TO BE ODD": { - "type": "integer", - "default": 0, - "description": "Pre-Hough blur size for ALT algorithm (must be odd)", - "category": "ball_identification", - "ui_name": "ALT Pre-Hough Blur Size" } }, "categoryList": [ "System", "Cameras", - "Simulators", "Ball Detection", "AI Detection", "Storage", @@ -3935,11 +3639,6 @@ "description": "Camera hardware and settings", "icon": "camera" }, - "Simulators": { - "displayName": "Simulator Interfaces", - "description": "Golf simulator connections", - "icon": "game" - }, "Strobing": { "displayName": "LED Strobing", "description": "LED timing and speed adjustments", @@ -3989,10 +3688,6 @@ "displayName": "Cameras", "order": 3 }, - "Simulators": { - "displayName": "Simulators", - "order": 4 - }, "Ball Detection": { "displayName": "Ball Detection", "order": 5 diff --git a/Software/web-server/constants.py b/Software/web-server/constants.py index d0dd33df..bb13229e 100644 --- a/Software/web-server/constants.py +++ b/Software/web-server/constants.py @@ -3,8 +3,10 @@ import os from pathlib import Path -MPS_TO_MPH = 2.237 +MPS_TO_MPH = 2.23694 HOME_DIR = Path(os.environ.get("HOME", str(Path.home()))) PITRAC_DIR = HOME_DIR / ".pitrac" IMAGES_DIR = Path(os.environ.get("PITRAC_IMAGES_DIR", str(HOME_DIR / "LM_Shares" / "Images"))) +DB_PATH = Path(os.environ.get("PITRAC_DB_PATH", str(PITRAC_DIR / "pitrac.db"))) +SERVER_PORT = int(os.environ.get("PITRAC_WEB_PORT", "8080")) diff --git a/Software/web-server/db/__init__.py b/Software/web-server/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/web-server/db/database.py b/Software/web-server/db/database.py new file mode 100644 index 00000000..30efe5bb --- /dev/null +++ b/Software/web-server/db/database.py @@ -0,0 +1,56 @@ +import sqlite3 +import threading +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator, List, Optional + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + + +class Database: + def __init__(self, db_path: Path): + db_path = Path(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(db_path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA foreign_keys=ON") + self._conn.execute("PRAGMA busy_timeout=5000") + self._lock = threading.Lock() + self._migrate() + + def _migrate(self) -> None: + current = self._conn.execute("PRAGMA user_version").fetchone()[0] + self.created = current == 0 + for script in sorted(MIGRATIONS_DIR.glob("*.sql"), key=lambda p: int(p.name.split("_")[0])): + version = int(script.name.split("_")[0]) + if version <= current: + continue + # executescript auto-commits before the version pragma runs, so every + # statement must be IF NOT EXISTS-safe to survive a mid-migration crash + self._conn.executescript(script.read_text()) + self._conn.execute(f"PRAGMA user_version = {version}") + self._conn.commit() + + def query(self, sql: str, params: tuple = ()) -> List[sqlite3.Row]: + with self._lock: + return self._conn.execute(sql, params).fetchall() + + def execute(self, sql: str, params: tuple = ()) -> Optional[int]: + with self._lock: + cur = self._conn.execute(sql, params) + self._conn.commit() + return cur.lastrowid + + def executemany(self, sql: str, rows: List[tuple]) -> None: + with self._lock: + self._conn.executemany(sql, rows) + self._conn.commit() + + @contextmanager + def transaction(self) -> Iterator[sqlite3.Connection]: + with self._lock, self._conn: + yield self._conn + + def close(self) -> None: + self._conn.close() diff --git a/Software/web-server/db/migrations/001_shot_history.sql b/Software/web-server/db/migrations/001_shot_history.sql new file mode 100644 index 00000000..e85907ad --- /dev/null +++ b/Software/web-server/db/migrations/001_shot_history.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + ended_at TEXT, + label TEXT +); + +-- shot ids come from the C++ process (epoch ms at detection), not autoincrement +CREATE TABLE IF NOT EXISTS shots ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + result_type TEXT NOT NULL, + speed REAL NOT NULL DEFAULT 0, + carry REAL NOT NULL DEFAULT 0, + launch_angle REAL NOT NULL DEFAULT 0, + side_angle REAL NOT NULL DEFAULT 0, + back_spin INTEGER NOT NULL DEFAULT 0, + side_spin INTEGER NOT NULL DEFAULT 0, + message TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_shots_session ON shots(session_id); + +CREATE TABLE IF NOT EXISTS shot_images ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + shot_id INTEGER NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + file_path TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_shot_images_shot ON shot_images(shot_id); diff --git a/Software/web-server/db/migrations/002_settings.sql b/Software/web-server/db/migrations/002_settings.sql new file mode 100644 index 00000000..6af2f871 --- /dev/null +++ b/Software/web-server/db/migrations/002_settings.sql @@ -0,0 +1,12 @@ +-- key is the dotted config key, value is JSON-encoded +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS calibration ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/Software/web-server/db/migrations/003_simulators.sql b/Software/web-server/db/migrations/003_simulators.sql new file mode 100644 index 00000000..42f994f4 --- /dev/null +++ b/Software/web-server/db/migrations/003_simulators.sql @@ -0,0 +1,10 @@ +-- settings is the JSON object of the type's fields; enabled is the On switch +CREATE TABLE IF NOT EXISTS simulators ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + enabled INTEGER NOT NULL, + settings TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/Software/web-server/db/migrations/004_sim_results.sql b/Software/web-server/db/migrations/004_sim_results.sql new file mode 100644 index 00000000..5a8a3107 --- /dev/null +++ b/Software/web-server/db/migrations/004_sim_results.sql @@ -0,0 +1,14 @@ +-- what a simulator reported back for a shot; distances in meters as the simulator sends them +CREATE TABLE IF NOT EXISTS shot_sim_results ( + shot_id INTEGER NOT NULL REFERENCES shots(id) ON DELETE CASCADE, + simulator_id TEXT NOT NULL, + carry REAL, + total REAL, + roll REAL, + height REAL, + lateral REAL, + club_id TEXT, + club_name TEXT, + received_at TEXT NOT NULL, + PRIMARY KEY (shot_id, simulator_id) +); diff --git a/Software/web-server/db/repositories.py b/Software/web-server/db/repositories.py new file mode 100644 index 00000000..de5708f5 --- /dev/null +++ b/Software/web-server/db/repositories.py @@ -0,0 +1,226 @@ +import json +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from db.database import Database +from models import ShotData + + +class SessionRepository: + def __init__(self, db: Database): + self.db = db + + def ensure_open(self, now_iso: str, timeout_minutes: int) -> int: + rows = self.db.query( + "SELECT id, started_at FROM sessions WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1" + ) + if rows: + session_id = rows[0]["id"] + last = self.db.query( + "SELECT MAX(created_at) AS t FROM shots WHERE session_id = ?", (session_id,) + )[0]["t"] or rows[0]["started_at"] + cutoff = datetime.fromisoformat(now_iso) - timedelta(minutes=timeout_minutes) + if datetime.fromisoformat(last) >= cutoff: + return session_id + self.db.execute("UPDATE sessions SET ended_at = ? WHERE id = ?", (now_iso, session_id)) + return self.db.execute("INSERT INTO sessions (started_at) VALUES (?)", (now_iso,)) + + def close_open(self, now_iso: str) -> None: + self.db.execute("UPDATE sessions SET ended_at = ? WHERE ended_at IS NULL", (now_iso,)) + + def get(self, session_id: int) -> Optional[Dict[str, Any]]: + rows = self.db.query("SELECT * FROM sessions WHERE id = ?", (session_id,)) + return dict(rows[0]) if rows else None + + def list(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]: + rows = self.db.query( + """SELECT s.*, COUNT(sh.id) AS shot_count + FROM sessions s LEFT JOIN shots sh ON sh.session_id = s.id + GROUP BY s.id ORDER BY s.id DESC LIMIT ? OFFSET ?""", + (limit, offset), + ) + return [dict(r) for r in rows] + + def oldest_prunable(self) -> Optional[Dict[str, Any]]: + # oldest ended session that still has image rows — keeps the prune loop advancing + rows = self.db.query( + """SELECT * FROM sessions WHERE ended_at IS NOT NULL + AND EXISTS (SELECT 1 FROM shot_images si JOIN shots s ON s.id = si.shot_id + WHERE s.session_id = sessions.id) + ORDER BY id ASC LIMIT 1""" + ) + return dict(rows[0]) if rows else None + + def delete(self, session_id: int) -> None: + self.db.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + + +class ShotRepository: + def __init__(self, db: Database): + self.db = db + + def add(self, shot_id: int, session_id: int, shot: ShotData, + images: List[Tuple[str, str]]) -> None: + self.db.execute( + """INSERT OR IGNORE INTO shots + (id, session_id, created_at, result_type, speed, carry, + launch_angle, side_angle, back_spin, side_spin, message) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (shot_id, session_id, shot.timestamp, shot.result_type, shot.speed, + shot.carry, shot.launch_angle, shot.side_angle, shot.back_spin, + shot.side_spin, shot.message), + ) + # first writer wins: a re-post with different images keeps the originals, + # but a shot that arrived imageless can pick them up later + existing = self.db.query("SELECT COUNT(*) FROM shot_images WHERE shot_id = ?", (shot_id,)) + if existing[0][0] == 0 and images: + self.db.executemany( + "INSERT INTO shot_images (shot_id, kind, file_path) VALUES (?, ?, ?)", + [(shot_id, kind, path) for kind, path in images], + ) + + def add_sim_result(self, shot_id: int, simulator_id: str, data: Dict[str, Any], received_at: str) -> None: + result = data.get("result") or {} + club = data.get("club") or {} + self.db.execute( + """INSERT OR REPLACE INTO shot_sim_results + (shot_id, simulator_id, carry, total, roll, height, lateral, club_id, club_name, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (shot_id, simulator_id, result.get("carry"), result.get("total"), result.get("roll"), + result.get("height"), result.get("lateral"), club.get("id"), club.get("name"), received_at), + ) + + def _attach_images(self, shot_row: Dict[str, Any]) -> Dict[str, Any]: + imgs = self.db.query( + "SELECT kind, file_path FROM shot_images WHERE shot_id = ? ORDER BY id", + (shot_row["id"],), + ) + shot_row["images"] = [dict(i) for i in imgs] + return shot_row + + def get(self, shot_id: int) -> Optional[Dict[str, Any]]: + rows = self.db.query("SELECT * FROM shots WHERE id = ?", (shot_id,)) + if not rows: + return None + shot = self._attach_images(dict(rows[0])) + results = self.db.query( + "SELECT * FROM shot_sim_results WHERE shot_id = ? ORDER BY received_at", (shot_id,) + ) + shot["sim_results"] = [{k: r[k] for k in r.keys() if k != "shot_id"} for r in results] + return shot + + def list_for_session(self, session_id: int) -> List[Dict[str, Any]]: + rows = self.db.query( + "SELECT * FROM shots WHERE session_id = ? ORDER BY id DESC", (session_id,) + ) + return [dict(r) for r in rows] + + def delete_images_for_session(self, session_id: int) -> None: + self.db.execute( + "DELETE FROM shot_images WHERE shot_id IN (SELECT id FROM shots WHERE session_id = ?)", + (session_id,), + ) + + def oldest_shot_with_images_excluding_latest(self) -> Optional[int]: + rows = self.db.query( + """SELECT MIN(shot_id) AS id FROM shot_images + WHERE shot_id != (SELECT MAX(id) FROM shots)""" + ) + return rows[0]["id"] + + def delete_images_for_shot(self, shot_id: int) -> None: + self.db.execute("DELETE FROM shot_images WHERE shot_id = ?", (shot_id,)) + + def shot_ids_with_images(self) -> set: + """Set of shot ids that have at least one shot_images row.""" + rows = self.db.query("SELECT DISTINCT shot_id FROM shot_images") + return {r["shot_id"] for r in rows} + + def shot_ids_for_session(self, session_id: int) -> list: + rows = self.db.query("SELECT id FROM shots WHERE session_id = ?", (session_id,)) + return [r["id"] for r in rows] + + +class KeyValueRepository: + def __init__(self, db: Database, table: str): + if table not in ("settings", "calibration"): + raise ValueError(f"Unknown key-value table: {table}") + self.db = db + self.table = table + + def load(self) -> Dict[str, Any]: + rows = self.db.query(f"SELECT key, value FROM {self.table}") + return {r["key"]: json.loads(r["value"]) for r in rows} + + def replace_all(self, flat: Dict[str, Any], touched: Iterable[str] = ()) -> None: + with self.db.transaction() as conn: + self.replace_all_in(conn, flat, touched) + + def updated_at(self, key: str) -> Optional[str]: + rows = self.db.query(f"SELECT updated_at FROM {self.table} WHERE key = ?", (key,)) + return rows[0]["updated_at"] if rows else None + + def replace_all_in(self, conn, flat: Dict[str, Any], touched: Iterable[str] = ()) -> None: + # A row keeps its updated_at while its value is unchanged unless it is in touched (a calibration that + # ran again), so the timestamp says when that key was last changed or recalibrated + now = datetime.now().isoformat() + touched = set(touched) + old = {r["key"]: (r["value"], r["updated_at"]) for r in conn.execute(f"SELECT key, value, updated_at FROM {self.table}")} + rows = [] + for key, value in flat.items(): + encoded = json.dumps(value) + prev_value, prev_updated = old.get(key, (None, None)) + rows.append((key, encoded, prev_updated if prev_value == encoded and key not in touched else now)) + conn.execute(f"DELETE FROM {self.table}") + conn.executemany(f"INSERT INTO {self.table} (key, value, updated_at) VALUES (?, ?, ?)", rows) + + +class SimulatorRepository: + def __init__(self, db: Database): + self.db = db + + @staticmethod + def _row(row) -> Dict[str, Any]: + return { + "id": row["id"], + "type": row["type"], + "name": row["name"], + "on": bool(row["enabled"]), + "settings": json.loads(row["settings"]), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def list(self) -> List[Dict[str, Any]]: + return [self._row(r) for r in self.db.query("SELECT * FROM simulators ORDER BY rowid")] + + def get(self, sim_id: str) -> Optional[Dict[str, Any]]: + rows = self.db.query("SELECT * FROM simulators WHERE id = ?", (sim_id,)) + return self._row(rows[0]) if rows else None + + def add(self, sim_type: str, name: str, on: bool, settings: Dict[str, Any]) -> Dict[str, Any]: + with self.db.transaction() as conn: + sim_id = self.add_in(conn, sim_type, name, on, settings) + return self.get(sim_id) + + def add_in(self, conn, sim_type: str, name: str, on: bool, settings: Dict[str, Any]) -> str: + sim_id = uuid.uuid4().hex + now = datetime.now().isoformat() + conn.execute( + """INSERT INTO simulators (id, type, name, enabled, settings, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (sim_id, sim_type, name, int(on), json.dumps(settings), now, now), + ) + return sim_id + + def update(self, sim_id: str, name: str, on: bool, settings: Dict[str, Any]) -> Optional[Dict[str, Any]]: + self.db.execute( + "UPDATE simulators SET name = ?, enabled = ?, settings = ?, updated_at = ? WHERE id = ?", + (name, int(on), json.dumps(settings), datetime.now().isoformat(), sim_id), + ) + return self.get(sim_id) + + def delete(self, sim_id: str) -> bool: + with self.db.transaction() as conn: + return conn.execute("DELETE FROM simulators WHERE id = ?", (sim_id,)).rowcount > 0 diff --git a/Software/web-server/eslint.config.js b/Software/web-server/eslint.config.js index 2bb57595..0669a9f6 100644 --- a/Software/web-server/eslint.config.js +++ b/Software/web-server/eslint.config.js @@ -1,6 +1,9 @@ +const js = require('@eslint/js'); const prettierConfig = require('eslint-config-prettier'); module.exports = [ + { ignores: ['static/vendor/**', 'static/js/lucide.min.js'] }, + js.configs.recommended, { files: ['**/*.js'], languageOptions: { @@ -26,13 +29,26 @@ module.exports = [ Array: 'readonly', Number: 'readonly', String: 'readonly', - isNaN: 'readonly' + isNaN: 'readonly', + localStorage: 'readonly', + navigator: 'readonly', + requestAnimationFrame: 'readonly', + FormData: 'readonly', + lucide: 'readonly', + location: 'readonly', + HTMLElement: 'readonly', + Event: 'readonly', + CustomEvent: 'readonly', + getComputedStyle: 'readonly', + Node: 'readonly', + FileReader: 'readonly' } }, rules: { 'no-unused-vars': ['error', { - 'argsIgnorePattern': '^_|^e$', - 'varsIgnorePattern': '^(saveChanges|resetAll|reloadConfig|showDiff|exportConfig|importConfig|filterConfig|closeModal|setTheme|openImage|resetShot|controlPiTrac|startBtn|stopBtn|restartBtn|calibration)$' + 'argsIgnorePattern': '^_', + 'caughtErrors': 'none', + 'varsIgnorePattern': '^(setTheme|openImage|resetShot|controlPiTrac|startBtn|stopBtn|restartBtn|calibration|showStatusMessage)$' }], 'no-console': ['warn', { 'allow': ['warn', 'error'] }], 'curly': ['error', 'multi-line'], diff --git a/Software/web-server/log_files.py b/Software/web-server/log_files.py new file mode 100644 index 00000000..155ac572 --- /dev/null +++ b/Software/web-server/log_files.py @@ -0,0 +1,106 @@ +import asyncio +import os +import re +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Tuple + +RUN_LOG_RE = re.compile(r"^pitrac-\d{8}-\d{6}\.log$") +BLOCK = 8192 + + +def run_log_path(log_dir: Path, started_at: datetime) -> Path: + return log_dir / f"pitrac-{started_at:%Y%m%d-%H%M%S}.log" + + +def list_run_logs(log_dir: Path) -> List[Path]: + if not log_dir.is_dir(): + return [] + return sorted(p for p in log_dir.glob("pitrac-*.log") if RUN_LOG_RE.match(p.name)) + + +def latest_run_log(log_dir: Path) -> Optional[Path]: + logs = list_run_logs(log_dir) + return logs[-1] if logs else None + + +def _read_back(path: Path, end: int, n: int) -> Tuple[bytes, int]: + """Read backwards from byte `end` until >= n newlines collected. Returns (data, start).""" + chunks, newlines = [], 0 + with open(path, "rb") as f: + while end > 0 and newlines <= n: + start = max(0, end - BLOCK) + f.seek(start) + chunk = f.read(end - start) + chunks.append(chunk) + newlines += chunk.count(b"\n") + end = start + return b"".join(reversed(chunks)), end + + +def _last_lines(data: bytes, data_start: int, data_end: int, n: int) -> Tuple[List[str], int]: + if not data: + return [], data_start + trailing_newline = data.endswith(b"\n") + parts = data.split(b"\n") + if trailing_newline: + parts.pop() + selected = parts[-n:] + selected_bytes = sum(len(p) + 1 for p in selected) + if not trailing_newline and selected: + selected_bytes -= 1 + offset = data_end - selected_bytes + return [p.decode("utf-8", errors="replace") for p in selected], max(data_start, offset) + + +def tail_lines(path: Path, n: int) -> Tuple[List[str], int]: + size = path.stat().st_size + data, start = _read_back(path, size, n) + return _last_lines(data, start, size, n) + + +def read_chunk_before(path: Path, offset: int, n: int) -> Tuple[List[str], int]: + """n lines ending at byte offset; returns (lines, new_offset). offset 0 → ([], 0).""" + end = min(offset, path.stat().st_size) + data, start = _read_back(path, end, n) + return _last_lines(data, start, end, n) + + +def prune_run_logs(log_dir: Path, cap_bytes: int) -> None: + # legacy single-file log from old installs is always the first prune candidate + logs = [p for p in [log_dir / "pitrac.log"] if p.exists()] + list_run_logs(log_dir) + total = sum(p.stat().st_size for p in logs) + for p in logs[:-1]: # never the live/latest file + if total <= cap_bytes: + break + total -= p.stat().st_size + p.unlink(missing_ok=True) + + +def truncate_if_over(path: Path, cap_bytes: int) -> bool: + if path.exists() and path.stat().st_size > cap_bytes: + os.truncate(path, 0) + return True + return False + + +async def follow(path: Path, poll_interval: float = 0.5): + """Yield new lines appended to `path`, polling for growth. Resets on truncate.""" + pos = path.stat().st_size if path.exists() else 0 + pending = b"" + while True: + if path.exists(): + size = path.stat().st_size + if size < pos: # truncate backstop fired (marathon-run cap) + pos = 0 + pending = b"" + if size > pos: + with open(path, "rb") as f: + f.seek(pos) + pending += f.read(size - pos) + pos = size + # hold back any trailing partial line until its newline arrives + *complete, pending = pending.split(b"\n") + for line in complete: + yield line.decode("utf-8", errors="replace") + await asyncio.sleep(poll_interval) diff --git a/Software/web-server/main.py b/Software/web-server/main.py index 979238cd..62223d66 100644 --- a/Software/web-server/main.py +++ b/Software/web-server/main.py @@ -20,6 +20,7 @@ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) +from constants import SERVER_PORT # noqa: E402 from server import app # noqa: E402 if __name__ == "__main__": @@ -29,4 +30,4 @@ elif uvicorn_level == "critical": uvicorn_level = "error" - uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True, log_level=uvicorn_level) + uvicorn.run("main:app", host="0.0.0.0", port=SERVER_PORT, reload=True, log_level=uvicorn_level) diff --git a/Software/web-server/models.py b/Software/web-server/models.py index 45778685..1b9c1895 100644 --- a/Software/web-server/models.py +++ b/Software/web-server/models.py @@ -1,6 +1,6 @@ -from dataclasses import dataclass, asdict +from dataclasses import dataclass, asdict, field from enum import Enum -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional class ResultType(Enum): @@ -27,6 +27,8 @@ class ShotData: result_type: str = "Waiting for ball..." message: str = "" timestamp: Optional[str] = None + shot_id: Optional[int] = None + images: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return asdict(self) diff --git a/Software/web-server/parsers.py b/Software/web-server/parsers.py index 65c09079..afe94ab6 100644 --- a/Software/web-server/parsers.py +++ b/Software/web-server/parsers.py @@ -2,7 +2,6 @@ from datetime import datetime from typing import Any, Dict, List -from constants import MPS_TO_MPH from models import ResultType, ShotData logger = logging.getLogger(__name__) diff --git a/Software/web-server/pitrac_manager.py b/Software/web-server/pitrac_manager.py index 55506c99..d7111786 100644 --- a/Software/web-server/pitrac_manager.py +++ b/Software/web-server/pitrac_manager.py @@ -7,9 +7,12 @@ import os import signal import subprocess +from datetime import datetime from pathlib import Path from typing import Optional, Dict, Any from config_manager import ConfigurationManager +from log_files import run_log_path, latest_run_log, prune_run_logs, tail_lines +from constants import SERVER_PORT logger = logging.getLogger(__name__) @@ -32,9 +35,14 @@ def expand_path(path_str: str) -> Path: log_dir = expand_path(sys_paths.get("logDirectory", {}).get("default", "~/.pitrac/logs")) pid_dir = expand_path(sys_paths.get("pidDirectory", {}).get("default", "~/.pitrac/run")) - self.log_file = log_dir / proc_mgmt.get("camera1LogFile", {}).get("default", "pitrac.log") + self.log_dir = log_dir + self.log_file = latest_run_log(log_dir) or (log_dir / "pitrac.log") self.pid_file = pid_dir / proc_mgmt.get("camera1PidFile", {}).get("default", "pitrac.pid") + storage = metadata.get("storage", {}) + self.log_dir_cap_bytes = storage.get("logDirectoryCapMB", {}).get("default", 30) * 1024 * 1024 + self.run_log_cap_bytes = storage.get("runLogCapMB", {}).get("default", 50) * 1024 * 1024 + self.process_check_command = proc_mgmt.get("processCheckCommand", {}).get("default", "pitrac_lm") self.startup_delay = proc_mgmt.get("startupDelayCamera1", {}).get("default", 3) self.shutdown_grace_period = proc_mgmt.get("shutdownGracePeriod", {}).get("default", 5) @@ -49,47 +57,10 @@ def expand_path(path_str: str) -> Path: self.log_file.parent.mkdir(parents=True, exist_ok=True) self.pid_file.parent.mkdir(parents=True, exist_ok=True) - def _build_cli_args_from_metadata(self) -> list: - args = [] - merged_config = self.config_manager.get_config() - skip_args = {"--system_mode", "--web_server_share_dir"} - - for param in self.config_manager.get_cli_parameters(): - cli_arg = param["cliArgument"] - param_type = param["type"] - if cli_arg in skip_args: - continue - key = param["key"] - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - if value is None: - continue - if param_type != "boolean" and value == "": - continue - if param_type == "boolean": - if value: - args.append(cli_arg) - else: - if param_type == "path" and value: - value = str(value).replace("~", str(Path.home())) - args.append(f"{cli_arg}={value}") - return args - - def _build_command(self, config_file_path: Optional[Path] = None) -> list: + def _build_command(self) -> list: cmd = [self.pitrac_binary] cmd.append("--system_mode=camera1") - - if config_file_path and Path(config_file_path).exists(): - cmd.append(f"--config_file={config_file_path}") - else: - logger.error("No config file path provided!") - - cmd.extend(self._build_cli_args_from_metadata()) + cmd.append(f"--web_server_port={SERVER_PORT}") config = self.config_manager.get_config() web_share_dir = ( @@ -113,39 +84,18 @@ async def start(self) -> Dict[str, Any]: Path(self.log_file).parent.mkdir(parents=True, exist_ok=True) Path(self.pid_file).parent.mkdir(parents=True, exist_ok=True) - try: - generated_config_path = self.config_manager.generate_golf_sim_config() - logger.info(f"Generated config file at: {generated_config_path}") - except RuntimeError as e: - logger.error(f"Failed to generate config: {e}") - return {"status": "error", "message": f"Failed to generate configuration: {e}", "error": str(e)} - env = os.environ.copy() - home_dir = str(Path.home()) env["LD_LIBRARY_PATH"] = "/usr/lib/pitrac" env["PITRAC_ROOT"] = "/usr/lib/pitrac" env["OMP_WAIT_POLICY"] = "PASSIVE" - env["PITRAC_BASE_IMAGE_LOGGING_DIR"] = "~/LM_Shares/Images/".replace("~", home_dir) - env["PITRAC_WEBSERVER_SHARE_DIR"] = "~/LM_Shares/WebShare/".replace("~", home_dir) - - merged_config = self.config_manager.get_config() - for param in self.config_manager.get_environment_parameters(): - key = param["key"] - env_var = param["envVariable"] - value = merged_config - for part in key.split("."): - if isinstance(value, dict): - value = value.get(part) - else: - value = None - break - if value is not None and value != "": - env[env_var] = str(value) - - Path(env["PITRAC_BASE_IMAGE_LOGGING_DIR"]).mkdir(parents=True, exist_ok=True) - Path(env["PITRAC_WEBSERVER_SHARE_DIR"]).mkdir(parents=True, exist_ok=True) - - cmd = self._build_command(config_file_path=generated_config_path) + + (Path.home() / "LM_Shares/Images").mkdir(parents=True, exist_ok=True) + (Path.home() / "LM_Shares/WebShare").mkdir(parents=True, exist_ok=True) + + cmd = self._build_command() + + self.log_file = run_log_path(self.log_dir, datetime.now()) + prune_run_logs(self.log_dir, self.log_dir_cap_bytes) with open(self.log_file, "a") as log: process = subprocess.Popen( @@ -265,15 +215,12 @@ def get_status(self) -> Dict[str, Any]: "is_running": pid is not None, "pid": pid, "log_file": str(self.log_file), - "generated_config_path": str(Path.home() / ".pitrac/config/generated_golf_sim_config.json"), "binary": self.pitrac_binary, } if self.log_file.exists(): try: - with open(self.log_file, "r") as f: - lines = f.readlines() - status["recent_logs"] = lines[-self.recent_log_lines:] if len(lines) > self.recent_log_lines else lines + status["recent_logs"] = tail_lines(self.log_file, self.recent_log_lines)[0] except Exception as e: status["log_error"] = str(e) diff --git a/Software/web-server/pytest.ini b/Software/web-server/pytest.ini index 264b64fa..465287f3 100644 --- a/Software/web-server/pytest.ini +++ b/Software/web-server/pytest.ini @@ -4,6 +4,7 @@ python_files = test_*.py python_classes = Test* python_functions = test_* asyncio_mode = auto +asyncio_default_fixture_loop_scope = function addopts = -v --tb=short diff --git a/Software/web-server/requirements-test.txt b/Software/web-server/requirements-test.txt index 73fc854d..ea3be9d8 100644 --- a/Software/web-server/requirements-test.txt +++ b/Software/web-server/requirements-test.txt @@ -6,4 +6,4 @@ pytest-xdist==3.5.0 httpx==0.25.2 websocket-client==1.6.4 faker==20.1.0 -opencv-python-headless>=4.7.0 \ No newline at end of file +opencv-python-headless==4.14.0.94 diff --git a/Software/web-server/requirements.txt b/Software/web-server/requirements.txt index d2c4ed9d..700898fb 100644 --- a/Software/web-server/requirements.txt +++ b/Software/web-server/requirements.txt @@ -7,4 +7,3 @@ pyyaml==6.0.3 aiofiles==25.1.0 websockets==16.0 spidev>=3.6 -gpiozero>=2.0 diff --git a/Software/web-server/retention.py b/Software/web-server/retention.py new file mode 100644 index 00000000..1c61103a --- /dev/null +++ b/Software/web-server/retention.py @@ -0,0 +1,82 @@ +import logging +import shutil +from datetime import datetime, timedelta +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Orphan dirs younger than this are left alone — they may still be receiving writes. +_ORPHAN_GRACE = timedelta(hours=24) + + +def _dir_bytes(shots_dir: Path) -> int: + """Sum file sizes under shots_dir; returns 0 if the dir doesn't exist.""" + if not shots_dir.is_dir(): + return 0 + return sum(f.stat().st_size for f in shots_dir.rglob("*") if f.is_file()) + + +class ImageRetention: + def __init__(self, session_repo, shot_repo, images_dir: Path, cap_mb: int): + self._sessions = session_repo + self._shots = shot_repo + self._images_dir = images_dir + self._cap_bytes = cap_mb * 1024 * 1024 + + def prune(self, now: datetime | None = None) -> None: + shots_dir = self._images_dir / "shots" + total = _dir_bytes(shots_dir) + if total <= self._cap_bytes: + return + + # Step 2: prune ended sessions oldest-first until under cap or no prunable remain. + # oldest_prunable() advances each iteration because we remove that session's image + # rows, so it can never return the same session twice → no infinite loop. + while total > self._cap_bytes: + session = self._sessions.oldest_prunable() + if session is None: + break # only open sessions or image-less sessions remain; stop + sid = session["id"] + for shot_id in self._shots.shot_ids_for_session(sid): + self.remove_shot_dir(shot_id) + self._shots.delete_images_for_session(sid) + total = _dir_bytes(shots_dir) + logger.info(f"pruned session {sid} images; remaining {total // (1024*1024)}MB") + + # Open session: oldest shots first, never the newest (the dashboard shows it). + while total > self._cap_bytes: + shot_id = self._shots.oldest_shot_with_images_excluding_latest() + if shot_id is None: + break + self.remove_shot_dir(shot_id) + self._shots.delete_images_for_shot(shot_id) + total = _dir_bytes(shots_dir) + logger.info(f"pruned shot {shot_id} images; remaining {total // (1024*1024)}MB") + + # Step 3: orphan sweep — shots/ dirs with no shot_images rows. + # C++ may write images for error-status shots that never get persisted. + if total > self._cap_bytes and shots_dir.is_dir(): + _now = now or datetime.now() + cutoff = (_now - _ORPHAN_GRACE).timestamp() + known = self._shots.shot_ids_with_images() + for d in shots_dir.iterdir(): + if not d.is_dir(): + continue + try: + dir_id = int(d.name) + except ValueError: + continue + if dir_id in known: + continue + if d.stat().st_mtime <= cutoff: + shutil.rmtree(d, ignore_errors=True) + logger.info(f"pruned orphan dir {d.name}") + + def remove_shot_dir(self, shot_id: int) -> None: + # Deletes by shot id, never by shot_images.file_path, so a stored path cannot point the delete elsewhere. + shots_dir = (self._images_dir / "shots").resolve() + d = shots_dir / str(shot_id) + if d.resolve() != d: + logger.warning(f"not pruning {d}: it resolves outside {shots_dir}") + return + shutil.rmtree(d, ignore_errors=True) diff --git a/Software/web-server/server.py b/Software/web-server/server.py index 83b4aac8..3350554b 100644 --- a/Software/web-server/server.py +++ b/Software/web-server/server.py @@ -3,27 +3,45 @@ import logging import os import shutil +import sqlite3 import subprocess +import time +from contextlib import asynccontextmanager +from dataclasses import replace from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Union -from fastapi import FastAPI, File, Request, UploadFile, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from calibration_manager import CalibrationManager +from retention import ImageRetention, _dir_bytes from camera_detector import CameraDetector from config_manager import ConfigurationManager from constants import ( + DB_PATH, IMAGES_DIR, MPS_TO_MPH, ) +from db.database import Database +from log_files import ( + follow, + latest_run_log, + list_run_logs, + prune_run_logs, + read_chunk_before, + tail_lines, + truncate_if_over, +) +from db.repositories import KeyValueRepository, SessionRepository, ShotRepository, SimulatorRepository from managers import ConnectionManager, ShotDataStore from models import ShotData from parsers import ShotDataParser from pitrac_manager import PiTracProcessManager +from sim_manager import SimManager, SimSettingsError, import_legacy_settings from strobe_calibration_manager import StrobeCalibrationManager from testing_tools_manager import TestingToolsManager from update_manager import UpdateManager @@ -35,6 +53,9 @@ CHARUCO_SQUARE_LENGTH = 0.023 CHARUCO_MARKER_LENGTH = 0.017 +LENS_CALIBRATION_FEED = "lens calibration feed" +LENS_PREVIEW = "lens preview" + # Locked to defeat AE/AWB/tuning drift across calibration frames (sub-pixel # corner bias). Tuning file matches production rcPi5GS.sh; shutter is shorter # than production (11 vs 20 ms) to limit hand-tremor blur. @@ -174,18 +195,33 @@ class PiTracServer: _BASE_DIR = Path(__file__).resolve().parent def __init__(self): - self.app = FastAPI(title="PiTrac Dashboard") + self.app = FastAPI(title="PiTrac Dashboard", lifespan=self._lifespan) self.templates = Jinja2Templates(directory=str(self._BASE_DIR / "templates")) + self.templates.env.globals["asset_version"] = str(int(time.time())) self.connection_manager = ConnectionManager() self.shot_store = ShotDataStore() self.parser = ShotDataParser() - self.config_manager = ConfigurationManager() + self.db = Database(DB_PATH) + self.config_manager = ConfigurationManager(self.db) self.pitrac_manager = PiTracProcessManager(self.config_manager) self.calibration_manager = CalibrationManager(self.config_manager) self.testing_manager = TestingToolsManager(self.config_manager) self.strobe_calibration_manager = StrobeCalibrationManager(self.config_manager) self.update_manager = UpdateManager() self.update_manager.set_broadcast_callback(self.connection_manager.broadcast) + self.session_repo = SessionRepository(self.db) + self.shot_repo = ShotRepository(self.db) + metadata = self.config_manager.load_configurations_metadata() + self.session_timeout_minutes = metadata.get("storage", {}).get("sessionTimeoutMinutes", {}).get("default", 30) + self.image_cap_mb = metadata.get("storage", {}).get("imageStorageCapMB", {}).get("default", 5000) + self.retention = ImageRetention(self.session_repo, self.shot_repo, IMAGES_DIR, self.image_cap_mb) + self.sim_connection_manager = ConnectionManager() + sim_repo = SimulatorRepository(self.db) + if import_legacy_settings(KeyValueRepository(self.db, "settings"), sim_repo): + self.config_manager.reload() + self.sim_manager = SimManager( + sim_repo, broadcast=self.sim_connection_manager.broadcast, on_result=self._store_sim_result + ) self.shutdown_flag = False self.background_tasks: set[asyncio.Task] = set() self._active_cameras: Dict[int, str] = {} # camera_index -> endpoint name @@ -196,12 +232,12 @@ def __init__(self): self._setup_routes() - @self.app.on_event("startup") - async def _startup(): - await self.startup_event() - - @self.app.on_event("shutdown") - async def _shutdown(): + @asynccontextmanager + async def _lifespan(self, app: FastAPI): + await self.startup_event() + try: + yield + finally: await self.shutdown_event() def _setup_routes(self) -> None: @@ -238,9 +274,11 @@ async def get_current_shot() -> Dict[str, Any]: async def get_shot_history(limit: int = 10) -> list: return [shot.to_dict() for shot in self.shot_store.get_history(limit)] - @self.app.get("/api/images/{filename}", response_model=None) + @self.app.get("/api/images/{filename:path}", response_model=None) async def get_image(filename: str): - image_path = IMAGES_DIR / filename + image_path = (IMAGES_DIR / filename).resolve() + if not image_path.is_relative_to(IMAGES_DIR.resolve()): + return {"error": "Image not found"} if image_path.exists() and image_path.is_file(): return FileResponse(image_path) return {"error": "Image not found"} @@ -252,50 +290,129 @@ async def reset_shot() -> Dict[str, Optional[str]]: logger.info("Shot data reset via API") return {"status": "reset", "timestamp": shot_data.timestamp} + def sim_error(e: Exception) -> JSONResponse: + if isinstance(e, SimSettingsError): + return JSONResponse(status_code=400, content={"error": str(e), "fields": e.fields}) + return JSONResponse(status_code=404, content={"error": "Unknown simulator"}) + + async def json_body(request: Request) -> Any: + try: + return await request.json() + except ValueError: + return None + + @self.app.get("/api/sims/types") + async def get_sim_types() -> List[Dict[str, Any]]: + return self.sim_manager.types() + + @self.app.get("/api/sims") + async def get_sims() -> List[Dict[str, Any]]: + return self.sim_manager.status() + + @self.app.post("/api/sims") + async def create_sim(request: Request): + try: + return self.sim_manager.create(await json_body(request)) + except SimSettingsError as e: + return sim_error(e) + + @self.app.post("/api/sims/test") + async def test_sim(request: Request): + try: + return await self.sim_manager.test_connection(await json_body(request)) + except SimSettingsError as e: + return sim_error(e) + + @self.app.put("/api/sims/{sim_id}") + async def update_sim(sim_id: str, request: Request): + try: + return self.sim_manager.update(sim_id, await json_body(request)) + except (SimSettingsError, KeyError) as e: + return sim_error(e) + + @self.app.delete("/api/sims/{sim_id}") + async def delete_sim(sim_id: str): + try: + self.sim_manager.delete(sim_id) + except KeyError as e: + return sim_error(e) + return {"deleted": sim_id} + + @self.app.post("/api/sims/{sim_id}/{action}") + async def sim_action(sim_id: str, action: str): + if action not in ("connect", "disconnect") or self.sim_manager.repo.get(sim_id) is None: + return JSONResponse(status_code=404, content={"error": "Unknown simulator"}) + try: + await getattr(self.sim_manager, action)(sim_id) + except KeyError: + return JSONResponse(status_code=409, content={"error": "Turn this simulator on first"}) + return self.sim_manager.status() + + @self.app.websocket("/ws/sims") + async def websocket_sims(websocket: WebSocket) -> None: + await self.sim_connection_manager.connect(websocket) + try: + await websocket.send_json({"type": "sim_status", "sims": self.sim_manager.status()}) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + self.sim_connection_manager.disconnect(websocket) + except Exception: + self.sim_connection_manager.disconnect(websocket) + @self.app.post("/api/internal/shot-result") - async def receive_shot_result(request: Request) -> Dict[str, str]: + async def receive_shot_result(request: Request) -> Dict[str, Any]: """Receives shot results from the C++ pitrac_lm process via HTTP POST.""" body = await request.json() - result_type_int = int(body.get("result_type", 0)) + result_type_int = int(body.get("result_type") or 0) result_type_str = self.parser._get_result_type_string(result_type_int) - speed_mps = float(body.get("speed_mps", 0)) - message = str(body.get("message", "")) + speed_mps = float(body.get("speed_mps") or 0.0) + message = str(body.get("message") or "") + + # Club changes the C++ already applied; not for the dashboard, history or sims + if result_type_str == "Control Message": + return self._shot_result_reply() is_status = result_type_str in self.parser._get_status_message_strings() - is_fake_hit = result_type_int == 7 and message in [ - "Club type was set", "Test message", "Configuration update", - ] - if is_status or is_fake_hit: - current = self.shot_store.get() - shot_data = ShotData( - speed=current.speed, - carry=current.carry, - launch_angle=current.launch_angle, - side_angle=current.side_angle, - back_spin=current.back_spin, - side_spin=current.side_spin, + if is_status: + shot_data = replace( + self.shot_store.get(), result_type=result_type_str, message=message, timestamp=datetime.now().isoformat(), ) else: + speed_mph = speed_mps * MPS_TO_MPH + launch_angle = float(body.get("launch_angle") or 0.0) + side_angle = float(body.get("side_angle") or 0.0) shot_data = ShotData( - speed=round(speed_mps * MPS_TO_MPH, 1), - carry=float(body.get("carry", 0)), - launch_angle=round(float(body.get("launch_angle", 0)), 1), - side_angle=round(float(body.get("side_angle", 0)), 1), - back_spin=int(body.get("back_spin", 0)), - side_spin=int(body.get("side_spin", 0)), + speed=round(speed_mph, 1), + carry=float(body.get("carry") or 0.0), + launch_angle=round(launch_angle, 1), + side_angle=round(side_angle, 1), + back_spin=int(body.get("back_spin") or 0), + side_spin=int(body.get("side_spin") or 0), result_type=result_type_str, message=message, timestamp=datetime.now().isoformat(), ) + # Sims report results against this id, so it has to exist before they get the shot + shot_data.shot_id = body.get("shot_id") or int(datetime.now().timestamp() * 1000) + shot_data.images = list(body.get("images") or []) + # The sims round for their own protocol, so they get the values before display rounding + sim_shot = replace(shot_data, speed=speed_mph, launch_angle=launch_angle, side_angle=side_angle) + self._run_in_background(self.sim_manager.on_shot(sim_shot)) + if result_type_str == "Hit": + images = [(Path(p).stem, p) for p in shot_data.images] + await asyncio.to_thread(self._persist_shot, shot_data.shot_id, shot_data, images) self.shot_store.update(shot_data) await self.connection_manager.broadcast(shot_data.to_dict()) - return {"status": "ok"} + if is_status: + self._run_in_background(self.sim_manager.on_status(result_type_str)) + return self._shot_result_reply() @self.app.post("/api/internal/image-ready") async def receive_image_ready(request: Request) -> Dict[str, str]: @@ -308,11 +425,17 @@ async def receive_image_ready(request: Request) -> Dict[str, str]: }) return {"status": "ok"} + @self.app.get("/api/internal/config") + async def internal_config() -> Dict[str, Any]: + """Merged config consumed by pitrac_lm at startup (replaces the generated JSON file).""" + return await asyncio.to_thread(self.config_manager.build_generated_config) + @self.app.get("/health") async def health_check() -> Dict[str, Union[str, bool, int]]: pitrac_running = False try: - result = subprocess.run( + result = await asyncio.to_thread( + subprocess.run, ["pgrep", "-f", "pitrac_lm"], capture_output=True, text=True, @@ -384,6 +507,11 @@ async def get_config_metadata() -> Dict[str, Any]: metadata = self.config_manager.load_configurations_metadata() return metadata.get("settings", {}) + @self.app.get("/api/config/calibrated") + async def get_calibrated_keys() -> List[str]: + """Keys whose current value comes from the calibration layer""" + return self.config_manager.get_calibrated_keys() + @self.app.get("/api/config/diff") async def get_config_diff() -> Dict[str, Any]: """Get differences between user settings and defaults""" @@ -414,10 +542,6 @@ async def update_config(key: str, request: Request): success, message, requires_restart = self.config_manager.set_config(key, value) if success: - # Rebuild the generated config so the C++ process picks up changes - generated_config_path = self.config_manager.generate_golf_sim_config() - logger.info(f"Generated config file at: {generated_config_path}") - # Broadcast update to WebSocket clients await self.connection_manager.broadcast( { @@ -446,9 +570,8 @@ async def reset_config(): success, message = self.config_manager.reset_all() if success: - self.config_manager.generate_golf_sim_config() await self.connection_manager.broadcast({"type": "config_reset"}) - return {"success": True, "message": message} + return {"success": True, "message": message, "calibration_kept": True} return JSONResponse(status_code=500, content={"error": message}) @@ -457,7 +580,6 @@ async def reload_config(): """Reload configuration from disk""" try: self.config_manager.reload() - self.config_manager.generate_golf_sim_config() return {"status": "Configuration reloaded"} except Exception as e: logger.error(f"Failed to reload config: {e}") @@ -473,12 +595,11 @@ async def import_config(request: Request): """Import configuration from exported data""" try: config_data = await request.json() - success, message = self.config_manager.import_config(config_data) + success, message, skipped = self.config_manager.import_config(config_data) if success: - self.config_manager.generate_golf_sim_config() await self.connection_manager.broadcast({"type": "config_import"}) - return {"success": True, "message": message} + return {"success": True, "message": message, "skipped": skipped} return JSONResponse(status_code=400, content={"error": message}) except Exception as e: @@ -492,6 +613,8 @@ async def start_pitrac() -> Dict[str, Any]: safety = self.strobe_calibration_manager.is_strobe_safe() if not safety["safe"]: return {"status": "error", "message": safety["reason"]} + if refusal := self._testing_tool_refusal(): + return refusal result = await self.pitrac_manager.start() logger.info(f"PiTrac start request: {result}") return result @@ -501,6 +624,8 @@ async def stop_pitrac() -> Dict[str, Any]: """Stop the PiTrac launch monitor process""" result = await self.pitrac_manager.stop() logger.info(f"PiTrac stop request: {result}") + if result.get("status") == "stopped": + self.session_repo.close_open(datetime.now().isoformat()) return result @self.app.post("/api/pitrac/restart") @@ -509,6 +634,8 @@ async def restart_pitrac() -> Dict[str, Any]: safety = self.strobe_calibration_manager.is_strobe_safe() if not safety["safe"]: return {"status": "error", "message": safety["reason"]} + if refusal := self._testing_tool_refusal(): + return refusal result = await self.pitrac_manager.restart() logger.info(f"PiTrac restart request: {result}") return result @@ -552,6 +679,10 @@ async def run_auto_calibration(camera: str) -> Dict[str, Any]: return {"status": "error", "message": safety["reason"]} if self.calibration_manager.loop is None: return {"status": "error", "message": "Server still starting up, please retry in a moment"} + if self.pitrac_manager.is_running(): + return {"status": "error", "message": "Stop PiTrac before calibrating"} + if reason := self._camera_busy(camera): + return {"status": "error", "message": reason} return await self.calibration_manager.run_auto_calibration(camera) @self.app.post("/api/calibration/manual/{camera}") @@ -661,7 +792,7 @@ async def distortion_camera_feed(websocket: WebSocket) -> None: await websocket.close() return - self._active_cameras[camera_index] = "distortion-feed" + self._active_cameras[camera_index] = LENS_CALIBRATION_FEED # Read actual resolution after first frame (accurate for rpicam-vid) ret, first_frame = await asyncio.to_thread(cap.read) @@ -711,7 +842,7 @@ async def distortion_camera_feed(websocket: WebSocket) -> None: }) # Draw coverage grid overlay (single blend pass) - status = self.calibration_manager.calibration_status.get(camera, {}) + status = self.calibration_manager.distortion_status.get(camera, {}) cov = status.get("coverage") if cov and cov.get("grid"): dh, dw = display.shape[:2] @@ -796,7 +927,7 @@ async def undistort_preview_feed(websocket: WebSocket) -> None: await websocket.close() return - self._active_cameras[camera_index] = "undistort-preview" + self._active_cameras[camera_index] = LENS_PREVIEW # Read actual resolution and precompute undistort maps ret, first_frame = await asyncio.to_thread(cap.read) @@ -888,6 +1019,11 @@ async def run_distortion_calibration(camera: str, request: Request) -> Dict[str, return {"status": "error", "message": "Server still starting up, please retry in a moment"} if self.pitrac_manager.is_running(): return {"status": "error", "message": "Stop PiTrac before running distortion calibration"} + reason = self._camera_busy(camera, own_feed=LENS_CALIBRATION_FEED) or ( + self.calibration_manager.distortion_precheck(camera) + ) + if reason: + return {"status": "error", "message": reason} target_images = 40 try: @@ -905,9 +1041,54 @@ async def run_distortion_calibration(camera: str, request: Request) -> Dict[str, return {"status": "started", "message": f"Distortion calibration started for {camera}"} @self.app.post("/api/calibration/stop") - async def stop_calibration() -> Dict[str, Any]: - """Stop any running calibration process""" - return await self.calibration_manager.stop_calibration() + async def stop_calibration(request: Request) -> Dict[str, Any]: + """Stop running calibration jobs. Optional body {camera, kind} narrows which ones.""" + try: + body = await request.json() + except ValueError: + body = None + if not isinstance(body, dict): + body = {} + camera, kind = body.get("camera"), body.get("kind") + if camera not in (None, "camera1", "camera2") or kind not in (None, "ball", "distortion"): + return {"status": "error", "message": "Invalid camera or kind"} + return await self.calibration_manager.stop_calibration(camera, kind) + + @self.app.get("/api/setup/status") + async def setup_status() -> Dict[str, Any]: + """What a new build still needs: strobe calibration and camera calibrations. Simulators are optional.""" + board_version = self.config_manager.get_config("gs_config.strobing.kConnectionBoardVersion") + board_version = int(board_version) if board_version is not None else None + safety = self.strobe_calibration_manager.is_strobe_safe() + calibration = self.calibration_manager.get_calibration_data() + updated_at = self.config_manager.calibration_updated_at + cameras = { + f"camera{n}": { + "type": self.config_manager.get_config(f"cameras.slot{n}.type"), + "lens_calibrated": calibration[f"camera{n}"]["lens_calibrated"], + "position_calibrated": calibration[f"camera{n}"]["position_calibrated"], + "lens_updated_at": updated_at(f"gs_config.cameras.kCamera{n}CalibrationMatrix"), + "position_updated_at": max( + filter(None, (updated_at(f"gs_config.cameras.kCamera{n}{key}") for key in ("FocalLength", "Angles"))), + default=None, + ), + } + for n in (1, 2) + } + sims = [{key: sim[key] for key in ("id", "name", "type", "on", "status")} for sim in self.sim_manager.status()] + return { + "board_version": board_version, + "strobe": { + "required": board_version == 3, + "safe": safety["safe"], + "reason": safety.get("reason", ""), + "updated_at": updated_at(self.strobe_calibration_manager.DAC_CONFIG_KEY), + }, + "cameras": cameras, + "simulator": {"instances": sims, "connected": [sim["name"] for sim in sims if sim["status"] == "connected"]}, + "complete": safety["safe"] + and all(c["lens_calibrated"] and c["position_calibrated"] for c in cameras.values()), + } @self.app.get("/testing", response_class=HTMLResponse) async def testing_page(request: Request) -> Response: @@ -922,15 +1103,18 @@ async def get_testing_tools() -> Dict[str, Any]: @self.app.post("/api/testing/run/{tool_id}") async def run_testing_tool(tool_id: str) -> Dict[str, Any]: """Run a specific testing tool""" + if tool_id not in self.testing_manager.tools: + return {"status": "error", "message": f"Unknown tool: {tool_id}"} safety = self.strobe_calibration_manager.is_strobe_safe() if not safety["safe"]: return {"status": "error", "message": safety["reason"]} if self.pitrac_manager.is_running(): return { "status": "error", - "message": "Cannot run testing tools while PiTrac is running. Please stop PiTrac first.", + "message": "Cannot run testing tools while PiTrac is running. Stop PiTrac first.", } + self.testing_manager.last_results.pop(tool_id, None) task = asyncio.create_task(self._run_tool_async(tool_id)) self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) @@ -944,15 +1128,12 @@ async def stop_testing_tool(tool_id: str) -> Dict[str, Any]: @self.app.get("/api/testing/status") async def get_testing_status() -> Dict[str, Any]: - """Get status of running testing tools""" - running = self.testing_manager.get_running_tools() - - results = {} - if hasattr(self.testing_manager, "completed_results"): - results = self.testing_manager.completed_results - self.testing_manager.completed_results = {} - - return {"running": running, "results": results} + """Get running testing tools and the last result of each tool""" + return { + "running": self.testing_manager.get_running_tools(), + "elapsed": {t: int(time.time() - s) for t, s in self.testing_manager.started_at.items()}, + "results": self.testing_manager.last_results, + } @self.app.post("/api/testing/upload-image") async def upload_test_image(file: UploadFile = File(...)) -> Dict[str, Any]: @@ -1094,6 +1275,43 @@ async def detect_cameras() -> Dict[str, Any]: }, } + # Shot history API + @self.app.get("/api/sessions") + async def list_sessions(limit: int = 50, offset: int = 0) -> Dict[str, Any]: + rows = await asyncio.to_thread(self.session_repo.list, limit + 1, offset) + return {"sessions": rows[:limit], "has_more": len(rows) > limit} + + @self.app.get("/api/sessions/{session_id}/shots") + async def list_shots_for_session(session_id: int) -> list: + session = await asyncio.to_thread(self.session_repo.get, session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + return await asyncio.to_thread(self.shot_repo.list_for_session, session_id) + + @self.app.get("/api/shots/{shot_id}") + async def get_shot(shot_id: int) -> Dict[str, Any]: + shot = await asyncio.to_thread(self.shot_repo.get, shot_id) + if shot is None: + raise HTTPException(status_code=404, detail="Shot not found") + return shot + + @self.app.delete("/api/sessions/{session_id}") + async def delete_session(session_id: int) -> Dict[str, Any]: + session = await asyncio.to_thread(self.session_repo.get, session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + shot_ids = await asyncio.to_thread(self.shot_repo.shot_ids_for_session, session_id) + await asyncio.to_thread(self.session_repo.delete, session_id) + for shot_id in shot_ids: + await asyncio.to_thread(self.retention.remove_shot_dir, shot_id) + return {"status": "deleted", "session_id": session_id} + + @self.app.get("/api/storage/usage") + async def storage_usage() -> Dict[str, Any]: + shots_dir = IMAGES_DIR / "shots" + used_bytes = await asyncio.to_thread(_dir_bytes, shots_dir) + return {"used_mb": used_bytes // (1024 * 1024), "cap_mb": self.image_cap_mb} + @self.app.get("/api/cameras/types") async def get_camera_types() -> Dict[str, Any]: """Get available camera types and their descriptions""" @@ -1112,8 +1330,8 @@ async def get_branches() -> Dict[str, Any]: return await self.update_manager.get_branches() @self.app.get("/api/update/check") - async def check_for_updates() -> Dict[str, Any]: - return await self.update_manager.check_for_updates() + async def check_for_updates(branch: Optional[str] = None) -> Dict[str, Any]: + return await self.update_manager.check_for_updates(branch=branch) @self.app.post("/api/update/start") async def start_update(request: Request) -> Dict[str, Any]: @@ -1140,6 +1358,11 @@ async def cancel_update() -> Dict[str, Any]: async def update_status() -> Dict[str, Any]: return self.update_manager.get_status() + @self.app.get("/history", response_class=HTMLResponse) + async def history_page(request: Request) -> Response: + """Serve shot history page""" + return self.templates.TemplateResponse(request, "history.html") + @self.app.get("/logs", response_class=HTMLResponse) async def logs_page(request: Request) -> Response: """Serve logs viewer page""" @@ -1174,7 +1397,7 @@ async def get_log_services() -> Dict[str, List[Dict[str, Any]]]: services.append( { "id": "pitrac", - "name": "PiTrac Camera 1", + "name": "Launch monitor", "status": "running" if pitrac_status["is_running"] else "stopped", "pid": pitrac_status.get("pid"), } @@ -1191,12 +1414,60 @@ async def get_log_services() -> Dict[str, List[Dict[str, Any]]]: return {"services": services} + @self.app.get("/api/logs/history") + async def logs_history( + service: str = "pitrac", + file: Optional[str] = None, + before: Optional[int] = None, + lines: int = 200, + ) -> Dict[str, Any]: + """Scrollback for the per-run logs: page backwards across run files via a cursor.""" + lines = max(1, min(lines, 1000)) + + # journald scrollback is out of scope; only the file-backed pitrac log pages. + if service != "pitrac": + return {"lines": [], "next": None} + + log_dir = self.pitrac_manager.log_dir + run_logs = await asyncio.to_thread(list_run_logs, log_dir) # oldest → newest + if not run_logs: + return {"lines": [], "next": None} + + by_name = {p.name: p for p in run_logs} + if file is not None: + path = by_name.get(file) + if path is None: # unknown name → don't serve (also blocks traversal) + return {"lines": [], "next": None} + else: + path = run_logs[-1] + + def _prev_cursor() -> Optional[Dict[str, Any]]: + idx = run_logs.index(path) + if idx == 0: + return None # reached the very beginning + prev = run_logs[idx - 1] + return {"file": prev.name, "offset": prev.stat().st_size} + + if before is None: + result_lines, offset = await asyncio.to_thread(tail_lines, path, lines) + next_cursor = {"file": path.name, "offset": offset} if offset > 0 else _prev_cursor() + return {"lines": result_lines, "next": next_cursor} + + # Stale cursor: the truncate backstop shrank the file under us; tell the client + # to re-anchor from the tail rather than serving lines past the new end. + size = await asyncio.to_thread(lambda: path.stat().st_size) + if before > size: + return {"lines": [], "next": {"file": path.name, "offset": size}, "reset": True} + + result_lines, new_offset = await asyncio.to_thread(read_chunk_before, path, before, lines) + next_cursor = {"file": path.name, "offset": new_offset} if new_offset > 0 else _prev_cursor() + return {"lines": result_lines, "next": next_cursor} + async def _stream_service_logs(self, websocket: WebSocket, service: str) -> None: """Stream logs for a specific service via WebSocket""" try: if service == "pitrac": - log_file = self.pitrac_manager.log_file - await self._stream_file_logs(websocket, log_file) + await self._stream_file_logs(websocket) elif service == "pitrac-web": await self._stream_systemd_logs(websocket, "pitrac-web") else: @@ -1276,43 +1547,71 @@ async def _stream_systemd_logs(self, websocket: WebSocket, unit: str) -> None: except Exception as e: logger.error(f"Error streaming systemd logs: {e}") - async def _stream_file_logs(self, websocket: WebSocket, log_file: Path) -> None: - """Stream logs from a file""" + async def _stream_file_logs(self, websocket: WebSocket) -> None: + """Stream the active per-run log: anchor + historical tail, then follow new lines. + + Tracks the latest run file rather than a fixed path — a pitrac restart rolls a new + per-run log, so when latest_run_log changes mid-view we re-anchor on the new file. + """ + log_dir = self.pitrac_manager.log_dir try: - if not log_file.exists(): - await websocket.send_json({"message": f"Log file not found: {log_file}", "level": "warning"}) + current = await asyncio.to_thread(latest_run_log, log_dir) + if current is None: + current = self.pitrac_manager.log_file + if not current.exists(): + await websocket.send_json({"message": f"Log file not found: {current}", "level": "warning"}) return - with open(log_file, "r") as f: - lines = f.readlines() - recent = lines[-100:] if len(lines) > 100 else lines - for line in recent: - await websocket.send_json({"message": line.rstrip(), "historical": True}) - - follow_proc = await asyncio.create_subprocess_exec( - "tail", - "-f", - str(log_file), - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - ) - - if follow_proc.stdout: - async for line in follow_proc.stdout: + # Outer loop: each pass anchors on `current`, then follows it until the + # latest run file changes (restart) — then breaks out to re-resolve. + poll = 0.5 + while True: + lines, offset = await asyncio.to_thread(tail_lines, current, 100) + await websocket.send_json({"type": "anchor", "file": current.name, "offset": offset}) + for line in lines: + await websocket.send_json({"message": line, "historical": True}) + + follower = follow(current, poll_interval=poll) + switched = False + while not switched: try: - await websocket.send_json( - { - "message": line.decode("utf-8", errors="replace").rstrip(), - "historical": False, - } - ) - except WebSocketDisconnect: - follow_proc.terminate() - return - + # Bound the wait so an idle old file still lets us notice a restart. + line = await asyncio.wait_for(follower.__anext__(), timeout=poll + 0.25) + await websocket.send_json({"message": line, "historical": False}) + except asyncio.TimeoutError: + pass + latest = await asyncio.to_thread(latest_run_log, log_dir) + if latest is not None and latest != current: + current = latest + switched = True + await follower.aclose() + + except WebSocketDisconnect: + return except Exception as e: logger.error(f"Error streaming file logs: {e}") + async def _maintenance_loop(self) -> None: + while not self.shutdown_flag: + await asyncio.sleep(300) + # Each backstop runs independently; one failing must not skip the others. + try: + await asyncio.to_thread(self.retention.prune) + except Exception: + logger.exception("retention prune failed; will retry next cycle") + try: + await asyncio.to_thread( + prune_run_logs, self.pitrac_manager.log_dir, self.pitrac_manager.log_dir_cap_bytes + ) + except Exception: + logger.exception("run-log dir prune failed; will retry next cycle") + try: + await asyncio.to_thread( + truncate_if_over, self.pitrac_manager.log_file, self.pitrac_manager.run_log_cap_bytes + ) + except Exception: + logger.exception("run-log truncate failed; will retry next cycle") + async def startup_event(self) -> None: logger.info("Starting PiTrac Web Server...") loop = asyncio.get_event_loop() @@ -1326,35 +1625,104 @@ async def startup_event(self) -> None: "V3 DAC not initialized — strobe calibration required before PiTrac can run" ) + task = asyncio.create_task(self._maintenance_loop()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + + task = asyncio.create_task(self._detect_cameras_if_unset()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + + await self.sim_manager.start() + logger.info("PiTrac Web Server ready — receiving results via HTTP POST") + async def _detect_cameras_if_unset(self) -> None: + if not self.db.created or self.config_manager.found_legacy_json: + return + try: + result = await asyncio.to_thread(lambda: CameraDetector().detect()) + except Exception: + logger.exception("Camera detection failed; using default camera settings") + return + cameras = result.get("cameras", []) + if not cameras: + logger.info("No cameras detected; using default camera settings") + return + for slot in range(1, min(len(cameras), 2) + 1): + detected = result["configuration"][f"slot{slot}"] + for field in ("type", "lens"): + key, value = f"cameras.slot{slot}.{field}", str(detected[field]) + valid, _ = self.config_manager.validate_config(key, value) + if valid: + self.config_manager.set_config(key, value) + else: + logger.warning(f"Detected {key} = {value} is not a supported option; keeping the default") + logger.info(f"Saved detected settings for {len(cameras)} camera(s)") + + def _camera_busy(self, camera: str, own_feed: Optional[str] = None) -> Optional[str]: + if reason := self.calibration_manager.busy_reason(camera): + return reason + feed = self._active_cameras.get(0 if camera == "camera1" else 1) + if feed and feed != own_feed: + return f"{camera.replace('camera', 'Camera ')} is in use by the {feed}. Close it first." + return None + + def _testing_tool_refusal(self) -> Optional[Dict[str, Any]]: + running = self.testing_manager.get_running_tools() + if not running: + return None + name = self.testing_manager.tools[running[0]]["name"] + return {"status": "error", "message": f"{name} is running. Stop it before starting PiTrac."} + async def _run_tool_async(self, tool_id: str) -> None: """Helper method to run a testing tool asynchronously""" try: result = await self.testing_manager.run_tool(tool_id) - - if not hasattr(self.testing_manager, "completed_results"): - self.testing_manager.completed_results = {} - self.testing_manager.completed_results[tool_id] = result - logger.info(f"Testing tool {tool_id} completed with status: {result.get('status')}") except Exception as e: logger.error(f"Error running testing tool {tool_id}: {e}") - if not hasattr(self.testing_manager, "completed_results"): - self.testing_manager.completed_results = {} - self.testing_manager.completed_results[tool_id] = {"status": "error", "message": str(e)} + result = {"status": "error", "message": str(e)} + self.testing_manager.last_results[tool_id] = result + + def _persist_shot(self, shot_id, shot_data, images): + session_id = self.session_repo.ensure_open(shot_data.timestamp, self.session_timeout_minutes) + self.shot_repo.add(shot_id, session_id, shot_data, images) + + async def _store_sim_result(self, sim_id: str, shot_id: int, data: Dict[str, Any]) -> None: + try: + await asyncio.to_thread( + self.shot_repo.add_sim_result, shot_id, sim_id, data, datetime.now().isoformat() + ) + except sqlite3.IntegrityError: + logger.warning(f"Simulator result for shot {shot_id} has no saved shot to attach to, dropped") + + def _run_in_background(self, coro) -> None: + task = asyncio.create_task(coro) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + + def _shot_result_reply(self) -> Dict[str, Any]: + reply: Dict[str, Any] = {"status": "ok", "armed": self.sim_manager.armed} + if self.sim_manager.club: + reply["club"] = self.sim_manager.club + return reply async def shutdown_event(self) -> None: logger.info("Shutting down PiTrac Web Server...") self.shutdown_flag = True - for task in self.background_tasks: + await self.sim_manager.stop() + + loop = asyncio.get_event_loop() + current_tasks = {t for t in self.background_tasks if t.get_loop() is loop} + for task in current_tasks: if not task.done(): task.cancel() - if self.background_tasks: - await asyncio.gather(*self.background_tasks, return_exceptions=True) + if current_tasks: + await asyncio.gather(*current_tasks, return_exceptions=True) for ws in self.connection_manager.connections: try: diff --git a/Software/web-server/sim_interface.py b/Software/web-server/sim_interface.py new file mode 100644 index 00000000..6b0e0345 --- /dev/null +++ b/Software/web-server/sim_interface.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import Awaitable, Callable, Dict, List, Optional + +from models import ShotData + +logger = logging.getLogger(__name__) + +StatusCallback = Callable[[], Awaitable[None]] + +STATUS_OFF = "off" +STATUS_CONNECTING = "connecting" +STATUS_CONNECTED = "connected" +STATUS_ERROR = "error" + + +class SimInterface(ABC): + """Base for a single simulator output target. + + A sim holds its own connection and converts a ShotData into whatever its + simulator expects. SimManager owns the collection and the status broadcast. + """ + + type = "sim" + display_name = "Simulator" + # The settings form: [{key, label, type ("host" or "integer"), default, required, min, max, advanced}] + FIELDS: List[Dict[str, object]] = [] + + def __init__(self) -> None: + self._status = STATUS_OFF + self._detail = "" + self._status_callback: Optional[StatusCallback] = None + + def set_status_callback(self, callback: StatusCallback) -> None: + self._status_callback = callback + + @property + def status(self) -> str: + return self._status + + async def _set_status(self, status: str, detail: str = "") -> None: + if status == self._status and detail == self._detail: + return + self._status = status + self._detail = detail + if self._status_callback is not None: + try: + await self._status_callback() + except Exception as e: + logger.warning(f"sim status callback failed: {e}") + + def info(self) -> Dict[str, str]: + return {"status": self._status, "detail": self._detail} + + @abstractmethod + async def connect(self) -> None: + ... + + @abstractmethod + async def disconnect(self) -> None: + ... + + @abstractmethod + async def send_shot(self, shot: ShotData) -> None: + ... + + async def on_ball_state(self, ball_detected: bool) -> None: + pass diff --git a/Software/web-server/sim_manager.py b/Software/web-server/sim_manager.py new file mode 100644 index 00000000..d2056b59 --- /dev/null +++ b/Software/web-server/sim_manager.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import asyncio +import functools +import inspect +import logging +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Type + +from config_manager import HOST_ERROR, _is_valid_host, coerce +from db.repositories import KeyValueRepository, SimulatorRepository +from models import ShotData +from sim_interface import STATUS_CONNECTED, STATUS_ERROR, STATUS_OFF, SimInterface +from sims.e6_sim import E6Sim +from sims.gspro_sim import GSProSim +from sims.ogs_sim import OGSSim + +logger = logging.getLogger(__name__) + +SIM_TYPES: Dict[str, Type[SimInterface]] = {cls.type: cls for cls in (GSProSim, E6Sim, OGSSim)} + +# The C++ sent a ball-detected heartbeat when it found the ball, just before the stabilization status +_BALL_DETECTED_BY_STATUS = { + "Waiting For Ball": False, + "Waiting For Placement To Stabilize": True, + "Ball Placed": True, +} + +BroadcastFn = Callable[[Dict[str, object]], Awaitable[None]] +ResultFn = Callable[[str, int, Dict[str, Any]], Awaitable[None]] + + +class SimSettingsError(ValueError): + def __init__(self, fields: Dict[str, str]): + self.fields = fields + super().__init__("; ".join(fields.values())) + + +def validate_settings(cls: Type[SimInterface], settings: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, str]]: + """The type's fields from settings with defaults filled in, and an error per field that does not validate""" + clean: Dict[str, Any] = {} + errors: Dict[str, str] = {} + for field in cls.FIELDS: + key, label = field["key"], field["label"] + value = settings.get(key, field.get("default")) + if field["type"] == "host": + value = "" if value is None else str(value).strip() + if not value and field.get("required"): + errors[key] = f"{label} is required" + elif not _is_valid_host(value): + errors[key] = HOST_ERROR + else: + try: + value = coerce("integer", value) + except ValueError: + errors[key] = f"{label} must be a whole number" + continue + if not field["min"] <= value <= field["max"]: + errors[key] = f"{label} must be between {field['min']} and {field['max']}" + if key not in errors: + clean[key] = value + return clean, errors + + +def _sim_type(name: Any) -> Type[SimInterface]: + cls = SIM_TYPES.get(name) + if cls is None: + raise SimSettingsError({"type": f"Unknown simulator type: {name}"}) + return cls + + +def _valid_settings(cls: Type[SimInterface], settings: Any) -> Dict[str, Any]: + if not isinstance(settings, dict): + raise SimSettingsError({"settings": "Settings must be an object"}) + clean, errors = validate_settings(cls, settings) + if errors: + raise SimSettingsError(errors) + return clean + + +def _object(body: Any) -> Dict[str, Any]: + if not isinstance(body, dict): + raise SimSettingsError({"body": "Send a JSON object"}) + return body + + +def _on(value: Any) -> bool: + try: + return coerce("boolean", value) + except ValueError: + raise SimSettingsError({"on": "On must be true or false"}) from None + + +def import_legacy_settings(settings: KeyValueRepository, sims: SimulatorRepository) -> bool: + """Turn the old one-per-type simulators..* settings into instances and remove them, in one write""" + stored = settings.load() + old = {key: value for key, value in stored.items() if key.startswith("simulators.")} + if not old: + return False + existing = {(s["type"], s["settings"].get("host"), s["settings"].get("port")) for s in sims.list()} + with settings.db.transaction() as conn: + for cls in SIM_TYPES.values(): + prefix = f"simulators.{cls.type}." + values = {key[len(prefix):]: value for key, value in old.items() if key.startswith(prefix)} + try: + on = coerce("boolean", values.get("enabled", False)) + except ValueError: + on = False + host = values.get("host") + if not on and not (isinstance(host, str) and host.strip()): + continue + clean, errors = validate_settings(cls, values) + for key, error in errors.items(): + logger.warning(f"Old {cls.display_name} setting {key}={values.get(key)!r} not kept: {error}") + fields = {field["key"]: field.get("default") for field in cls.FIELDS} | clean + if (cls.type, fields.get("host"), fields.get("port")) in existing: + logger.info(f"Old {cls.display_name} settings match a saved simulator, not adding another") + continue + sims.add_in(conn, cls.type, cls.display_name, on, fields) + logger.info(f"Moved the old {cls.display_name} settings into a simulator named {cls.display_name}") + settings.replace_all_in(conn, {key: value for key, value in stored.items() if key not in old}) + return True + + +class SimManager: + RELOAD_DEBOUNCE_SEC = 0.5 + TEST_TIMEOUT_SEC = 5 + + def __init__( + self, + repo: SimulatorRepository, + broadcast: Optional[BroadcastFn] = None, + on_result: Optional[ResultFn] = None, + ) -> None: + self.repo = repo + self._broadcast = broadcast + self._on_result = on_result + self._sims: Dict[str, SimInterface] = {} + self._build_errors: Dict[str, str] = {} + self._running = False + self._reload_tasks: Dict[str, asyncio.Task] = {} + self._connect_tasks: Dict[str, asyncio.Task] = {} + self._reload_lock = asyncio.Lock() + self._ball_detected: Optional[bool] = None + self._club: Optional[str] = None + self._club_sim: Optional[str] = None + + # -- Instances -- + + def types(self) -> List[Dict[str, Any]]: + return [{"type": cls.type, "display_name": cls.display_name, "fields": cls.FIELDS} for cls in SIM_TYPES.values()] + + def _default_name(self, cls: Type[SimInterface], exclude_id: Optional[str] = None) -> str: + taken = {inst["name"] for inst in self.repo.list() if inst["id"] != exclude_id} + name, n = cls.display_name, 1 + while name in taken: + n += 1 + name = f"{cls.display_name} {n}" + return name + + def create(self, body: Any) -> Dict[str, Any]: + body = _object(body) + cls = _sim_type(body.get("type")) + settings = _valid_settings(cls, body.get("settings", {})) + name = str(body.get("name") or "").strip() or self._default_name(cls) + inst = self.repo.add(cls.type, name, _on(body.get("on", True)), settings) + self._schedule_reload(inst["id"]) + return self._payload(inst) + + def update(self, sim_id: str, body: Any) -> Dict[str, Any]: + body = _object(body) + inst = self.repo.get(sim_id) + if inst is None: + raise KeyError(sim_id) + cls = _sim_type(inst["type"]) + if body.get("type", inst["type"]) != inst["type"]: + raise SimSettingsError({"type": "The type of a saved simulator can't change"}) + settings = body.get("settings", {}) + if isinstance(settings, dict): + settings = {**inst["settings"], **settings} + settings = _valid_settings(cls, settings) + name = inst["name"] + if "name" in body: + name = str(body["name"] or "").strip() or self._default_name(cls, exclude_id=sim_id) + on = _on(body["on"]) if "on" in body else inst["on"] + inst = self.repo.update(sim_id, name, on, settings) + self._schedule_reload(sim_id) + return self._payload(inst) + + def delete(self, sim_id: str) -> None: + if not self.repo.delete(sim_id): + raise KeyError(sim_id) + self._schedule_reload(sim_id) + + def _payload(self, inst: Dict[str, Any]) -> Dict[str, Any]: + cls = SIM_TYPES.get(inst["type"]) + sim = self._sims.get(inst["id"]) + if sim is not None: + live = sim.info() + elif inst["id"] in self._build_errors: + live = {"status": STATUS_ERROR, "detail": self._build_errors[inst["id"]]} + else: + live = {"status": STATUS_OFF, "detail": ""} + host, port = inst["settings"].get("host"), inst["settings"].get("port") + return { + "id": inst["id"], + "type": inst["type"], + "name": inst["name"], + "display_type": cls.display_name if cls else inst["type"], + "on": inst["on"], + "status": live["status"], + "target": f"{host}:{port}" if host else "", + "detail": live["detail"], + "settings": inst["settings"], + } + + def status(self) -> List[Dict[str, Any]]: + return [self._payload(inst) for inst in self.repo.list()] + + # -- Running sims -- + + def _build(self, inst: Dict[str, Any]) -> None: + try: + cls = _sim_type(inst["type"]) + settings = _valid_settings(cls, inst["settings"]) + hooks = { + "on_club": functools.partial(self.set_club, inst["id"]), + "on_result": functools.partial(self._record_result, inst["id"]), + "ball_state": lambda: bool(self._ball_detected), + } + accepted = inspect.signature(cls).parameters + sim = cls(**settings, **{key: hook for key, hook in hooks.items() if key in accepted}) + except (TypeError, ValueError, OverflowError) as e: + logger.error(f"Simulator {inst['name']} settings are invalid, not started: {e}") + self._build_errors[inst["id"]] = f"Invalid settings: {e}" + return + sim.set_status_callback(functools.partial(self._on_sim_status, inst["id"], sim)) + self._sims[inst["id"]] = sim + + def build_sims(self) -> None: + self._sims = {} + self._build_errors = {} + for inst in self.repo.list(): + if inst["on"]: + self._build(inst) + + def _connect_soon(self, sim_id: str) -> None: + self._connect_tasks[sim_id] = asyncio.create_task(self._sims[sim_id].connect()) + + async def start(self) -> None: + self._running = True + self.build_sims() + for sim_id in self._sims: + self._connect_soon(sim_id) + await self._broadcast_status() + + async def stop(self) -> None: + self._running = False + for task in self._reload_tasks.values(): + task.cancel() + self._reload_tasks = {} + for sim_id in list(self._sims): + await self._stop_sim(sim_id) + + def _schedule_reload(self, sim_id: str) -> None: + if not self._running: + return + if sim_id in self._reload_tasks: + self._reload_tasks[sim_id].cancel() + self._reload_tasks[sim_id] = asyncio.create_task(self._reload_after_debounce(sim_id)) + + async def _reload_after_debounce(self, sim_id: str) -> None: + await asyncio.sleep(self.RELOAD_DEBOUNCE_SEC) + async with self._reload_lock: + await self._stop_sim(sim_id) + self._sims.pop(sim_id, None) + self._build_errors.pop(sim_id, None) + inst = self.repo.get(sim_id) + if inst is not None and inst["on"]: + self._build(inst) + if sim_id in self._sims: + self._connect_soon(sim_id) + await self._broadcast_status() + + async def _stop_sim(self, sim_id: str) -> None: + task = self._connect_tasks.pop(sim_id, None) + if task is not None: + task.cancel() + sim = self._sims.get(sim_id) + if sim is None: + return + try: + await sim.disconnect() + except Exception as e: + logger.warning(f"sim {sim_id} disconnect failed: {e}") + + async def on_shot(self, shot: ShotData) -> None: + # A reload can drop or add a sim while a send is awaited + for sim_id, sim in list(self._sims.items()): + if sim.status != STATUS_CONNECTED: + continue + try: + await sim.send_shot(shot) + except Exception as e: + logger.warning(f"sim {sim_id} send_shot failed: {e}") + + async def on_status(self, result_type: str) -> None: + ball_detected = _BALL_DETECTED_BY_STATUS.get(result_type) + if ball_detected is None or ball_detected == self._ball_detected: + return + self._ball_detected = ball_detected + # A reload can drop or add a sim while a send is awaited + for sim_id, sim in list(self._sims.items()): + if sim.status != STATUS_CONNECTED: + continue + try: + await sim.on_ball_state(ball_detected) + except Exception as e: + logger.warning(f"sim {sim_id} on_ball_state failed: {e}") + + @property + def armed(self) -> bool: + return all( + sim.armed for sim in self._sims.values() if sim.status == STATUS_CONNECTED and hasattr(sim, "armed") + ) + + @property + def club(self) -> Optional[str]: + return self._club + + async def _record_result(self, sim_id: str, shot_id: int, data: Dict[str, Any]) -> None: + if self._on_result is not None: + await self._on_result(sim_id, shot_id, data) + + def set_club(self, sim_id: str, club: str) -> None: + self._club = club + self._club_sim = sim_id + + async def connect(self, sim_id: str) -> None: + await (await self._current_sim(sim_id)).connect() + + async def disconnect(self, sim_id: str) -> None: + await (await self._current_sim(sim_id)).disconnect() + + async def _current_sim(self, sim_id: str) -> SimInterface: + # Waits out a reload in progress, so the request reaches the rebuilt sim and not one being torn down + async with self._reload_lock: + sim = self._sims.get(sim_id) + if sim is None: + raise KeyError(sim_id) + return sim + + async def test_connection(self, body: Any) -> Dict[str, Any]: + """Connect a throwaway sim, handshake included, and disconnect it; never sends a shot""" + body = _object(body) + cls = _sim_type(body.get("type")) + settings = _valid_settings(cls, body.get("settings", {})) + where = f"{cls.display_name} at {settings['host']}:{settings['port']}" + sim = cls(**settings) + settled = asyncio.Event() + + async def on_status() -> None: + if sim.status in (STATUS_CONNECTED, STATUS_ERROR): + settled.set() + + sim.set_status_callback(on_status) + try: + await asyncio.wait_for(asyncio.gather(sim.connect(), settled.wait()), self.TEST_TIMEOUT_SEC) + ok, detail = sim.status == STATUS_CONNECTED, sim.info()["detail"] + except asyncio.TimeoutError: + ok, detail = False, f"no answer within {self.TEST_TIMEOUT_SEC} seconds" + finally: + await sim.disconnect() + if ok: + return {"ok": True, "message": f"Connected to {where}"} + return {"ok": False, "message": f"Could not connect to {where}: {detail}"} + + async def _on_sim_status(self, sim_id: str, sim: SimInterface) -> None: + if sim_id == self._club_sim and sim.status != STATUS_CONNECTED: + self._club = None + self._club_sim = None + await self._broadcast_status() + + async def _broadcast_status(self) -> None: + if self._broadcast is None: + return + try: + await self._broadcast({"type": "sim_status", "sims": self.status()}) + except Exception as e: + logger.warning(f"sim status broadcast failed: {e}") diff --git a/Software/web-server/sims/__init__.py b/Software/web-server/sims/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/Software/web-server/sims/e6_auth.py b/Software/web-server/sims/e6_auth.py new file mode 100644 index 00000000..bfdf1817 --- /dev/null +++ b/Software/web-server/sims/e6_auth.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import ctypes +import json +from typing import Optional + +LIB_PATH = "/usr/lib/pitrac/libpitrac_e6.so" +_BUFFER_SIZE = 4096 +_CHALLENGE_TYPES = ("Handshake", "Challenge") + +_lib: Optional[ctypes.CDLL] = None + + +class E6AuthError(RuntimeError): + pass + + +class E6AuthUnavailable(E6AuthError): + pass + + +def _load() -> ctypes.CDLL: + global _lib + if _lib is None: + try: + lib = ctypes.CDLL(LIB_PATH) + process = lib.e6_process + except (OSError, AttributeError) as e: + raise E6AuthUnavailable(f"e6 auth library missing: {LIB_PATH}") from e + process.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t] + process.restype = ctypes.c_int + _lib = lib + return _lib + + +def is_challenge(text: str) -> bool: + try: + message = json.loads(text) + except ValueError: + return False + return isinstance(message, dict) and message.get("Type") in _CHALLENGE_TYPES and "Challenge" in message + + +def challenge_response(text: str) -> str: + """Answer an E6 authentication challenge (a Handshake or Challenge message carrying a Challenge field).""" + # Other messages reach code in the closed-source object that crashes this process + if not is_challenge(text): + raise E6AuthError("not an e6 authentication challenge") + buf = ctypes.create_string_buffer(_BUFFER_SIZE) + length = _load().e6_process(text.encode(), buf, _BUFFER_SIZE) + if length < 0: + raise E6AuthError(f"e6_process returned {length}") + try: + return buf.value.decode() + except UnicodeDecodeError as e: + raise E6AuthError("e6_process reply is not utf-8") from e diff --git a/Software/web-server/sims/e6_sim.py b/Software/web-server/sims/e6_sim.py new file mode 100644 index 00000000..3e14152e --- /dev/null +++ b/Software/web-server/sims/e6_sim.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio +import logging +from typing import Callable, Dict, Optional + +from models import ShotData +from sim_interface import STATUS_CONNECTED, STATUS_CONNECTING, STATUS_ERROR +from sims import e6_auth +from sims.tcp_sim import HOST_FIELD, TcpSim, clamp, port_field, round1 + +logger = logging.getLogger(__name__) + +_CLUBS = {"Putter": "putter", "Driver": "driver"} +_ZERO_CLUB_DATA = dict.fromkeys(("ClubHeadSpeed", "ClubAngleFace", "ClubAnglePath", "ClubHeadSpeedMPH"), 0.0) + + +def build_ball_data(shot: ShotData) -> Dict[str, object]: + # The ranges E6 enforces + ball = { + "BackSpin": clamp(float(shot.back_spin), -999, 19999), + "BallSpeed": clamp(float(shot.speed), 0.09, 249.9), + "LaunchAngle": float(shot.launch_angle), + "LaunchDirection": float(shot.side_angle), + "SideSpin": clamp(float(shot.side_spin), -5999, 5999), + } + return {"Type": "SetBallData", "BallData": {key: round1(value) for key, value in ball.items()}} + + +class E6Sim(TcpSim): + type = "e6" + display_name = "E6 Connect" + FIELDS = [ + HOST_FIELD, + port_field(2483), + {"key": "inter_message_delay_ms", "label": "Delay between shot messages (ms)", "type": "integer", + "default": 50, "min": 0, "max": 500, "advanced": True}, + ] + READS = True + CONNECTED_ON_OPEN = False + AUTH_TIMEOUT_SEC = 10 + + def __init__( + self, + host: str, + port: int = 2483, + inter_message_delay_ms: int = 50, + on_club: Optional[Callable[[str], None]] = None, + auth: Callable[[str], str] = e6_auth.challenge_response, + ) -> None: + super().__init__(host, port) + self.inter_message_delay_ms = max(0, int(inter_message_delay_ms)) + self.armed = False + self._on_club = on_club + self._auth = auth + + def _teardown_socket(self) -> None: + super()._teardown_socket() + self.armed = False + + async def _on_connected(self) -> None: + await self._send_obj({"Type": "Handshake"}) + self._spawn(self._auth_deadline()) + + async def _auth_deadline(self) -> None: + await asyncio.sleep(self.AUTH_TIMEOUT_SEC) + if self.status == STATUS_CONNECTING: + await self._set_status(STATUS_ERROR, "E6 did not authenticate") + self._schedule_reconnect() + + async def disconnect(self) -> None: + if self._writer is not None: + try: + await asyncio.wait_for(self._send_obj({"Type": "Disconnect"}), self.DISCONNECT_TIMEOUT_SEC) + except Exception as e: + logger.warning(f"E6 disconnect message failed: {e}") + await super().disconnect() + + async def _set_armed(self, armed: bool) -> None: + self.armed = armed + await self._set_status(self.status, f"{self.host}:{self.port}" + (" armed" if armed else "")) + + async def _give_up(self, detail: str) -> None: + # Reconnecting cannot fix authentication, so stay down until the user connects again + logger.warning(f"E6: {detail}") + self._want_connected = False + await self._set_status(STATUS_ERROR, detail) + self._teardown_socket() + + async def send_shot(self, shot: ShotData) -> None: + if not self.armed: + logger.warning("E6 shot not sent: E6 is not armed") + return + delay = self.inter_message_delay_ms / 1000 + await self._send_or_reconnect(build_ball_data(shot)) + await asyncio.sleep(delay) + await self._send_or_reconnect({"Type": "SetClubData", "ClubData": _ZERO_CLUB_DATA}) + await asyncio.sleep(delay) + await self._send_or_reconnect({"Type": "SendShot"}) + await self._set_armed(False) + + async def _on_message(self, obj: object, raw: str = "") -> None: + if not isinstance(obj, dict): + logger.warning(f"E6 sent an unexpected message: {obj!r}") + return + msg_type = obj.get("Type") + if e6_auth.is_challenge(raw): + # The exact text both ways: the object's reply is not always valid JSON, and the C++ sent it as is + try: + reply = self._auth(raw) + if not reply: + raise e6_auth.E6AuthError("no reply to the challenge") + except e6_auth.E6AuthError as e: + await self._give_up(f"E6 authentication failed: {e}") + return + await self._send_or_reconnect(reply.encode()) + elif msg_type == "Authentication": + if obj.get("Success") in ("true", True): + logger.info("E6 authenticated") + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + else: + await self._give_up(f"E6 authentication failed: {obj.get('Message') or obj}") + elif msg_type == "SimCommand": + await self._on_command(obj.get("SubType"), obj.get("Details")) + elif msg_type == "ACK": + logger.debug(f"E6 ACK: {obj.get('SubType')}") + elif msg_type in ("Warning", "ShotError"): + logger.warning(f"E6 {msg_type}: {obj}") + else: + logger.info(f"E6 sent {obj}") + + async def _on_command(self, sub_type: object, details: object) -> None: + if sub_type == "Ping": + await self._send_or_reconnect({"Type": "Pong"}) + elif sub_type == "Arm": + await self._set_armed(True) + elif sub_type == "Disarm": + await self._set_armed(False) + elif sub_type == "PlayerDataModified": + club_type = details.get("ClubType") if isinstance(details, dict) else None + club = _CLUBS.get(club_type) + logger.info(f"E6 player data: club {club_type!r}" + ("" if club else ", ignored")) + if club and self._on_club is not None: + self._on_club(club) + elif sub_type == "ShotComplete": + logger.info(f"E6 shot complete: {details}") + else: + logger.info(f"E6 sim command {sub_type!r}: {details}") diff --git a/Software/web-server/sims/gspro_sim.py b/Software/web-server/sims/gspro_sim.py new file mode 100644 index 00000000..b0a2a4c6 --- /dev/null +++ b/Software/web-server/sims/gspro_sim.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import itertools +import json +import logging +import math +from typing import Callable, Dict, Optional + +from models import ShotData +from sims.tcp_sim import HOST_FIELD, TcpSim, port_field, round1 + +logger = logging.getLogger(__name__) + +_MAX_SPEED_MPH = 200.0 +# Process-wide, so numbers survive reconnects and sim rebuilds +_shot_numbers = itertools.count(1) +_ZERO_CLUB_DATA = { + key: 0.0 + for key in ( + "Speed", + "AngleOfAttack", + "FaceToTarget", + "Lie", + "Loft", + "Path", + "SpeedAtImpact", + "VerticalFaceImpact", + "HorizontalFaceImpact", + "ClosureRate", + ) +} + + +def _spin_axis(back: float, side: float) -> float: + # Same formula as GsResults::GetSpinAxis, so a negative back spin keeps the C++ sign + if abs(side) <= 0.0001: + return 0.0 + if back == 0: + return math.copysign(90.0, side) + return math.degrees(math.atan(side / back + 0.00001)) + + +def _message(shot_number: int, ball: Dict[str, float], heartbeat: bool, ball_detected: bool) -> Dict[str, object]: + return { + "DeviceID": "PiTrac LM 0.1", + "Units": "Yards", + "ShotNumber": shot_number, + "APIversion": "1", + "BallData": {key: round1(value) for key, value in ball.items()}, + "ClubData": _ZERO_CLUB_DATA, + "ShotDataOptions": { + "ContainsBallData": not heartbeat, + "ContainsClubData": False, + "LaunchMonitorIsReady": True, + "LaunchMonitorBallDetected": ball_detected, + "IsHeartBeat": heartbeat, + }, + } + + +def build_shot_payload(shot: ShotData, shot_number: int) -> Dict[str, object]: + back = float(shot.back_spin) + side = float(shot.side_spin) + ball = { + "Speed": min(float(shot.speed), _MAX_SPEED_MPH), + "SpinAxis": _spin_axis(back, side), + "TotalSpin": math.hypot(back, side), + "BackSpin": back, + "SideSpin": side, + "HLA": float(shot.side_angle), + "VLA": float(shot.launch_angle), + } + return _message(shot_number, ball, heartbeat=False, ball_detected=True) + + +def build_heartbeat(ball_detected: bool) -> Dict[str, object]: + ball = dict.fromkeys(("Speed", "SpinAxis", "TotalSpin", "BackSpin", "SideSpin", "HLA", "VLA"), 0.0) + return _message(0, ball, heartbeat=True, ball_detected=ball_detected) + + +class GSProSim(TcpSim): + type = "gspro" + display_name = "GSPro" + FIELDS = [HOST_FIELD, port_field(921)] + READS = True + + def __init__( + self, + host: str, + port: int = 921, + on_club: Optional[Callable[[str], None]] = None, + ball_state: Callable[[], bool] = lambda: False, + ) -> None: + super().__init__(host, port) + self._on_club = on_club + self._ball_state = ball_state + + def _encode(self, obj: Dict[str, object]) -> bytes: + # The layout boost's write_json gave the C++ sender + return (json.dumps(obj, indent=4) + "\n").encode("utf-8") + + async def _on_connected(self) -> None: + await self._send_obj(build_heartbeat(self._ball_state())) + + async def on_ball_state(self, ball_detected: bool) -> None: + if self._writer is None: + return + try: + await self._send_or_reconnect(build_heartbeat(ball_detected)) + except Exception: + pass + + async def send_shot(self, shot: ShotData) -> None: + if self._writer is None: + raise ConnectionError("GSPro not connected") + await self._send_or_reconnect(build_shot_payload(shot, next(_shot_numbers))) + + async def _on_message(self, obj: object, raw: str = "") -> None: + if not isinstance(obj, dict): + logger.warning(f"GSPro sent an unexpected message: {obj!r}") + return + message = obj.get("Message", "") + try: + code = int(obj.get("Code", 0)) + except (TypeError, ValueError): + logger.warning(f"GSPro sent a message with no usable Code: {obj!r}") + return + if code == 201: + player = obj.get("Player") or {} + # Any club but PT counts as the driver + club = "putter" if player.get("Club") == "PT" else "driver" + logger.info(f"GSPro player info: club {player.get('Club')!r}, handed {player.get('Handed')!r}") + if self._on_club is not None: + self._on_club(club) + elif code == 200: + logger.info(f"GSPro: {message}") + else: + logger.warning(f"GSPro returned {code}: {message}") + await self._set_status(self.status, f"{self.host}:{self.port} GSPro {code}: {message}") diff --git a/Software/web-server/sims/ogs_sim.py b/Software/web-server/sims/ogs_sim.py new file mode 100644 index 00000000..4ede977c --- /dev/null +++ b/Software/web-server/sims/ogs_sim.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import math +from typing import Any, Awaitable, Callable, Dict, Optional, Union + +from models import ShotData +from sims.tcp_sim import HOST_FIELD, TcpSim, clamp, port_field + +logger = logging.getLogger(__name__) + +# OGS parses each socket read as one JSON message, so two messages landing in one read are both dropped +_MIN_SEND_GAP_SEC = 0.1 + + +def build_device_status(ready: bool) -> Dict[str, object]: + return {"type": "device", "status": "ready" if ready else "busy"} + + +def build_shot_payload(shot: ShotData) -> Dict[str, object]: + back = float(shot.back_spin) + side = float(shot.side_spin) + spin_speed = round(math.hypot(back, side)) + spin_axis = math.degrees(math.atan2(side, back)) if (back or side) else 0.0 + return { + "type": "shot", + "unit": "imperial", + "shot": { + "ballSpeed": round(float(shot.speed), 1), + "verticalLaunchAngle": round(clamp(float(shot.launch_angle), 0, 45), 1), + "horizontalLaunchAngle": round(clamp(float(shot.side_angle), -45, 45), 1), + "spinSpeed": spin_speed, + "spinAxis": round(clamp(spin_axis, -45, 45), 1), + }, + } + + +class OGSSim(TcpSim): + type = "ogs" + display_name = "OpenGolfSim" + READS = True + FIELDS = [ + HOST_FIELD, + port_field(3111), + {"key": "keepalive_sec", "label": "Keepalive (seconds)", "type": "integer", + "default": 5, "min": 1, "max": 60, "advanced": True}, + ] + + def __init__( + self, + host: str, + port: int = 3111, + keepalive_sec: int = 5, + ball_state: Callable[[], bool] = lambda: False, + on_result: Optional[Callable[[int, Dict[str, Any]], Awaitable[None]]] = None, + ) -> None: + super().__init__(host, port) + self.keepalive_sec = max(1, int(keepalive_sec)) + self._ball_state = ball_state + self._on_result = on_result + self._awaiting_result: Optional[int] = None + self._keepalive_task: Optional[asyncio.Task] = None + self._send_lock = asyncio.Lock() + self._last_send = 0.0 + + def _teardown_socket(self) -> None: + super()._teardown_socket() + self._keepalive_task = None + + def _encode(self, obj: Dict[str, object]) -> bytes: + return (json.dumps(obj) + "\n").encode("utf-8") + + async def _send_obj(self, obj: Union[Dict[str, object], bytes]) -> None: + async with self._send_lock: + loop = asyncio.get_running_loop() + wait = self._last_send + _MIN_SEND_GAP_SEC - loop.time() + if wait > 0: + await asyncio.sleep(wait) + await super()._send_obj(obj) + self._last_send = loop.time() + + async def _on_connected(self) -> None: + await self._send_obj(build_device_status(self._ball_state())) + self._keepalive_task = self._spawn(self._keepalive_loop()) + + async def on_ball_state(self, ball_detected: bool) -> None: + if self._writer is None: + return + try: + await self._send_or_reconnect(build_device_status(ball_detected)) + except Exception: + pass + + async def send_shot(self, shot: ShotData) -> None: + if self._writer is None: + raise ConnectionError("OGS not connected") + await self._send_or_reconnect(build_shot_payload(shot)) + self._awaiting_result = shot.shot_id + + async def _on_message(self, obj: object, raw: str = "") -> None: + if not isinstance(obj, dict): + return + if obj.get("type") == "player": + # Club IDs are logged, not acted on, until we know what OGS sends for the putter + data = obj.get("data") or {} + logger.info(f"OGS player update: club {data.get('club')!r}") + elif obj.get("type") == "result": + # ponytail: pairs a result with the last shot sent and takes only the first one; + # match on the echoed data.shot if results ever start arriving after the next shot + shot_id, self._awaiting_result = self._awaiting_result, None + data = obj.get("data") or {} + logger.info(f"OGS result for shot {shot_id}: {data.get('result')!r}") + if shot_id is not None and self._on_result is not None: + await self._on_result(shot_id, data) + elif obj.get("status") == 400: + logger.warning(f"OGS rejected a message: {obj.get('error')!r}") + + async def _keepalive_loop(self) -> None: + while self._want_connected and self._writer is not None: + await asyncio.sleep(self.keepalive_sec) + try: + await self._send_or_reconnect(build_device_status(self._ball_state())) + except Exception: + return diff --git a/Software/web-server/sims/tcp_sim.py b/Software/web-server/sims/tcp_sim.py new file mode 100644 index 00000000..374a40ed --- /dev/null +++ b/Software/web-server/sims/tcp_sim.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import math +from typing import Coroutine, Dict, List, Optional, Union + +from sim_interface import ( + SimInterface, + STATUS_CONNECTED, + STATUS_CONNECTING, + STATUS_ERROR, + STATUS_OFF, +) + +logger = logging.getLogger(__name__) + +_RECONNECT_BACKOFF_SEC = [1, 2, 5, 10] +_MAX_UNPARSED_CHARS = 64 * 1024 + +HOST_FIELD = {"key": "host", "label": "Host", "type": "host", "required": True, "default": ""} + + +def port_field(default: int) -> Dict[str, object]: + return {"key": "port", "label": "Port", "type": "integer", "default": default, "min": 1, "max": 65535} + + +def clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def round1(value: float) -> float: + # std::round in GsResults::FormatDoubleAsString rounds halves away from zero + return math.copysign(math.floor(abs(value) * 10 + 0.5), value) / 10 + + +class TcpSim(SimInterface): + """A sim that is a TCP client of its simulator, with reconnect. + + Subclasses frame messages in _encode, send their greeting in _on_connected, + and set READS to have each JSON object the simulator sends, with its raw text, passed to _on_message. + One that must authenticate first sets CONNECTED_ON_OPEN False and reports connected itself. + """ + + CONNECT_TIMEOUT_SEC = 5 + DISCONNECT_TIMEOUT_SEC = 2 + READS = False + CONNECTED_ON_OPEN = True + + def __init__(self, host: str, port: int) -> None: + super().__init__() + self.host = host + self.port = port + self._writer: Optional[asyncio.StreamWriter] = None + self._reader: Optional[asyncio.StreamReader] = None + self._tasks: List[asyncio.Task] = [] + self._reconnect_task: Optional[asyncio.Task] = None + self._want_connected = False + self._conn_lock = asyncio.Lock() + + async def connect(self) -> None: + self._want_connected = True + await self._open() + + async def _open(self) -> None: + async with self._conn_lock: + if self._writer is not None: + return + if not self.host: + await self._set_status(STATUS_ERROR, "no host configured") + return + await self._set_status(STATUS_CONNECTING, f"{self.host}:{self.port}") + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), self.CONNECT_TIMEOUT_SEC + ) + if self._want_connected: + await self._on_connected() + except asyncio.CancelledError: + self._teardown_socket() + await self._set_status(STATUS_OFF, "") + raise + except Exception as e: + if not self._want_connected: + return + logger.warning(f"{self.display_name} connect failed: {e!r}") + if isinstance(e, asyncio.TimeoutError): + detail = f"no answer within {self.CONNECT_TIMEOUT_SEC} seconds" + else: + detail = str(e) or type(e).__name__ + await self._set_status(STATUS_ERROR, detail) + self._schedule_reconnect() + return + # A disconnect during any await above already tore down and set the status + if not self._want_connected or self._writer is None: + self._teardown_socket() + return + if self.CONNECTED_ON_OPEN: + await self._set_status(STATUS_CONNECTED, f"{self.host}:{self.port}") + if self.READS and self._reader is not None: + self._spawn(self._read_loop()) + + async def _on_connected(self) -> None: + pass + + def _encode(self, obj: Dict[str, object]) -> bytes: + return json.dumps(obj).encode("utf-8") + + async def _send_obj(self, obj: Union[Dict[str, object], bytes]) -> None: + if self._writer is None: + raise ConnectionError("not connected") + self._writer.write(obj if isinstance(obj, bytes) else self._encode(obj)) + await self._writer.drain() + + async def _send_or_reconnect(self, obj: Union[Dict[str, object], bytes]) -> None: + try: + await self._send_obj(obj) + except Exception as e: + logger.warning(f"{self.display_name} send failed: {e}") + await self._set_status(STATUS_ERROR, str(e)) + self._schedule_reconnect() + raise + + async def _on_message(self, obj: object, raw: str = "") -> None: + pass + + async def _read_loop(self) -> None: + decoder = json.JSONDecoder() + reader = self._reader + buf = "" + try: + while True: + chunk = await reader.read(4096) + if not chunk: + break + buf += chunk.decode("utf-8", errors="replace") + while buf := buf.lstrip(): + try: + obj, end = decoder.raw_decode(buf) + except json.JSONDecodeError: + if buf[0] != "{" or len(buf) > _MAX_UNPARSED_CHARS: + logger.warning(f"{self.display_name} sent unparseable data, dropped: {buf[:200]!r}") + buf = "" + break + raw, buf = buf[:end], buf[end:] + try: + await self._on_message(obj, raw) + except Exception as e: + logger.warning(f"{self.display_name} message handling failed: {e!r}") + if self._reader is not reader: + return + except (ConnectionError, OSError) as e: + logger.warning(f"{self.display_name} read failed: {e}") + await self._set_status(STATUS_ERROR, "connection closed") + self._schedule_reconnect() + + def _spawn(self, coro: Coroutine) -> asyncio.Task: + task = asyncio.create_task(coro) + self._tasks.append(task) + return task + + def _schedule_reconnect(self) -> None: + self._teardown_socket() + if not self._want_connected: + return + if self._reconnect_task and not self._reconnect_task.done(): + return + self._reconnect_task = asyncio.create_task(self._reconnect_loop()) + + async def _reconnect_loop(self) -> None: + attempt = 0 + while self._want_connected and self._writer is None: + delay = _RECONNECT_BACKOFF_SEC[min(attempt, len(_RECONNECT_BACKOFF_SEC) - 1)] + await asyncio.sleep(delay) + if not self._want_connected: + return + await self._open() + attempt += 1 + + def _teardown_socket(self) -> None: + for task in self._tasks: + task.cancel() + self._tasks = [] + if self._writer is not None: + try: + self._writer.close() + except Exception: + pass + self._writer = None + self._reader = None + + async def disconnect(self) -> None: + self._want_connected = False + if self._reconnect_task and not self._reconnect_task.done(): + self._reconnect_task.cancel() + writer = self._writer + self._teardown_socket() + if writer is not None: + try: + await asyncio.wait_for(writer.wait_closed(), self.DISCONNECT_TIMEOUT_SEC) + except Exception: + # A peer that stopped reading leaves buffered data, so drop it rather than wait + writer.transport.abort() + await self._set_status(STATUS_OFF, "") diff --git a/Software/web-server/static/css/calibration.css b/Software/web-server/static/css/calibration.css index 926cbf94..eb8c35f8 100644 --- a/Software/web-server/static/css/calibration.css +++ b/Software/web-server/static/css/calibration.css @@ -1,111 +1,12 @@ /* Calibration page */ -/* ---- Camera selection cards ---- */ -.camera-option input:checked + .card { - border-color: var(--color-primary) !important; - box-shadow: 0 0 12px oklch(59% 0.17 270 / 0.3); -} - -/* ---- Wizard steps ---- */ -.wizard-steps { - position: relative; - z-index: 0; -} - -/* Connecting line behind the circles */ -.wizard-line { - position: absolute; - top: 20px; - left: 10%; - right: 10%; - height: 2px; - background: var(--color-base-300); - z-index: 0; -} - -.wizard-steps .step { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - z-index: 1; - flex: 1; -} - -.step-circle { - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 0.9rem; - background: var(--color-base-300); - color: var(--color-base-content); - border: 2px solid var(--color-base-300); - transition: all 0.3s; -} - -.step-label { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.6; - transition: all 0.3s; -} - -.wizard-steps .step.active .step-circle { - background: var(--color-primary); - color: var(--color-primary-content); - border-color: var(--color-primary); - box-shadow: 0 0 12px rgba(102, 126, 234, 0.4); -} - -.wizard-steps .step.active .step-label { - opacity: 1; - color: var(--color-primary); -} - -.wizard-steps .step.completed .step-circle { - background: var(--color-success); - color: var(--color-primary-content); - border-color: var(--color-success); -} - -.wizard-steps .step.completed .step-label { - opacity: 0.7; -} - -/* ---- Progress bars ---- */ -div.progress-bar { - display: block; - width: 100%; - height: 10px; - background: var(--color-base-300); - border-radius: 9999px; - overflow: hidden; - margin-bottom: 0.25rem; -} - -div.progress-bar > .progress-fill { - display: block; - height: 100%; - background: linear-gradient(135deg, var(--color-primary), var(--color-secondary)); - border-radius: 9999px; - transition: width 0.3s ease; - width: 0%; -} - /* ---- Camera preview ---- */ .camera-preview { + position: relative; width: 100%; height: 300px; background: #000; border-radius: 0.5rem; - margin-bottom: 0.75rem; - position: relative; overflow: hidden; } @@ -115,81 +16,7 @@ div.progress-bar > .progress-fill { object-fit: contain; } -.camera-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - opacity: 0.5; -} - -/* ---- Ball status ---- */ -.ball-status { - padding: 0.75rem; - border-radius: 0.25rem; - font-size: 0.875rem; -} - -.ball-status.success { - background: oklch(70% 0.14 160 / 0.15); - border: 1px solid oklch(70% 0.14 160); - color: oklch(70% 0.14 160); -} - -.ball-status.error { - background: oklch(65% 0.19 25 / 0.15); - border: 1px solid oklch(65% 0.19 25); - color: oklch(65% 0.19 25); -} - -/* ---- Completion details ---- */ -.completion-details { - color: oklch(70% 0.14 160); - line-height: 1.4; -} - -/* ---- Terminal log ---- */ -.log-content { - background: #000; - border: 1px solid var(--color-base-300, oklch(15% 0.008 275)); - border-radius: 0.25rem; - padding: 1rem; - height: 200px; - overflow-y: auto; - font-family: ui-monospace, monospace; - font-size: 0.85rem; - color: #0f0; -} - -/* ---- Calibration data grid ---- */ -.calibration-data-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 0.75rem; -} - -.calibration-data-item { - background: var(--color-base-300, oklch(15% 0.008 275)); - padding: 0.75rem 1rem; - border-radius: 0.5rem; - display: flex; - justify-content: space-between; - align-items: center; - border: 1px solid var(--color-base-300, oklch(15% 0.008 275)); -} - -.calibration-data-label { - opacity: 0.6; - font-size: 0.875rem; -} - -.calibration-data-value { - font-weight: 600; - font-family: ui-monospace, monospace; -} - -/* ---- Distortion coverage grid (ChArUco) ---- */ +/* ---- Lens coverage grid (ChArUco) ---- */ .coverage-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -216,13 +43,3 @@ div.progress-bar > .progress-fill { .coverage-cell[data-count="3"] { background: var(--color-success); } - -/* ---- Light theme ---- */ -[data-theme="light"] .calibration-data-item { - background: var(--color-base-200); - border-color: var(--color-base-300); -} - -[data-theme="light"] .log-content { - border-color: var(--color-base-300); -} diff --git a/Software/web-server/static/css/config.css b/Software/web-server/static/css/config.css index 0836dc95..3c8ed3ee 100644 --- a/Software/web-server/static/css/config.css +++ b/Software/web-server/static/css/config.css @@ -1,466 +1,42 @@ -/* Config page — only what daisyUI/Tailwind can't cover */ +/* Config page rows and groups rendered by config.js */ -/* Status message coloring via JS-set classes (success/error/warning/info) */ -.status-message:empty { display: none; } -.status-message.success { color: var(--color-success); } -.status-message.error { color: var(--color-error); } -.status-message.warning { color: var(--color-warning); } -.status-message.info { color: var(--color-info); } - -/* Category sidebar items — JS creates these dynamically */ -.category-item { - padding: 0.6rem 1rem; - margin-bottom: 0.125rem; - border-radius: 0.5rem; - cursor: pointer; - color: color-mix(in oklch, var(--color-base-content) 60%, transparent); - transition: background 0.15s, color 0.15s; -} - -.category-item:hover { - background: var(--color-base-300); - color: var(--color-base-content); -} - -.category-item.active { - background: var(--color-primary); - color: var(--color-primary-content); -} - -.category-item[data-category="Basic"] { - font-weight: 600; -} - -.category-item[data-category="Basic"]::after { - content: "*"; - float: right; - color: var(--color-warning); -} - -.category-item.active[data-category="Basic"]::after { - color: var(--color-primary-content); -} - -/* Config groups and sections rendered by JS */ -.config-group { - margin-bottom: 2rem; - animation: fadeIn 0.25s ease-out; -} -.config-group:last-child { margin-bottom: 0; } -.config-group[style*="display: none"] { animation: none; } - -.config-group-title { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.75rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--color-base-300); -} - -.config-main-section-header { - margin: 2rem 0 1.25rem; - padding-bottom: 0.75rem; - border-bottom: 2px solid var(--color-base-300); -} -.config-main-section-header:first-child { margin-top: 0; } -.config-main-section-header h2 { - margin: 0; - font-size: 1.5rem; - font-weight: 600; - color: var(--color-primary); -} - -.config-section { margin-bottom: 3rem; } -.config-section:last-child { margin-bottom: 0; } - -.config-section-header { - margin: 1.5rem 0 0.75rem; - display: flex; - align-items: center; - gap: 0.5rem; -} -.config-section-header:first-of-type { margin-top: 0; } - -.config-section-header .section-label { - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.6; - padding: 0.2rem 0.6rem; - background: var(--color-base-300); - border-radius: 0.25rem; -} - -/* Each config row — 3-column grid */ -.config-item { +.config-row { display: grid; - grid-template-columns: minmax(280px, 1fr) minmax(180px, 1fr) auto; - gap: 1.25rem; - align-items: start; - padding: 0.75rem; - border-radius: 0.5rem; - border-bottom: 1px solid color-mix(in oklch, var(--color-base-300) 50%, transparent); - transition: background 0.15s; -} -.config-item:last-child { border-bottom: none; } -.config-item:hover { background: color-mix(in oklch, var(--color-base-300) 30%, transparent); } - -.config-item.modified { - border-left: 3px solid var(--color-warning); - padding-left: calc(0.75rem - 3px); -} - -.config-item.user-set { - background: color-mix(in oklch, var(--color-primary) 3%, transparent); - border-left: 2px solid var(--color-primary); - padding-left: calc(0.75rem - 2px); -} - -.config-item.modified.user-set { - border-left: 3px solid var(--color-warning); - padding-left: calc(0.75rem - 3px); + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; + padding: 0.75rem 0; + border-bottom: 1px solid color-mix(in oklch, var(--color-base-300) 70%, transparent); } +.config-row:last-child { border-bottom: none; } +.config-row[hidden] { display: none; } -.config-item.using-default { - opacity: 0.85; +.config-row.is-pending { + box-shadow: inset 3px 0 0 var(--color-warning); + padding-left: 0.75rem; } -/* Label column */ -.config-label { +.config-row-control { display: flex; flex-direction: column; - gap: 0.2rem; -} - -.config-label-name { - font-weight: 600; - font-size: 0.95rem; -} - -.config-description { - font-size: 0.825rem; - opacity: 0.6; - line-height: 1.4; - margin-top: 0.15rem; - margin-bottom: 0.35rem; -} - -.config-label .key { - font-family: ui-monospace, monospace; - font-size: 0.7rem; - opacity: 0.4; - background: var(--color-base-300); - padding: 0.1rem 0.35rem; - border-radius: 0.2rem; - display: inline-block; - word-break: break-all; -} - -/* Input styling — keep .config-input for JS querySelector */ -.config-input { - width: 100%; - padding: 0.45rem 0.65rem; - background: var(--color-base-100); - border: 1px solid var(--color-base-300); - border-radius: 0.375rem; - color: var(--color-base-content); - font-size: 0.85rem; - font-family: ui-monospace, monospace; - transition: border-color 0.15s, box-shadow 0.15s; -} - -.config-input:focus { - outline: none; - border-color: var(--color-primary); - box-shadow: 0 0 0 2px color-mix(in oklch, var(--color-primary) 15%, transparent); -} - -.config-input.modified { - border-color: var(--color-warning); - background: color-mix(in oklch, var(--color-warning) 5%, transparent); + align-items: flex-start; + gap: 0.25rem; + min-width: 0; } +.config-row-control .row-actions:empty { display: none; } -.config-input.default-value { - color: color-mix(in oklch, var(--color-base-content) 45%, transparent); - font-style: italic; -} -.config-input.default-value::placeholder { - color: color-mix(in oklch, var(--color-base-content) 35%, transparent); - font-style: normal; -} - -textarea.config-input { - resize: vertical; - min-height: 3rem; - line-height: 1.4; -} - -select.config-input { - cursor: pointer; -} - -/* Input wrapper for the clear-value button overlay */ -.input-container { - display: flex; - align-items: center; - gap: 0.75rem; -} -.input-container select, -.input-container input { flex: 1; min-width: 0; } - -.input-wrapper { - position: relative; - display: flex; - align-items: center; - width: 100%; -} -.input-wrapper .config-input { padding-right: 2rem; } -.input-wrapper select.config-input { padding-right: 2.5rem; } - -.clear-value-btn { - position: absolute; - right: 0.4rem; - background: transparent; - border: none; - color: color-mix(in oklch, var(--color-base-content) 35%, transparent); - font-size: 1.2rem; - cursor: pointer; - width: 1.4rem; - height: 1.4rem; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - transition: background 0.15s, color 0.15s; -} -.clear-value-btn:hover { - background: color-mix(in oklch, var(--color-error) 10%, transparent); - color: var(--color-error); -} - -/* Default badge */ -.default-badge { - display: inline-block; - padding: 0.1rem 0.35rem; - margin-left: 0.4rem; - background: var(--color-base-300); - color: color-mix(in oklch, var(--color-base-content) 45%, transparent); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.05em; - border-radius: 0.2rem; - border: 1px solid var(--color-base-300); - vertical-align: middle; -} - -/* Restart indicator */ -.restart-indicator { - color: var(--color-warning); - font-size: 0.85em; - margin-left: 0.4rem; - cursor: help; -} - -/* Config actions column */ -.config-actions { - display: flex; - gap: 0.4rem; - align-items: center; -} -.config-actions button { - padding: 0.3rem 0.6rem; - background: color-mix(in oklch, var(--color-base-300) 50%, transparent); - border: 1px solid var(--color-base-300); - border-radius: 0.3rem; - color: color-mix(in oklch, var(--color-base-content) 60%, transparent); - font-size: 0.75rem; - cursor: pointer; - transition: background 0.15s; -} -.config-actions button:hover { - background: var(--color-base-300); - color: var(--color-base-content); -} - -/* Validation errors */ -.validation-error { - color: var(--color-error); - font-size: 0.825rem; - margin-top: 0.2rem; - padding: 0.2rem 0.5rem; - background: color-mix(in oklch, var(--color-error) 5%, transparent); - border-radius: 0.25rem; - border-left: 3px solid var(--color-error); -} - -/* Settings counter (injected by JS into status bar) */ -.settings-counter { - display: flex; - gap: 1rem; - padding: 0.5rem 0.75rem; - background: color-mix(in oklch, var(--color-base-300) 40%, transparent); - border-radius: 0.5rem; - font-size: 0.85rem; - align-items: center; -} - -.settings-counter span { - display: flex; - align-items: center; - gap: 0.4rem; - padding: 0.2rem 0.6rem; - border-radius: 1rem; - background: var(--color-base-100); - cursor: help; -} - -.counter-custom { border: 1px solid var(--color-primary); color: var(--color-primary); } -.counter-default { border: 1px solid color-mix(in oklch, var(--color-base-content) 30%, transparent); color: color-mix(in oklch, var(--color-base-content) 40%, transparent); } -.counter-total { border: 1px solid var(--color-base-300); color: color-mix(in oklch, var(--color-base-content) 50%, transparent); } - -/* Loading overlay — use specific class to avoid colliding with daisyUI's .loading */ -.config-loading-overlay { - display: none; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - text-align: center; - z-index: 9999; -} -.config-loading-overlay.active { display: block; } - -/* Config modal — JS uses classList .active, not .showModal(). - Scoped to #confirmModal so we don't clobber daisyUI's in base.html. */ -#confirmModal { - display: none; - position: fixed; - inset: 0; - background: oklch(0% 0 0 / 0.5); - z-index: 9999; - align-items: center; - justify-content: center; -} -#confirmModal.active { display: flex; } - -/* Diff viewer (rendered by JS into modal body) */ -.diff-viewer { max-width: 900px; margin: 0 auto; } - -.diff-header { margin-bottom: 1.25rem; text-align: center; } -.diff-summary { opacity: 0.5; font-size: 0.85rem; margin-top: 0.4rem; } - -.diff-table { - width: 100%; - border-collapse: collapse; - margin: 0.75rem 0; -} -.diff-table th { - background: color-mix(in oklch, var(--color-base-300) 50%, transparent); - padding: 0.6rem; - text-align: left; - font-weight: 600; - border-bottom: 2px solid var(--color-base-300); -} -.diff-table td { - padding: 0.6rem; - border-bottom: 1px solid color-mix(in oklch, var(--color-base-300) 50%, transparent); -} - -.diff-key-name { font-weight: 500; } -.diff-key-path { - font-size: 0.7rem; - opacity: 0.4; - font-family: ui-monospace, monospace; -} - -.diff-default code, -.diff-user code { - background: color-mix(in oklch, var(--color-base-300) 50%, transparent); - padding: 0.2rem 0.4rem; - border-radius: 0.25rem; - font-size: 0.825rem; -} - -.diff-footer { - display: flex; - justify-content: space-between; - margin-top: 1.25rem; - padding-top: 0.75rem; - border-top: 1px solid color-mix(in oklch, var(--color-base-300) 50%, transparent); -} - -/* Dependency warning toast */ -.dependency-warning { animation: slideIn 0.3s ease; } - -/* Small button variant used by JS (detect, reset, etc.) */ -.btn-small { - padding: 0.3rem 0.7rem; - font-size: 0.8rem; - white-space: nowrap; -} - -/* Animations */ -@keyframes slideIn { - from { opacity: 0; transform: translateX(100%); } - to { opacity: 1; transform: translateX(0); } -} - -@keyframes slideOut { - from { opacity: 1; transform: translateX(0); } - to { opacity: 0; transform: translateX(100%); } -} - -/* Mobile: stack everything */ -@media (max-width: 768px) { - .config-item { - grid-template-columns: 1fr; - gap: 0.5rem; - } - - .config-label { - padding-bottom: 0.4rem; - border-bottom: 1px solid color-mix(in oklch, var(--color-base-300) 30%, transparent); - } - - .status-bar { - flex-direction: column; - } - - .status-right { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.4rem; - width: 100%; - } - .status-right .btn:first-child { - grid-column: span 2; - } - - .input-container { - flex-direction: column; - gap: 0.4rem; +@media (min-width: 768px) { + .config-row { + grid-template-columns: minmax(0, 1fr) minmax(14rem, 22rem); + gap: 1.5rem; + align-items: start; } - - .settings-counter { - flex-direction: column; - gap: 0.4rem; - font-size: 0.8rem; - } - .settings-counter span { - width: 100%; - justify-content: center; - } - - .diff-footer { - flex-direction: column; - gap: 0.4rem; - } - .diff-footer button { width: 100%; } + .config-row-control { align-items: flex-end; } } -/* Tablet: narrower sidebar */ -@media (min-width: 769px) and (max-width: 1024px) { - .config-container { grid-template-columns: 200px 1fr; } - .config-item { grid-template-columns: 1fr 1fr auto; } -} +.config-group-heading { margin-top: 1.25rem; } +.config-group:first-child > .config-group-heading { margin-top: 0; } +h3 + .config-group > .config-group-heading, +h3 + .config-group-heading { margin-top: 0.75rem; } + +.config-search-group + .config-search-group { margin-top: 1.25rem; } +.config-search-group > h3 { margin: 0.75rem 0 0.25rem; } diff --git a/Software/web-server/static/css/dashboard.css b/Software/web-server/static/css/dashboard.css index 6d7b3d36..5d0118dd 100644 --- a/Software/web-server/static/css/dashboard.css +++ b/Software/web-server/static/css/dashboard.css @@ -1,10 +1,14 @@ -/* Dashboard — custom styles that daisyUI/Tailwind can't cover */ +/* Dashboard: custom styles that daisyUI/Tailwind can't cover */ /* Left accent border on all metric cards */ .metric-card { border-left: 3px solid var(--color-primary); } +.metric-number { font-size: clamp(2.5rem, 5vw, 3.25rem); } +.metric-card.hero .metric-number { font-size: clamp(3.25rem, 7vw, 4.5rem); } +.metric-unit { font-size: clamp(1rem, 1.8vw, 1.25rem); } + /* Metric value pop animation on update */ .metric-number.updated { animation: metricPop 0.5s ease-out; @@ -17,7 +21,7 @@ 100% { transform: scale(1); } } -/* Status strip — accent left border changes per state */ +/* Status strip: accent left border changes per state */ .status-strip { border-left: 3px solid color-mix(in oklch, var(--color-base-content) 30%, transparent); transition: all 0.3s ease; @@ -31,13 +35,10 @@ transition: all 0.3s ease; } -/* Status strip message — in CSS because JS resets className */ +/* Status strip message */ .status-strip-message { font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } /* Status strip states */ @@ -47,28 +48,35 @@ animation: pulse 2s infinite; } -.status-strip.waiting { border-left-color: var(--color-warning); } +/* Traffic-light ramp at matched OKLCH L 80 / C 0.17, hue 50/85/150. */ +.status-strip.waiting { border-left-color: oklch(80% 0.17 50); } .status-strip.waiting .ball-icon { - background-color: var(--color-warning); + background-color: oklch(80% 0.17 50); animation: pulse 2s infinite; } +.status-strip.waiting #status-strip-title { + color: oklch(80% 0.17 50); +} -.status-strip.stabilizing { border-left-color: #fbbf24; } +.status-strip.stabilizing { border-left-color: oklch(80% 0.17 85); } .status-strip.stabilizing .ball-icon { - background-color: #fbbf24; + background-color: oklch(80% 0.17 85); animation: bounce 1s infinite; } +.status-strip.stabilizing #status-strip-title { + color: oklch(80% 0.17 85); +} .status-strip.ready { - border-left-color: var(--color-success); - background: linear-gradient(90deg, color-mix(in oklch, var(--color-success) 6%, transparent) 0%, transparent 40%); + border-left-color: oklch(80% 0.17 150); + background: linear-gradient(90deg, color-mix(in oklch, oklch(80% 0.17 150) 6%, transparent) 0%, transparent 40%); } .status-strip.ready .ball-icon { - background-color: var(--color-success); + background-color: oklch(80% 0.17 150); transform: scale(1.05); } .status-strip.ready #status-strip-title { - color: var(--color-success); + color: oklch(80% 0.17 150); } .status-strip.hit { border-left-color: var(--color-primary); } @@ -82,7 +90,7 @@ background-color: var(--color-error); } -/* Image panel inner — in CSS because JS resets className directly */ +/* Image panel inner */ .image-panel-inner { flex: 1; display: flex; @@ -92,7 +100,7 @@ min-height: 300px; } -/* Empty state — centered with a ring around the golf ball */ +/* Empty state: centered with a ring around the golf ball */ .image-empty-state { display: flex; flex-direction: column; @@ -142,11 +150,11 @@ @media (max-width: 767px) { .status-strip-separator { display: none; } - .metric-number { font-size: 1.75rem !important; } - .metric-card.hero .metric-number { font-size: 2.5rem !important; } + .metric-number { font-size: 1.75rem; } + .metric-card.hero .metric-number { font-size: 2.5rem; } } @media (min-width: 1400px) { - .metric-card.hero .metric-number { font-size: 5rem !important; } - .metric-number { font-size: 3.5rem !important; } + .metric-number { font-size: 3.5rem; } + .metric-card.hero .metric-number { font-size: 5rem; } } diff --git a/Software/web-server/static/css/logs.css b/Software/web-server/static/css/logs.css index 826e9c31..6bf48e51 100644 --- a/Software/web-server/static/css/logs.css +++ b/Software/web-server/static/css/logs.css @@ -1,169 +1,49 @@ -/* Logs page — terminal viewer and status bits that daisyUI can't handle */ +/* Logs page: line colors inside the shared .terminal panel */ -/* Service status badge variants (JS sets .running / .stopped) */ -.service-status.running { - color: oklch(45% 0.16 145); - background: oklch(85% 0.08 145 / 0.25); - border: 1px solid oklch(70% 0.12 145 / 0.3); - border-radius: 9999px; - padding: 0.125rem 0.625rem; - font-size: 0.75rem; - font-weight: 600; - display: inline-flex; - align-items: center; - gap: 0.375rem; - line-height: 1.5; -} - -.service-status.stopped { - color: oklch(55% 0.19 25); - background: oklch(80% 0.06 25 / 0.2); - border: 1px solid oklch(65% 0.14 25 / 0.3); - border-radius: 9999px; - padding: 0.125rem 0.625rem; - font-size: 0.75rem; - font-weight: 600; - display: inline-flex; - align-items: center; - gap: 0.375rem; - line-height: 1.5; -} - -/* Light theme overrides for service status badges */ -[data-theme="light"] .service-status.running { - color: oklch(35% 0.16 145); - background: oklch(92% 0.06 145 / 0.4); - border-color: oklch(55% 0.12 145 / 0.4); -} - -[data-theme="light"] .service-status.stopped { - color: oklch(45% 0.19 25); - background: oklch(90% 0.04 25 / 0.35); - border-color: oklch(55% 0.14 25 / 0.4); -} - -/* Connection indicator dot (JS toggles .connected / .disconnected) */ -.status-indicator { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - -.status-indicator.connected { - background: oklch(70% 0.14 160); - animation: pulse 2s infinite; -} - -.status-indicator.disconnected { - background: oklch(65% 0.19 25); -} - -/* Paused state — JS adds .paused to the pause button */ -.paused { - background: oklch(72% 0.16 55 / 0.15); -} - -/* ---------- Log viewer terminal ---------- */ - -.log-viewer { - flex: 1; - background: #1e1e1e; - color: #d4d4d4; - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 0.8125rem; - line-height: 1.5; - padding: 1rem; - border-radius: 0.5rem; - overflow-y: auto; - overflow-x: auto; - white-space: pre-wrap; - word-wrap: break-word; - min-height: 200px; -} - -[data-theme="dark"] .log-viewer { - background: #0d1117; - border: 1px solid oklch(15% 0.008 275); -} - -/* Pseudo-content states (JS toggles these classes) */ -.log-viewer.loading::before { - content: "Loading logs..."; - color: oklch(50% 0 0); +.log-viewer:empty::before { + content: attr(data-empty); + opacity: 0.6; font-style: italic; } -.log-viewer.empty::before { - content: "No logs available. Select a service to view logs."; - color: oklch(50% 0 0); - font-style: italic; -} - -/* ---------- Log lines ---------- - JS creates elements with .log-entry; we also alias .log-line - so either class name works. */ - -.log-entry, -.log-line { +.log-entry { margin: 0; padding: 0.25rem 0.5rem; border-left: 3px solid transparent; - transition: background 0.1s ease; } -.log-entry:hover, -.log-line:hover { - background: rgba(255, 255, 255, 0.05); +.log-entry:hover { + background: color-mix(in oklab, var(--color-neutral-content) 8%, transparent); } -/* Level colors */ -.log-entry.error, -.log-line.error { - color: #f48771; - border-left-color: #f48771; - background: rgba(244, 135, 113, 0.05); +.log-entry.error { + color: var(--color-error); + border-left-color: var(--color-error); } -.log-entry.warning, -.log-line.warning { - color: #dcdcaa; - border-left-color: #dcdcaa; - background: rgba(220, 220, 170, 0.05); +.log-entry.warning { + color: var(--color-warning); + border-left-color: var(--color-warning); } -.log-entry.info, -.log-line.info { - color: #9cdcfe; - border-left-color: #9cdcfe; +.log-entry.info { + color: var(--color-info); + border-left-color: var(--color-info); } -.log-entry.debug, -.log-line.debug { - color: #858585; - border-left-color: #858585; +.log-entry.debug { + color: color-mix(in oklab, var(--color-neutral-content) 55%, transparent); + border-left-color: color-mix(in oklab, var(--color-neutral-content) 55%, transparent); } -.log-entry.trace, -.log-line.trace { - color: #666666; - border-left-color: #666666; - font-size: 0.75rem; +.log-timestamp { + opacity: 0.6; + margin-right: 0.5rem; } -/* ---------- Mobile tweaks ---------- */ - @media (max-width: 768px) { .log-viewer { font-size: 0.75rem; padding: 0.75rem; } } - -@media (max-width: 480px) { - .log-viewer { - font-size: 0.7rem; - padding: 0.5rem; - } -} diff --git a/Software/web-server/static/css/pitrac.css b/Software/web-server/static/css/pitrac.css index 8de82135..e7959a94 100644 --- a/Software/web-server/static/css/pitrac.css +++ b/Software/web-server/static/css/pitrac.css @@ -48,6 +48,10 @@ html[data-theme="light"] { --color-error-content: #ffffff; } +/* Chrome's base-select (opted into by daisyUI) clips long option text at the padding edge, under the arrow; + the classic rendering ellipsizes before it */ +select.select { appearance: none; } + /* Lucide icon sizing */ .lucide { width: 20px; height: 20px; vertical-align: middle; } .lucide.icon-sm { width: 16px; height: 16px; } @@ -55,22 +59,19 @@ html[data-theme="light"] { .lucide.icon-lg { width: 48px; height: 48px; } .lucide.icon-menu { width: 20px; height: 20px; } -/* Golf ball status dots — can't do this with daisyUI */ -.status-dot { - width: 0.75rem; - height: 0.75rem; - background-color: oklch(70% 0.14 160); - mask: url('/static/golf_ball.svg') center / contain no-repeat; - -webkit-mask: url('/static/golf_ball.svg') center / contain no-repeat; - filter: drop-shadow(0 0 3px oklch(70% 0.14 160)); - transition: all 0.3s; - cursor: help; -} - -.status-dot.disconnected { - background-color: oklch(65% 0.19 25); - filter: drop-shadow(0 0 3px oklch(65% 0.19 25)); - animation: pulse 2s infinite; +/* Log and command output panel */ +.terminal { + background: var(--color-neutral); + color: var(--color-neutral-content); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-box, 0.5rem); + padding: 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + overflow-y: auto; } /* Logo invert for dark mode */ @@ -84,15 +85,10 @@ html[data-theme="light"] { /* Active theme button highlight */ .theme-btn.active { background: linear-gradient(135deg, oklch(59% 0.17 270), oklch(47% 0.17 300)); - color: white !important; + color: var(--color-primary-content) !important; border-color: transparent; } -/* Control button color overrides — daisyUI btn-success/error/warning handle most of it, - but we keep these classes for JS compatibility */ -.control-btn.loading { animation: pulse 1s infinite; } -.control-btn svg { width: 16px; height: 16px; } - /* Animations */ @keyframes pulse { 0%, 100% { opacity: 1; } @@ -111,5 +107,8 @@ html[data-theme="light"] { .animate-fade-in { animation: fadeIn 0.4s ease-out; } -/* Mono text helper */ -.mono { font-family: ui-monospace, monospace; } +/* Setup checklist: the action drops under the text on phones */ +@media (max-width: 639px) { + .list .list-row > .setup-action { grid-row-start: 2; grid-column-start: 2; } +} +.setup-action:empty { display: none; } diff --git a/Software/web-server/static/css/testing.css b/Software/web-server/static/css/testing.css index d85f930c..2128dd46 100644 --- a/Software/web-server/static/css/testing.css +++ b/Software/web-server/static/css/testing.css @@ -1,4 +1,4 @@ -/* Testing page — only styles daisyUI/Tailwind can't handle */ +/* Testing page: only styles daisyUI/Tailwind can't handle */ /* Tool cards (built dynamically by JS) */ .tool-card { @@ -6,38 +6,11 @@ border: 1px solid var(--color-base-300); border-radius: 0.5rem; padding: 1rem; - transition: all 0.2s ease; - position: relative; -} - -.tool-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - border-color: var(--color-primary); + transition: border-color 0.2s ease; } .tool-card.running { border-color: var(--color-warning); - background: var(--color-base-200); -} - -.tool-card.running::before { - content: ''; - position: absolute; - top: 0.5rem; - right: 0.5rem; - width: 0.5rem; - height: 0.5rem; - background: var(--color-warning); - border-radius: 50%; - animation: pulse 2s infinite; -} - -.tool-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 0.5rem; } .tool-name { @@ -46,18 +19,8 @@ font-weight: 600; } -.sudo-badge { - background: var(--color-error); - color: white; - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; -} - .tool-description { - margin: 0 0 1rem 0; + margin: 0; opacity: 0.6; font-size: 0.875rem; line-height: 1.4; @@ -72,125 +35,9 @@ flex: 1; } -/* JS creates stop buttons with .btn-danger (not daisyUI's btn-error) */ -.btn-danger { - background: var(--color-error); - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: var(--radius-field, 0.25rem); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; -} - -.btn-danger:hover { - opacity: 0.85; -} - -/* Terminal-style output panel */ -.output-content { - background: #1e1e1e; - color: #d4d4d4; - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 0.8125rem; - line-height: 1.5; - padding: 1rem; - border-radius: 0.5rem; -} - -[data-theme="dark"] .output-content { - background: #0d1117; - border: 1px solid oklch(15% 0.008 275); -} - -.output-placeholder { - font-family: inherit; -} - -.output-line { - margin: 0.25rem 0; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - word-break: break-all; - white-space: pre-wrap; -} - -.output-line.info { - background: rgba(59, 130, 246, 0.08); - color: #9cdcfe; -} - -.output-line.success { - background: rgba(34, 197, 94, 0.08); - color: #b5cea8; -} - -.output-line.warning { - background: rgba(251, 146, 60, 0.08); - color: #dcdcaa; -} - -.output-line.error { - background: rgba(239, 68, 68, 0.08); - color: #f48771; -} - -/* Image result shown inside the modal */ -.image-result { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.image-result img { - border-radius: 0.5rem; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); -} - -.image-actions { - display: flex; - justify-content: center; - gap: 1rem; -} - -/* Div-based modal (JS controls via display: block/none, not ) */ -#testModal { - position: fixed; - inset: 0; - z-index: 999; - display: flex; - justify-content: center; - align-items: center; -} - -#testModal .modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: -1; - cursor: pointer; -} - -#testModal .modal-box { - background: var(--color-base-200); - border-radius: 1rem; - padding: 1.5rem; - max-height: 90vh; - overflow: auto; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4); -} - /* Upload drag-over state (JS toggles this) */ .upload-area.drag-over { border-color: var(--color-primary) !important; background: color-mix(in oklch, var(--color-primary) 8%, transparent) !important; transform: scale(1.02); } - -/* Mobile */ -@media (max-width: 768px) { - .tool-grid { - grid-template-columns: 1fr !important; - } -} diff --git a/Software/web-server/static/css/update.css b/Software/web-server/static/css/update.css index b347160c..f4adc5c3 100644 --- a/Software/web-server/static/css/update.css +++ b/Software/web-server/static/css/update.css @@ -1,19 +1,5 @@ -/* Update page — only styles daisyUI/Tailwind can't handle */ +/* Update page: commit rows and build log line colors */ -/* Loading dots animation on action buttons (JS toggles .loading) */ -.action-btn.loading span::after { - content: "..."; - animation: dots 1.5s steps(3, end) infinite; -} - -@keyframes dots { - 0% { content: ""; } - 33% { content: "."; } - 66% { content: ".."; } - 100% { content: "..."; } -} - -/* Commit list items (built dynamically by JS) */ .commit-item { display: flex; align-items: baseline; @@ -46,24 +32,8 @@ white-space: nowrap; } -/* Terminal-style build log */ .build-log { - background: #1e1e1e; - color: #d4d4d4; - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 0.8125rem; - line-height: 1.5; - padding: 1rem; - border-radius: 0.5rem; - overflow-y: auto; max-height: 400px; - white-space: pre-wrap; - word-wrap: break-word; -} - -[data-theme="dark"] .build-log { - background: #0d1117; - border: 1px solid oklch(15% 0.008 275); } .log-line { @@ -71,42 +41,10 @@ padding: 0.125rem 0.5rem; } -.log-line.error { color: #f48771; } -.log-line.update { color: #9cdcfe; } -.log-line.git { color: #b5cea8; } -.log-line.build { color: #d4d4d4; } - -/* Update banner (JS creates this dynamically) */ -.update-banner { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - border-radius: 0.375rem; - margin-bottom: 1.25rem; - font-size: 0.875rem; - font-weight: 500; -} - -.update-banner.info { - background: rgba(66, 153, 225, 0.1); - color: var(--color-info); - border: 1px solid rgba(66, 153, 225, 0.2); -} - -.update-banner.success { - background: rgba(72, 187, 120, 0.1); - color: var(--color-success); - border: 1px solid rgba(72, 187, 120, 0.2); -} - -.update-banner.error { - background: rgba(245, 101, 101, 0.1); - color: var(--color-error); - border: 1px solid rgba(245, 101, 101, 0.2); -} +.log-line.error { color: var(--color-error); } +.log-line.update { color: var(--color-info); } +.log-line.git { color: var(--color-success); } -/* Mobile */ @media (max-width: 768px) { .commit-item { flex-direction: column; diff --git a/Software/web-server/static/js/calibration.js b/Software/web-server/static/js/calibration.js index 73936155..4773d8c6 100644 --- a/Software/web-server/static/js/calibration.js +++ b/Software/web-server/static/js/calibration.js @@ -1,915 +1,450 @@ -/** - * PiTrac Calibration UI Controller - */ - -class CalibrationManager { +/* global requireStrobeSafe, escapeHtml, toast, confirmDialog, api, formatNumber, distortionCalibration, setSetupStatus, setupStatusRequest, + SETUP_CAMERAS, cameraName, setupLock, setupChecklist, renderSetupChecklist, setupSummaryHtml, loadCameraLabels */ + +const CAMERAS = SETUP_CAMERAS; +const POSITION_DURATION = { camera1: 'about 30 s', camera2: 'about 2 minutes' }; +const SECTIONS = ['strobe', 'lens', 'ball']; +const LOCK_NOTES = { + unknown: 'Could not load the calibration status. Use Retry in the checklist above.', + strobe: 'Calibrate the strobe first. It is the first row in the checklist above.', + lens: (camera) => `Calibrate the lens for ${cameraName(camera).toLowerCase()} first.`, +}; + +class CalibrationPage { constructor() { - this.currentStep = 1; - this.selectedCameras = []; - this.calibrationMethod = null; - this.calibrationInProgress = false; - this.statusPollInterval = null; - this.cameraPollIntervals = new Map(); - this.ballVerified = { - camera1: false, - camera2: false - }; - this.calibrationResults = {}; + this.setup = null; + this.status = null; + this.section = null; + this.strobeRunning = false; this.strobePollingTimer = null; - + this.positionRunning = false; + this.positionCamera = null; + this.positionStopped = false; + this.positionPollTimer = null; + this.redoFailures = {}; + this.cameraLabels = {}; + + window.addEventListener('hashchange', () => this.openHash()); + window.addEventListener('pagehide', () => this.cleanup()); + document.querySelectorAll('input[name="ball-camera"]').forEach(input => { + input.addEventListener('change', () => this.renderPosition()); + }); this.init(); - this.setupPageCleanup(); } async init() { - await this.loadSystemStatus(); - - await this.loadCalibrationData(); - - await this.loadStrobeSettings(); - - this.setupEventListeners(); - - this.startStatusPolling(); - } - - /** - * Setup cleanup handlers for page unload - */ - setupPageCleanup() { - window.addEventListener('beforeunload', () => { - this.cleanup(); - }); - - window.addEventListener('pagehide', () => { - this.cleanup(); + await this.refresh(setupStatusRequest); + this.loadStrobeSettings(); + loadCameraLabels().then(labels => { + this.cameraLabels = labels; + this.renderChecklist(); }); - } - /** - * Cleanup all intervals and resources - */ - cleanup() { - if (this.statusPollInterval) { - clearInterval(this.statusPollInterval); - this.statusPollInterval = null; + if (this.strobeRunning) { + this.open('strobe'); + this.showStrobeRunning(); + this.pollStrobeStatus(); + return; } - - if (this.strobePollingTimer) { - clearTimeout(this.strobePollingTimer); - this.strobePollingTimer = null; + const lensCamera = CAMERAS.find(c => distortionCalibration.isActive(this.status?.distortion?.[c])); + if (lensCamera) { + this.open('lens', lensCamera); + return; } - - this.cameraPollIntervals.forEach((intervalId) => { - clearInterval(intervalId); - }); - this.cameraPollIntervals.clear(); - } - - setupEventListeners() { - document.querySelectorAll('input[name="camera"]').forEach(input => { - input.addEventListener('change', (e) => { - this.updateSelectedCameras(e.target.value); - }); - }); - - this.updateSelectedCameras('camera1'); - } - - updateSelectedCameras(value) { - if (value === 'both') { - this.selectedCameras = ['camera1', 'camera2']; - document.getElementById('camera1-view').style.display = 'block'; - document.getElementById('camera2-view').style.display = 'block'; - } else { - this.selectedCameras = [value]; - document.getElementById('camera1-view').style.display = - value === 'camera1' ? 'block' : 'none'; - document.getElementById('camera2-view').style.display = - value === 'camera2' ? 'block' : 'none'; + const positionCamera = CAMERAS.find(c => this.status?.[c]?.status === 'calibrating'); + if (positionCamera) { + this.open('ball', positionCamera); + this.setPositionRunning(true); + this.showPositionProgress(positionCamera, true); + return; } + this.openHash(); } - async loadSystemStatus() { - try { - const configResponse = await fetch('/api/config'); - if (configResponse.ok) { - const config = await configResponse.json(); - const systemMode = config.system?.mode || 'single'; - document.getElementById('system-mode').textContent = - systemMode === 'single' ? 'Single Pi' : 'Dual Pi'; - } - - const statusResponse = await fetch('/api/pitrac/status'); - if (statusResponse.ok) { - const status = await statusResponse.json(); - const statusElement = document.getElementById('pitrac-status'); - if (status.running) { - statusElement.textContent = 'Running'; - statusElement.style.color = '#4CAF50'; - } else { - statusElement.textContent = 'Stopped'; - statusElement.style.color = '#f44336'; - } - } - } catch (error) { - console.error('Error loading system status:', error); - } + // Links carry the camera as #lens-camera2; #position is the old name of #ball + openHash() { + const [name, camera] = location.hash.slice(1).split('-'); + const section = name === 'position' ? 'ball' : name; + if (SECTIONS.includes(section)) this.open(section, CAMERAS.includes(camera) ? camera : undefined); } - async loadCalibrationData() { - try { - const response = await fetch('/api/calibration/data'); - if (response.ok) { - const data = await response.json(); - this.displayCurrentCalibration(data); - } - } catch (error) { - console.error('Error loading calibration data:', error); - } + cleanup() { + clearTimeout(this.strobePollingTimer); + clearInterval(this.positionPollTimer); + distortionCalibration.leave(); + distortionCalibration._stopPolling(); } - displayCurrentCalibration(data) { - const container = document.getElementById('current-calibration-data'); - container.innerHTML = ''; - - if (data.camera1) { - this.addCalibrationDataItem(container, 'Camera 1 Focal Length', - data.camera1.focal_length?.toFixed(3) || 'Not set'); - - if (data.camera1.angles && Array.isArray(data.camera1.angles)) { - this.addCalibrationDataItem(container, 'Camera 1 Angles', - `[${data.camera1.angles.map(a => parseFloat(a).toFixed(2)).join(', ')}]`); - } else { - this.addCalibrationDataItem(container, 'Camera 1 Angles', 'Not set'); - } - } + async refresh(setupRequest = api('/api/setup/status')) { + const [setup, status, strobe] = await Promise.all([ + setupRequest, + api('/api/calibration/status'), + api('/api/strobe-calibration/status'), + ].map(p => p.catch(() => null))); + if (setup) this.setup = setup; + document.getElementById('checklist-error').hidden = !!this.setup; + if (status) this.status = status; + if (strobe) this.strobeRunning = strobe.state === 'calibrating'; + setSetupStatus(setup); + this.renderChecklist(); + } - if (data.camera2) { - this.addCalibrationDataItem(container, 'Camera 2 Focal Length', - data.camera2.focal_length?.toFixed(3) || 'Not set'); + // -- Checklist -- - if (data.camera2.angles && Array.isArray(data.camera2.angles)) { - this.addCalibrationDataItem(container, 'Camera 2 Angles', - `[${data.camera2.angles.map(a => parseFloat(a).toFixed(2)).join(', ')}]`); - } else { - this.addCalibrationDataItem(container, 'Camera 2 Angles', 'Not set'); - } - } + lockNote(section, cameras) { + const reasons = cameras.map(c => [c, setupLock(this.setup, section, c)]).filter(([, r]) => r); + if (!reasons.length) return null; + if (reasons.some(([, r]) => r === 'unknown')) return LOCK_NOTES.unknown; + if (reasons.some(([, r]) => r === 'strobe')) return LOCK_NOTES.strobe; + return reasons.map(([c]) => LOCK_NOTES.lens(c)).join(' '); } - addCalibrationDataItem(container, label, value) { - const item = document.createElement('div'); - item.className = 'calibration-data-item'; - item.innerHTML = ` - ${label}: - ${value} - `; - container.appendChild(item); + isRunning(step, camera) { + if (step === 'strobe') return this.strobeRunning; + return step === 'lens' + ? distortionCalibration.isActive(this.status?.distortion?.[camera]) + : this.status?.[camera]?.status === 'calibrating'; } - nextStep() { - if (this.currentStep === 1) { - this.showStep(2); - } else if (this.currentStep === 2) { - const allVerified = this.selectedCameras.every(cam => this.ballVerified[cam]); - if (!allVerified) { - this.showMessage('Please verify ball placement for all selected cameras', 'error'); - return; - } - this.showStep(3); - } else if (this.currentStep === 3) { - } + wasDone(section, camera) { + return section === 'strobe' ? !!this.setup?.strobe.safe : !!this.setup?.cameras[camera][`${section}_calibrated`]; } - prevStep() { - if (this.currentStep > 1) { - this.showStep(this.currentStep - 1); + runEnded(section, camera, failure, wasDone = this.wasDone(section, camera)) { + const rowId = camera ? `${section}-${camera}` : section; + if (failure && wasDone) { + this.redoFailures[rowId] = section === 'strobe' + ? `Redo failed. ${failure}` + : `Redo failed. Your previous calibration is still in use. ${failure}`; + } else { + delete this.redoFailures[rowId]; } } - showStep(stepNumber) { - document.querySelectorAll('.wizard-content').forEach(content => { - content.style.display = 'none'; - }); - - document.getElementById(`step${stepNumber}`).style.display = 'block'; - - document.querySelectorAll('.step').forEach(step => { - const stepNum = parseInt(step.dataset.step); - if (stepNum < stepNumber) { - step.classList.add('completed'); - step.classList.remove('active'); - } else if (stepNum === stepNumber) { - step.classList.add('active'); - step.classList.remove('completed'); - } else { - step.classList.remove('active', 'completed'); - } + renderChecklist() { + const items = setupChecklist(this.setup, { + running: (step, camera) => this.isRunning(step, camera), + failures: this.redoFailures, }); + renderSetupChecklist(document.getElementById('checklist'), items, { hints: true }); + const summary = document.getElementById('setup-summary'); + summary.hidden = !this.setup; + if (this.setup) summary.innerHTML = setupSummaryHtml(this.setup, this.cameraLabels); + document.getElementById('checklist-done').hidden = !this.setup || !items.every(item => item.state === 'done'); - this.currentStep = stepNumber; + this.renderStrobeState(); + this.renderPosition(); + distortionCalibration.renderLock(); } - /** - * Run auto calibration for the specified camera - * @param {string} camera - Camera identifier (camera1 or camera2) - * @param {Event} event - The click event from the button (optional) - */ - async runAutoCalibration(camera, event) { - if (!(await requireStrobeSafe())) return; - if (!this.validateCameraName(camera)) { - this.showMessage(`Invalid camera name: ${camera}`, 'error'); - return; - } - - const button = event?.target || event?.currentTarget; - const originalText = button?.textContent || 'Calibrate'; - - try { - if (button) { - button.disabled = true; - button.textContent = 'Calibrating...'; - } - - const response = await fetch(`/api/calibration/auto/${camera}`, { - method: 'POST' - }); + // -- Sections -- - if (response.ok) { - const result = await response.json(); - if (result.status === 'success') { - // Display the calibration image if available - if (result.calibration_data && result.calibration_data.image_path) { - const img = document.getElementById(`${camera}-image`); - // Convert the full path to a web-accessible URL - const imageName = result.calibration_data.image_path.split('/').pop(); - img.src = `/api/images/${imageName}`; - img.style.display = 'block'; - - const placeholder = img.parentElement.querySelector('.camera-placeholder'); - if (placeholder) { - placeholder.style.display = 'none'; - } - } - - this.showMessage(`Calibration successful for ${camera}`, 'success'); - } else { - this.showMessage(`Calibration failed: ${result.message}`, 'error'); - } - } else { - this.showMessage('Calibration request failed', 'error'); - } - } catch (error) { - console.error('Error running calibration:', error); - this.showMessage('Error running calibration', 'error'); - } finally { - if (button) { - button.disabled = false; - button.textContent = originalText; - } - } + open(section, camera) { + const input = camera && document.querySelector(`input[name="${section}-camera"][value="${camera}"]`); + if (input && !input.disabled) input.checked = true; + if (location.hash !== `#${section}`) window.history.replaceState(null, '', `#${section}`); + this.showSection(section); + this.renderPosition(); } - /** - * Check ball location in the camera view - * @param {string} camera - Camera identifier (camera1 or camera2) - * @param {Event} event - The click event from the button (optional) - */ - async checkBallLocation(camera, event) { - if (!(await requireStrobeSafe())) return; - if (!this.validateCameraName(camera)) { - this.showMessage(`Invalid camera name: ${camera}`, 'error'); - return; - } - - const button = event?.target || event?.currentTarget; - const originalText = button?.textContent || 'Check Ball Location'; - - try { - if (button) { - button.disabled = true; - button.textContent = 'Checking...'; - } - - const response = await fetch(`/api/calibration/ball-location/${camera}`, { - method: 'POST' - }); - - if (response.ok) { - const result = await response.json(); - const statusDiv = document.getElementById(`${camera}-ball-status`); - - if (result.ball_found) { - statusDiv.className = 'ball-status success'; - statusDiv.textContent = `Ball detected at position (${result.ball_info?.x || 0}, ${result.ball_info?.y || 0})`; - this.ballVerified[camera] = true; - - const allVerified = this.selectedCameras.every(cam => this.ballVerified[cam]); - if (allVerified) { - document.getElementById('verify-next').disabled = false; - document.getElementById('verification-message').className = 'alert alert-success'; - document.getElementById('verification-message').textContent = - '✅ Ball placement verified! Ready to proceed with calibration.'; - } - } else { - statusDiv.className = 'ball-status error'; - statusDiv.textContent = 'Ball not detected - please adjust placement'; - this.ballVerified[camera] = false; - } - } else { - this.showMessage('Failed to check ball location', 'error'); - } - } catch (error) { - console.error('Error checking ball location:', error); - this.showMessage('Error checking ball location', 'error'); - } finally { - if (button) { - button.disabled = false; - button.textContent = originalText; - } - } + showSection(name) { + if (name === 'strobe' && this.setup && !this.setup.strobe.required) return; + if (!SECTIONS.includes(name)) return; + if (this.section === 'lens' && name !== 'lens') distortionCalibration.leave(); + this.section = name; + SECTIONS.forEach(s => { document.getElementById(s).hidden = s !== name; }); + if (name === 'lens') distortionCalibration.enter(); + document.getElementById(name).scrollIntoView({ behavior: 'smooth', block: 'start' }); } - selectMethod(method) { - this.calibrationMethod = method; + // -- Position -- - document.querySelector('.calibration-options').style.display = 'none'; - - document.getElementById('calibration-progress').style.display = 'block'; - - this.startCalibration(method); + positionCameras() { + const choice = document.querySelector('input[name="ball-camera"]:checked').value; + return choice === 'both' ? CAMERAS : [choice]; } - async startCalibration(method) { - if (!(await requireStrobeSafe())) return; - this.calibrationInProgress = true; - - document.getElementById('calibration-log-content').innerHTML = ''; - this.addLogEntry('Starting calibration process...'); - - for (const camera of this.selectedCameras) { - await this.calibrateCamera(camera, method); - } + renderPosition() { + const note = this.positionRunning ? null : this.lockNote('position', this.positionCameras()); + const lockEl = document.getElementById('position-locked'); + lockEl.hidden = !note; + lockEl.textContent = note || ''; + document.getElementById('position-start-btn').hidden = this.positionRunning || !!note; + document.getElementById('position-stop-btn').hidden = !this.positionRunning; + document.getElementById('position-progress').hidden = !this.positionRunning; + document.querySelectorAll('input[name="ball-camera"]').forEach(i => { i.disabled = this.positionRunning; }); } - async calibrateCamera(camera, method) { - try { - this.addLogEntry(`Starting ${method} calibration for ${camera}...`); - - const progressBar = document.getElementById(`${camera}-progress`); - const statusText = document.getElementById(`${camera}-status`); - const detailsDiv = document.getElementById(`${camera}-details`); - - progressBar.style.width = '10%'; - statusText.textContent = 'Initializing...'; - detailsDiv.innerHTML = ''; - - const endpoint = method === 'auto' - ? `/api/calibration/auto/${camera}` - : `/api/calibration/manual/${camera}`; - - const response = await fetch(endpoint, { - method: 'POST' - }); - - if (response.ok) { - const result = await response.json(); - - progressBar.style.width = '50%'; - statusText.textContent = 'Calibrating...'; - - const finalResult = await this.pollForCompletion(camera); - - if (finalResult && finalResult.status === 'success') { - this.addLogEntry(`${camera} calibration completed successfully`); - this.addLogEntry(` Completion method: ${finalResult.completion_method || 'unknown'}`); - - const details = []; - if (finalResult.api_success) { - details.push('API Callbacks Received'); - } - if (finalResult.focal_length_received) { - details.push('Focal Length'); - } - if (finalResult.angles_received) { - details.push('Camera Angles'); - } - - detailsDiv.innerHTML = `${details.join(' | ')}`; - - progressBar.style.width = '100%'; - statusText.textContent = 'Completed'; - - if (!this.calibrationResults) { - this.calibrationResults = {}; - } - this.calibrationResults[camera] = finalResult; - - const allDone = this.selectedCameras.every(cam => { - const status = document.getElementById(`${cam}-status`).textContent; - return status === 'Completed' || status === 'Failed'; - }); - - if (allDone) { - await this.showCalibrationResults(); - } - } else { - const message = result.message || finalResult?.message || 'Unknown error'; - this.addLogEntry(`${camera} calibration failed: ${message}`); - - const details = []; - if (finalResult?.completion_method) { - details.push(`Method: ${finalResult.completion_method}`); - } - if (finalResult?.focal_length_received) { - details.push('Focal length received'); - } - if (finalResult?.angles_received) { - details.push('Angles received'); - } - - if (details.length > 0) { - detailsDiv.innerHTML = `${details.join(' | ')}`; - } - - progressBar.style.width = '100%'; - progressBar.style.background = '#f44336'; - statusText.textContent = 'Failed'; - } - } else { - throw new Error('Failed to start calibration'); - } - } catch (error) { - console.error(`Error calibrating ${camera}:`, error); - this.addLogEntry(`Error calibrating ${camera}: ${error.message}`); - - const progressBar = document.getElementById(`${camera}-progress`); - const statusText = document.getElementById(`${camera}-status`); - progressBar.style.width = '100%'; - progressBar.style.background = '#f44336'; - statusText.textContent = 'Error'; + setPositionRunning(running) { + this.positionRunning = running; + if (running) { + this.positionStopped = false; + document.getElementById('position-results').replaceChildren(); + } else { + clearInterval(this.positionPollTimer); + this.positionCamera = null; } + this.renderPosition(); } - async pollForCompletion(camera, timeout = 180000) { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - const response = await fetch('/api/calibration/status'); - if (response.ok) { - const status = await response.json(); - const cameraStatus = status[camera]; - - const progressBar = document.getElementById(`${camera}-progress`); - const statusText = document.getElementById(`${camera}-status`); - - if (cameraStatus && cameraStatus.progress) { - progressBar.style.width = `${cameraStatus.progress}%`; - } - - if (cameraStatus && cameraStatus.message) { - statusText.textContent = cameraStatus.message; - } - - if (cameraStatus && - (cameraStatus.status === 'completed' || - cameraStatus.status === 'failed' || - cameraStatus.status === 'error')) { - return cameraStatus; - } + async startPosition() { + if (!(await requireStrobeSafe())) return; + this.setPositionRunning(true); + for (const camera of this.positionCameras()) { + if (this.positionStopped) break; + this.showPositionProgress(camera, false); + let result; + try { + result = await api(`/api/calibration/auto/${camera}`, { method: 'POST' }); + } catch (err) { + result = { status: 'error', message: err.message }; } - - await new Promise(resolve => setTimeout(resolve, 1000)); + const ok = await this.showPositionResult(camera, result.status, result.message); + if (!ok) break; } - - throw new Error('Calibration timeout'); - } - - async showCalibrationResults() { - const response = await fetch('/api/calibration/data'); - if (response.ok) { - const data = await response.json(); - - if (this.selectedCameras.includes('camera1')) { - const result = this.calibrationResults?.camera1; - const card = document.getElementById('camera1-result-card'); - card.style.display = 'block'; - - document.getElementById('camera1-result-status').textContent = - result?.status === 'success' ? 'Success' : 'Failed'; - - document.getElementById('camera1-completion-method').textContent = - result?.completion_method - ? `${result.completion_method} ${result.api_success ? '(API callbacks ✓)' : ''}` - : '--'; - - if (data.camera1) { - document.getElementById('camera1-focal').textContent = - data.camera1.focal_length?.toFixed(3) || '--'; - - if (data.camera1.angles && Array.isArray(data.camera1.angles)) { - document.getElementById('camera1-angles').textContent = - `[${data.camera1.angles.map(a => parseFloat(a).toFixed(2)).join(', ')}]`; - } else { - document.getElementById('camera1-angles').textContent = '--'; - } - } - } else { - document.getElementById('camera1-result-card').style.display = 'none'; - } - - if (this.selectedCameras.includes('camera2')) { - const result = this.calibrationResults?.camera2; - const card = document.getElementById('camera2-result-card'); - card.style.display = 'block'; - - document.getElementById('camera2-result-status').textContent = - result?.status === 'success' ? 'Success' : 'Failed'; - - document.getElementById('camera2-completion-method').textContent = - result?.completion_method - ? `${result.completion_method} ${result.api_success ? '(API callbacks ✓)' : ''}` - : '--'; - - if (data.camera2) { - document.getElementById('camera2-focal').textContent = - data.camera2.focal_length?.toFixed(3) || '--'; - - if (data.camera2.angles && Array.isArray(data.camera2.angles)) { - document.getElementById('camera2-angles').textContent = - `[${data.camera2.angles.map(a => parseFloat(a).toFixed(2)).join(', ')}]`; - } else { - document.getElementById('camera2-angles').textContent = '--'; - } - } - } else { - document.getElementById('camera2-result-card').style.display = 'none'; + this.setPositionRunning(false); + this.refresh(); + } + + // The auto calibration POST blocks until the run ends. The status poll only feeds the message, + // unless the page was reloaded mid-run and there is no POST to wait on. + showPositionProgress(camera, reattached) { + this.positionCamera = camera; + const cam = this.setup?.cameras?.[camera]; + this.positionBefore = { calibrated: !!cam?.position_calibrated, updatedAt: cam?.position_updated_at ?? null }; + document.getElementById('position-progress-title').textContent = + `Calibrating ${cameraName(camera).toLowerCase()}, ${POSITION_DURATION[camera]}`; + document.getElementById('position-progress-message').textContent = ''; + clearInterval(this.positionPollTimer); + this.positionPollTimer = setInterval(async () => { + const status = await api('/api/calibration/status').catch(() => null); + if (!status) return; + this.status = status; + this.renderChecklist(); + const st = status[camera] || {}; + if (reattached && st.status !== 'calibrating') { + await this.showPositionResult(camera, st.status === 'completed' ? 'success' : st.status, st.message); + this.setPositionRunning(false); + this.refresh(); + return; } - - this.displayCurrentCalibration(data); + if (st.message) document.getElementById('position-progress-message').textContent = st.message; + }, 2000); + } + + // pitrac_lm saves focal length and angles in two calls, and a run can be marked failed after saving, + // so a failed run only leaves the old calibration in place if the saved timestamp did not move. + async showPositionResult(camera, outcome, message) { + clearInterval(this.positionPollTimer); + const ok = outcome === 'success'; + const before = this.positionBefore; + let partial = false; + if (!ok) { + await this.refresh(); + partial = (this.setup?.cameras?.[camera]?.position_updated_at ?? null) !== before.updatedAt; } - - this.showStep(4); - this.calibrationInProgress = false; - } - - async stopCalibration() { - if (confirm('Are you sure you want to stop the calibration process?')) { - try { - const response = await fetch('/api/calibration/stop', { - method: 'POST' - }); - - if (response.ok) { - this.addLogEntry('Calibration stopped by user'); - this.calibrationInProgress = false; - - this.selectedCameras.forEach(camera => { - if (this[`${camera}PollInterval`]) { - clearInterval(this[`${camera}PollInterval`]); - } - }); - - this.restart(); - } - } catch (error) { - console.error('Error stopping calibration:', error); - } + if (partial) { + this.redoFailures[`position-${camera}`] = + `${before.calibrated ? 'Redo' : 'Calibration'} failed after saving some new values. Run it again.`; + this.renderChecklist(); + } else { + this.runEnded('position', camera, ok || this.positionStopped ? null : message || 'Calibration failed', before.calibrated); } + const partialLine = partial ? '
Some new values were saved before it ended. Run it again.
' : ''; + const label = cameraName(camera); + const box = document.createElement('div'); + if (this.positionStopped) { + box.className = 'alert alert-soft'; + box.innerHTML = `
${label}: stopped.
${partialLine}
`; + } else if (outcome === 'error' && !partial) { + box.className = 'alert alert-error alert-soft'; + box.textContent = `${label}: ${message || 'Calibration could not start'}`; + } else if (ok) { + const data = await api('/api/calibration/data').catch(() => null); + const cam = (data && data[camera]) || {}; + const angles = Array.isArray(cam.angles) ? cam.angles.map(Number) : []; + const tilt = angles.length > 1 && !Number.isNaN(angles[1]) + ? ` Camera tilted ${Math.abs(angles[1]).toFixed(0)} degrees ${angles[1] < 0 ? 'down' : 'up'}.` + : ''; + box.className = 'alert alert-success alert-soft'; + box.innerHTML = ` +
+
${label}
+
Done.${tilt}
+
+ Details +
Focal length: ${formatNumber(cam.focal_length, 3)} mm
+
Angles: ${angles.map(a => formatNumber(a, 2)).join(', ') || '--'} degrees (side to side, up and down)
+
+
`; + } else { + box.className = 'alert alert-error alert-soft'; + box.innerHTML = ` +
+
${label} could not be calibrated
+ ${partialLine} +
${escapeHtml(message || 'Check that the ball is in place and the camera can see it, then try again.')}
+ Open logs +
`; + } + document.getElementById('position-results').append(box); + return ok && !this.positionStopped; } - restart() { - this.currentStep = 1; - this.calibrationMethod = null; - this.calibrationInProgress = false; - this.ballVerified = { - camera1: false, - camera2: false - }; - - this.showStep(1); - document.querySelector('.calibration-options').style.display = 'block'; - document.getElementById('calibration-progress').style.display = 'none'; - document.getElementById('verify-next').disabled = true; - - document.querySelectorAll('.camera-preview img').forEach(img => { - img.style.display = 'none'; - }); - document.querySelectorAll('.camera-placeholder').forEach(placeholder => { - placeholder.style.display = 'flex'; - }); - - document.querySelectorAll('.ball-status').forEach(status => { - status.textContent = ''; - status.className = 'ball-status'; - }); - - document.querySelectorAll('.progress-fill').forEach(bar => { - bar.style.width = '0%'; - bar.style.background = ''; + async stopPosition() { + const camera = this.positionCamera; + const ok = await confirmDialog({ + title: 'Stop ball calibration?', + body: `This stops the run on ${cameraName(camera).toLowerCase()}.`, + confirmLabel: 'Stop', + danger: true, }); - } - - addLogEntry(message) { - const logContent = document.getElementById('calibration-log-content'); - const timestamp = new Date().toLocaleTimeString(); - const entry = document.createElement('div'); - entry.textContent = `[${timestamp}] ${message}`; - logContent.appendChild(entry); - logContent.scrollTop = logContent.scrollHeight; - } - - /** - * Validate camera name is valid - * @param {string} camera - Camera identifier to validate - * @returns {boolean} True if valid camera name - */ - validateCameraName(camera) { - const validCameras = ['camera1', 'camera2']; - return validCameras.includes(camera); - } - - showMessage(message, type = 'info') { - const messageDiv = document.getElementById('verification-message'); - if (messageDiv) { - messageDiv.className = `alert alert-${type}`; - messageDiv.textContent = message; + if (!ok) return; + this.positionStopped = true; + try { + await api('/api/calibration/stop', { method: 'POST', body: { camera, kind: 'ball' } }); + } catch (err) { + toast(err.message, 'error'); } } - startStatusPolling() { - this.statusPollInterval = setInterval(() => { - if (!this.calibrationInProgress) { - this.loadSystemStatus(); - } - }, 5000); - } + // -- Strobe -- - // -- Strobe Calibration -- + renderStrobeState() { + if (!this.setup) return; + const safe = this.setup.strobe.safe; + document.getElementById('strobe-state').textContent = + this.strobeRunning ? 'Strobe: calibrating' : safe ? 'Strobe: calibrated' : 'Strobe: needs calibration'; + const btn = document.getElementById('strobe-calibrate-btn'); + if (!this.strobeRunning) btn.textContent = safe ? 'Recalibrate' : 'Calibrate'; + } async loadStrobeSettings() { try { - const [settingsRes, configRes] = await Promise.all([ - fetch('/api/strobe-calibration/settings'), - fetch('/api/config?key=gs_config.strobing.kConnectionBoardVersion') - ]); - - const calBtn = document.getElementById('strobe-calibrate-btn'); - const diagBtn = document.getElementById('strobe-diagnostics-btn'); - const controls = document.getElementById('strobe-controls'); - const warning = document.getElementById('strobe-board-warning'); - - let boardVersion = null; - if (configRes.ok) { - const config = await configRes.json(); - boardVersion = config.data; - } - - const boardEl = document.getElementById('strobe-board-version'); - boardEl.textContent = boardVersion ? 'V' + boardVersion : 'Not set'; - - const isV3 = boardVersion !== null && parseInt(boardVersion) === 3; - const tabInput = document.getElementById('strobe-tab-input'); - const tabContent = document.getElementById('strobe-tab-content'); - if (!isV3) { - calBtn.disabled = true; - diagBtn.disabled = true; - controls.style.opacity = '0.5'; - warning.style.display = 'block'; - if (tabInput) tabInput.classList.add('hidden'); - if (tabContent) tabContent.classList.add('hidden'); - } else { - calBtn.disabled = false; - diagBtn.disabled = false; - controls.style.opacity = '1'; - warning.style.display = 'none'; - if (tabInput) tabInput.classList.remove('hidden'); - if (tabContent) tabContent.classList.remove('hidden'); - } - - if (settingsRes.ok) { - const settings = await settingsRes.json(); - const dacEl = document.getElementById('strobe-saved-dac'); - if (settings.dac_setting !== null && settings.dac_setting !== undefined) { - dacEl.textContent = '0x' + parseInt(settings.dac_setting).toString(16).toUpperCase().padStart(2, '0'); - if (isV3) calBtn.textContent = 'Recalibrate'; - } else { - dacEl.textContent = 'Not calibrated'; - if (isV3) calBtn.textContent = 'Calibrate'; - } - } + const settings = await api('/api/strobe-calibration/settings'); + document.getElementById('strobe-saved-dac').textContent = + settings.dac_setting == null ? 'Not set' : this.formatDac(settings.dac_setting); } catch (error) { console.error('Error loading strobe settings:', error); } } - async startStrobeCalibration() { + formatDac(value) { + return '0x' + parseInt(value).toString(16).toUpperCase().padStart(2, '0'); + } + + showStrobeRunning() { + this.strobeRunning = true; const btn = document.getElementById('strobe-calibrate-btn'); - const cancelBtn = document.getElementById('strobe-cancel-btn'); - const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Calibrating'; + document.getElementById('strobe-cancel-btn').hidden = false; + document.getElementById('strobe-progress-area').hidden = false; + document.getElementById('strobe-result-area').hidden = true; + const bar = document.getElementById('strobe-progress-fill'); + bar.value = 0; + bar.classList.remove('progress-error'); + document.getElementById('strobe-progress-message').textContent = 'Starting'; + this.renderChecklist(); + } + async startStrobeCalibration() { const ledType = document.getElementById('strobe-led-type').value; - const targetCurrent = ledType === 'v3' ? 10.0 : 9.0; - + this.showStrobeRunning(); try { - btn.disabled = true; - btn.textContent = 'Starting...'; - cancelBtn.style.display = ''; - - // Reset and show progress area, hide stale results - document.getElementById('strobe-progress-area').style.display = 'block'; - document.getElementById('strobe-result-area').style.display = 'none'; - document.getElementById('strobe-progress-fill').style.width = '0%'; - document.getElementById('strobe-progress-fill').style.background = ''; - document.getElementById('strobe-progress-message').textContent = 'Starting...'; - document.getElementById('strobe-state').textContent = 'Running'; - - const response = await fetch('/api/strobe-calibration/start', { + await api('/api/strobe-calibration/start', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - led_type: ledType, - target_current: targetCurrent, - overwrite: true - }) + body: { led_type: ledType, target_current: ledType === 'v3' ? 10.0 : 9.0, overwrite: true }, }); - - if (!response.ok) { - const err = await response.json().catch(() => ({})); - throw new Error(err.error || 'Failed to start calibration'); - } - - btn.textContent = 'Calibrating...'; this.pollStrobeStatus(); } catch (error) { - console.error('Error starting strobe calibration:', error); - btn.disabled = false; - btn.textContent = originalText; - cancelBtn.style.display = 'none'; - document.getElementById('strobe-state').textContent = 'Failed'; + this.finishStrobe(); document.getElementById('strobe-progress-message').textContent = error.message; } } pollStrobeStatus() { this.strobePollingTimer = setTimeout(async () => { + let status; try { - const response = await fetch('/api/strobe-calibration/status'); - if (!response.ok) { - this.pollStrobeStatus(); - return; - } - - const status = await response.json(); - const progressFill = document.getElementById('strobe-progress-fill'); - const progressMsg = document.getElementById('strobe-progress-message'); - - if (status.progress !== undefined) { - progressFill.style.width = status.progress + '%'; - } - if (status.message) { - progressMsg.textContent = status.message; - } - - if (status.state === 'complete' || status.state === 'completed') { - this.onStrobeCalibrationDone(status); - } else if (status.state === 'failed' || status.state === 'error') { - this.onStrobeCalibrationFailed(status); - } else if (status.state === 'cancelled') { - this.onStrobeCalibrationCancelled(); - } else { - this.pollStrobeStatus(); - } + status = await api('/api/strobe-calibration/status'); } catch (error) { console.error('Error polling strobe status:', error); this.pollStrobeStatus(); + return; + } + if (status.progress !== undefined) { + document.getElementById('strobe-progress-fill').value = parseFloat(status.progress); + } + if (status.message) { + document.getElementById('strobe-progress-message').textContent = status.message; + } + + if (status.state === 'complete' || status.state === 'completed') { + this.onStrobeCalibrationDone(status); + } else if (status.state === 'failed' || status.state === 'error') { + this.onStrobeCalibrationFailed(status); + } else if (status.state === 'cancelled') { + this.runEnded('strobe', '', null); + this.finishStrobe(); + document.getElementById('strobe-progress-message').textContent = 'Cancelled'; + } else { + this.pollStrobeStatus(); } }, 500); } - onStrobeCalibrationDone(status) { - const btn = document.getElementById('strobe-calibrate-btn'); + async finishStrobe() { + this.strobeRunning = false; + document.getElementById('strobe-calibrate-btn').disabled = false; const cancelBtn = document.getElementById('strobe-cancel-btn'); - btn.disabled = false; - btn.textContent = 'Recalibrate'; - cancelBtn.style.display = 'none'; - - document.getElementById('strobe-progress-fill').style.width = '100%'; - document.getElementById('strobe-state').textContent = 'Complete'; - - // Show results - const resultArea = document.getElementById('strobe-result-area'); - const resultCard = document.getElementById('strobe-result-card'); - resultCard.style.borderColor = 'var(--success)'; - document.getElementById('strobe-result-title').textContent = 'Calibration Successful'; - - if (status.dac_setting !== undefined) { - document.getElementById('strobe-result-dac').textContent = - '0x' + status.dac_setting.toString(16).toUpperCase().padStart(2, '0'); - } - if (status.led_current !== undefined) { - document.getElementById('strobe-result-current').textContent = status.led_current.toFixed(2) + ' A'; - } - if (status.ldo_voltage !== undefined) { - document.getElementById('strobe-result-ldo').textContent = status.ldo_voltage.toFixed(2) + ' V'; - } - - resultArea.style.display = 'block'; - - // Refresh the saved DAC display + cancelBtn.hidden = true; + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel'; this.loadStrobeSettings(); + await this.refresh(); } - onStrobeCalibrationFailed(status) { - const cancelBtn = document.getElementById('strobe-cancel-btn'); - cancelBtn.style.display = 'none'; - this.loadStrobeSettings(); - - document.getElementById('strobe-progress-fill').style.width = '100%'; - document.getElementById('strobe-progress-fill').style.background = 'var(--error)'; - document.getElementById('strobe-state').textContent = 'Failed'; - document.getElementById('strobe-progress-message').textContent = - status.message || 'Calibration failed'; - - // Show failure in result area - const resultArea = document.getElementById('strobe-result-area'); - const resultCard = document.getElementById('strobe-result-card'); - resultCard.style.borderColor = 'var(--error)'; - document.getElementById('strobe-result-title').textContent = 'Calibration Failed'; - document.getElementById('strobe-result-dac').textContent = '--'; - document.getElementById('strobe-result-current').textContent = '--'; - document.getElementById('strobe-result-ldo').textContent = '--'; - resultArea.style.display = 'block'; + showStrobeResult(success, message) { + document.getElementById('strobe-result-card').style.borderColor = + success ? 'var(--color-success)' : 'var(--color-error)'; + document.getElementById('strobe-result-title').textContent = success ? 'Calibrated' : 'Calibration failed'; + document.getElementById('strobe-result-message').textContent = message; + document.getElementById('strobe-result-area').hidden = false; } - onStrobeCalibrationCancelled() { - const cancelBtn = document.getElementById('strobe-cancel-btn'); - cancelBtn.style.display = 'none'; - - document.getElementById('strobe-state').textContent = 'Idle'; - document.getElementById('strobe-progress-message').textContent = 'Cancelled by user'; - this.loadStrobeSettings(); + onStrobeCalibrationDone(status) { + document.getElementById('strobe-progress-fill').value = 100; + document.getElementById('strobe-result-dac').textContent = + status.dac_setting == null ? '--' : this.formatDac(status.dac_setting); + document.getElementById('strobe-result-current').textContent = `${formatNumber(status.led_current, 2)} A`; + document.getElementById('strobe-result-ldo').textContent = `${formatNumber(status.ldo_voltage, 2)} V`; + this.runEnded('strobe', '', null); + this.showStrobeResult(true, 'The strobe is ready. Next, calibrate the lenses.'); + this.finishStrobe(); } - async cancelStrobeCalibration() { - try { - const cancelBtn = document.getElementById('strobe-cancel-btn'); - cancelBtn.disabled = true; - cancelBtn.textContent = 'Cancelling...'; - - await fetch('/api/strobe-calibration/cancel', { method: 'POST' }); - // Polling loop will pick up the cancelled state - } catch (error) { - console.error('Error cancelling strobe calibration:', error); - } + onStrobeCalibrationFailed(status) { + const bar = document.getElementById('strobe-progress-fill'); + bar.value = 100; + bar.classList.add('progress-error'); + this.runEnded('strobe', '', status.message || 'Calibration failed'); + this.showStrobeResult(false, status.message || 'Calibration failed'); + this.finishStrobe(); } - async readStrobeDiagnostics() { - const btn = document.getElementById('strobe-diagnostics-btn'); - const originalText = btn.textContent; - + async cancelStrobeCalibration() { + const cancelBtn = document.getElementById('strobe-cancel-btn'); + cancelBtn.disabled = true; + cancelBtn.textContent = 'Cancelling'; try { - btn.disabled = true; - btn.textContent = 'Reading...'; - - const response = await fetch('/api/strobe-calibration/diagnostics'); - if (!response.ok) { - throw new Error('Failed to read diagnostics'); - } - - const data = await response.json(); - const grid = document.getElementById('strobe-diagnostics-grid'); - grid.innerHTML = ''; - - const items = [ - { label: 'LDO Voltage', value: data.ldo_voltage != null ? data.ldo_voltage.toFixed(2) + ' V' : '--' }, - { label: 'LED Current', value: data.led_current != null ? data.led_current.toFixed(2) + ' A' : '--' }, - { label: 'ADC CH0 Raw', value: data.adc_ch0_raw != null ? data.adc_ch0_raw : '--' }, - { label: 'ADC CH1 Raw', value: data.adc_ch1_raw != null ? data.adc_ch1_raw : '--' } - ]; - - items.forEach(item => { - this.addCalibrationDataItem(grid, item.label, item.value); - }); - - // Handle warnings - const warningEl = document.getElementById('strobe-diagnostics-warning'); - if (data.warning) { - warningEl.textContent = data.warning; - warningEl.style.display = 'block'; - } else { - warningEl.style.display = 'none'; - } - - document.getElementById('strobe-diagnostics-area').style.display = 'block'; + await api('/api/strobe-calibration/cancel', { method: 'POST' }); } catch (error) { - console.error('Error reading strobe diagnostics:', error); - } finally { - btn.disabled = false; - btn.textContent = originalText; + toast(error.message, 'error'); + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel'; } } } -const calibration = new CalibrationManager(); +const calibration = new CalibrationPage(); diff --git a/Software/web-server/static/js/common.js b/Software/web-server/static/js/common.js index 0c93f610..c7c3dc60 100644 --- a/Software/web-server/static/js/common.js +++ b/Software/web-server/static/js/common.js @@ -1,9 +1,126 @@ // Common functionality for all PiTrac pages +/* exported setTheme, controlPiTrac, requireStrobeSafe, escapeHtml, toast, confirmDialog, api, openSocket, onPiTracStatus, formatNumber, formatSided, + setupStatusRequest, setSetupStatus, setupHidden, setSetupHidden, showSetupChecklist */ if (typeof lucide !== 'undefined') { lucide.createIcons(); } +// -- Helpers -- + +const HTML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, c => HTML_ESCAPES[c]); +} + +function formatNumber(value, decimals) { + const n = Number(value); + return value == null || Number.isNaN(n) ? '--' : n.toFixed(decimals); +} + +// Negative is left, the sign the C++ sends and the sims pass straight through to GSPro HLA and SideSpin +function formatSided(value, decimals) { + const text = formatNumber(value, decimals); + if (text === '--') return text; + const n = Number(text) || 0; + return Math.abs(n).toFixed(decimals) + (n < 0 ? ' L' : n > 0 ? ' R' : ''); +} + +async function api(path, { method = 'GET', body } = {}) { + const res = await fetch(path, { + method, + headers: body === undefined ? undefined : { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = null; } + if (!res.ok) { + const detail = data && typeof data.detail === 'string' && data.detail; + const err = new Error((data && (data.error || data.message)) || detail || `${res.status} ${res.statusText}`); + err.data = data; + throw err; + } + return data; +} + +function openSocket(path, onMessage, { onOpen, onClose } = {}) { + let ws = null; + let timer = null; + let stopped = false; + const url = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}${path}`; + const connect = () => { + if (stopped || (ws && ws.readyState <= WebSocket.OPEN)) return; + clearTimeout(timer); + timer = null; + ws = new WebSocket(url); + ws.binaryType = 'arraybuffer'; + ws.onopen = () => onOpen && onOpen(); + ws.onmessage = (e) => onMessage(typeof e.data === 'string' ? JSON.parse(e.data) : e.data); + ws.onclose = () => { + if (onClose) onClose(); + if (!stopped && !timer) timer = setTimeout(connect, 3000); + }; + ws.onerror = () => ws.close(); + }; + const onVisible = () => { if (!document.hidden) connect(); }; + document.addEventListener('visibilitychange', onVisible); + connect(); + return { + send(msg) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }, + close() { + stopped = true; + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVisible); + if (ws) ws.close(); + }, + }; +} + +function toast(message, type = 'info', { sticky = type === 'error', actionHref = null, actionLabel = null, onAction = null } = {}) { + const region = document.getElementById('toast-region'); + if (!region) return; + const el = document.createElement('div'); + el.className = `alert alert-${type} max-w-sm whitespace-normal`; + const text = document.createElement('span'); + text.textContent = message; + el.append(text); + if (actionLabel && (actionHref || onAction)) { + const action = document.createElement(actionHref ? 'a' : 'button'); + action.className = 'btn btn-sm whitespace-nowrap'; + action.textContent = actionLabel; + if (actionHref) action.href = actionHref; + if (onAction) action.addEventListener('click', () => { el.remove(); onAction(); }); + el.append(action); + } + if (sticky) { + const close = document.createElement('button'); + close.className = 'btn btn-sm btn-ghost btn-square'; + close.setAttribute('aria-label', 'Dismiss'); + close.textContent = '✕'; + close.addEventListener('click', () => el.remove()); + el.append(close); + } else { + setTimeout(() => el.remove(), 4000); + } + region.append(el); +} + +function confirmDialog({ title, body, confirmLabel = 'Confirm', danger = false }) { + const dialog = document.getElementById('confirm-dialog'); + document.getElementById('confirm-dialog-title').textContent = title; + document.getElementById('confirm-dialog-body').textContent = body; + const ok = document.getElementById('confirm-dialog-ok'); + ok.textContent = confirmLabel; + ok.className = `btn ${danger ? 'btn-error' : 'btn-primary'}`; + dialog.returnValue = ''; + dialog.showModal(); + return new Promise((resolve) => { + dialog.addEventListener('close', () => resolve(dialog.returnValue === 'confirm'), { once: true }); + }); +} + // -- Theme -- let currentTheme = 'system'; @@ -13,18 +130,8 @@ function getSystemTheme() { } function applyTheme(theme) { - const root = document.documentElement; - document.querySelectorAll('.theme-btn').forEach(b => b.classList.remove('active')); - - if (theme === 'system') { - root.setAttribute('data-theme', getSystemTheme()); - const btn = document.querySelector('.theme-btn[data-theme="system"]'); - if (btn) btn.classList.add('active'); - } else { - root.setAttribute('data-theme', theme); - const btn = document.querySelector(`.theme-btn[data-theme="${theme}"]`); - if (btn) btn.classList.add('active'); - } + document.documentElement.setAttribute('data-theme', theme === 'system' ? getSystemTheme() : theme); + document.querySelectorAll('.theme-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === theme)); } function setTheme(theme) { @@ -34,7 +141,8 @@ function setTheme(theme) { } function initTheme() { - applyTheme(localStorage.getItem('pitrac-theme') || 'system'); + currentTheme = localStorage.getItem('pitrac-theme') || 'system'; + applyTheme(currentTheme); } window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { @@ -43,102 +151,134 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () // -- PiTrac Controls -- -function getButtons(action) { - return ['desktop', 'mobile'] - .map(s => document.getElementById(`pitrac-${action}-btn-${s}`)) - .filter(Boolean); -} +// Every other status from /api/pitrac/{start,stop,restart} is a failure or a refusal +const PITRAC_OK_STATUSES = ['started', 'already_running', 'stopped', 'not_running']; + +let pitracActionInFlight = false; +let pitracPending = null; +let pitracState = null; + +const PITRAC_PENDING_WORDS = { start: 'Starting...', stop: 'Stopping...', restart: 'Restarting...' }; async function controlPiTrac(action) { if ((action === 'start' || action === 'restart') && !(await requireStrobeSafe())) {return;} + pitracActionInFlight = true; + pitracPending = action; document.querySelectorAll('.control-btn').forEach(b => { b.disabled = true; }); - getButtons(action).forEach(b => b.classList.add('loading')); + renderPiTracButton(); try { - const res = await fetch(`/api/pitrac/${action}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || `Failed to ${action} PiTrac`); - } - const data = await res.json(); - if (typeof showStatusMessage === 'function') showStatusMessage(data.message, 'success'); - setTimeout(() => { if (typeof checkSystemStatus === 'function') checkSystemStatus(); }, 2000); + const data = await api(`/api/pitrac/${action}`, { method: 'POST' }); + if (!PITRAC_OK_STATUSES.includes(data.status)) throw new Error(data.message || `Failed to ${action} PiTrac`); + toast(data.message, 'success'); } catch (err) { - console.error(`Error ${action}ing PiTrac:`, err); - if (typeof showStatusMessage === 'function') showStatusMessage(err.message, 'error'); + toast(err.message, 'error', { actionHref: '/logs', actionLabel: 'Open logs' }); } finally { - getButtons(action).forEach(b => b.classList.remove('loading')); setTimeout(() => { - if (typeof checkPiTracStatus === 'function') { - checkPiTracStatus(); - } else { - document.querySelectorAll('.control-btn').forEach(b => { b.disabled = false; }); - } + pitracActionInFlight = false; + checkPiTracStatus(); }, 1000); } } function updatePiTracButtons(isRunning) { - const show = (btns) => btns.forEach(b => { b.style.display = ''; b.disabled = false; }); - const hide = (btns) => btns.forEach(b => { b.style.display = 'none'; }); - - if (isRunning) { - hide(getButtons('start')); - show(getButtons('stop')); - show(getButtons('restart')); - } else { - show(getButtons('start')); - hide(getButtons('stop')); - hide(getButtons('restart')); - } + const restart = document.getElementById('pitrac-restart-item'); + if (restart) restart.classList.toggle('hidden', !isRunning); + document.querySelectorAll('.control-btn').forEach(b => { b.disabled = false; }); } // -- Status Polling -- -async function checkSystemStatus() { - try { - const res = await fetch('/health'); - if (res.ok) return await res.json(); - } catch (e) { - console.error('Error checking system status:', e); +const piTracStatusSubscribers = []; + +function onPiTracStatus(fn) { + piTracStatusSubscribers.push(fn); +} + +function renderPiTracButton() { + const btn = document.getElementById('pitrac-btn'); + if (!btn || !pitracState) return; + const { is_running, pid, offline } = pitracState; + const state = offline ? 'offline' : is_running ? 'running' : 'stopped'; + const stateWord = { running: 'Running', stopped: 'Stopped', offline: 'Offline' }[state]; + const dot = { running: 'bg-success', stopped: 'bg-base-content/40', offline: 'bg-warning' }[state]; + let action = null; + if (pitracPending) action = { word: PITRAC_PENDING_WORDS[pitracPending] }; + else if (!offline) action = is_running + ? { word: 'Stop', icon: 'square', color: 'text-error', run: 'stop' } + : { word: 'Start', icon: 'play', color: 'text-success', run: 'start' }; + + const key = `${state}|${pitracPending}`; + btn.disabled = !action || !!pitracPending; + btn.dataset.action = action?.run || ''; + btn.title = offline ? 'Cannot reach the PiTrac web server' + : is_running ? `PiTrac is running (PID ${pid})` : 'PiTrac is stopped'; + btn.setAttribute('aria-label', offline ? 'PiTrac is offline.' + : pitracPending ? `PiTrac is ${stateWord.toLowerCase()}. ${PITRAC_PENDING_WORDS[pitracPending]}` + : `PiTrac is ${stateWord.toLowerCase()}. ${action.word} PiTrac.`); + const live = document.getElementById('pitrac-status-live'); + if (live && live.textContent !== stateWord) live.textContent = stateWord; + if (btn.dataset.rendered === key) return; + btn.dataset.rendered = key; + + const part = (cls, text) => { + const el = document.createElement('span'); + el.className = cls; + el.textContent = text; + return el; + }; + const dotEl = part(`w-2 h-2 rounded-full ${dot}`, ''); + const stateEl = part('hidden sm:inline', stateWord); + const nodes = [dotEl, stateEl]; + if (action) { + nodes.push(part('opacity-30 hidden sm:inline', '|')); + if (pitracPending) { + nodes.push(part('loading loading-spinner loading-xs', '')); + } else { + const icon = document.createElement('i'); + icon.dataset.lucide = action.icon; + icon.className = `icon-sm ${action.color}`; + nodes.push(icon); + } + nodes.push(part(`hidden sm:inline ${action.color || ''}`, action.word)); } - return null; + btn.replaceChildren(...nodes); + if (typeof lucide !== 'undefined') lucide.createIcons({ nodes: [btn] }); } async function checkPiTracStatus() { + let status; try { - const res = await fetch('/api/pitrac/status'); - const status = await res.json(); - updatePiTracButtons(status.is_running); - - const dot = document.getElementById('pitrac-status-dot'); - if (dot) { - dot.classList.toggle('connected', !!status.pid); - dot.classList.toggle('disconnected', !status.pid); - dot.title = status.pid ? `PiTrac Running (PID: ${status.pid})` : 'PiTrac Stopped'; - } - return status.is_running; - } catch (e) { - console.error('Failed to check PiTrac status:', e); - return false; + const data = await api('/api/pitrac/status'); + status = { is_running: !!data.is_running, pid: data.pid ?? null, offline: false }; + } catch { + status = { is_running: false, pid: null, offline: true }; } + pitracState = status; + if (!pitracActionInFlight) pitracPending = null; + renderPiTracButton(); + if (!pitracActionInFlight && !status.offline) updatePiTracButtons(status.is_running); + piTracStatusSubscribers.forEach(fn => { + try { + fn(status); + } catch (err) { + console.error('PiTrac status subscriber failed:', err); + } + }); + return status.is_running; } // -- Strobe Safety -- async function requireStrobeSafe() { try { - const res = await fetch('/api/strobe-safety'); - const data = await res.json(); + const data = await api('/api/strobe-safety'); if (data.safe) return true; showStrobeSafetyModal(data.reason || ''); return false; - } catch (_) { - showStrobeSafetyModal('Could not verify strobe safety — check server connection.'); + } catch { + showStrobeSafetyModal('Could not verify strobe safety. Check the connection to the PiTrac web server.'); return false; } } @@ -152,12 +292,51 @@ function showStrobeSafetyModal(reason) { if (typeof lucide !== 'undefined') lucide.createIcons(); } +// -- Setup checklist menu item -- + +const SETUP_HIDDEN_KEY = 'pitrac-setup-hidden'; +let setupIncomplete = false; + +function setupHidden() { + try { return localStorage.getItem(SETUP_HIDDEN_KEY) === '1'; } catch { return false; } +} + +function setSetupHidden(hidden) { + try { + if (hidden) localStorage.setItem(SETUP_HIDDEN_KEY, '1'); + else localStorage.removeItem(SETUP_HIDDEN_KEY); + } catch { /* storage blocked: the card just stays visible */ } + const item = document.getElementById('setup-show-item'); + if (item) item.classList.toggle('hidden', !(setupIncomplete && setupHidden())); +} + +function setSetupStatus(setup) { + if (!setup) return; + setupIncomplete = !setup.complete; + setSetupHidden(setupIncomplete && setupHidden()); +} + +function showSetupChecklist() { + setSetupHidden(false); + if (location.pathname === '/') document.dispatchEvent(new Event('pitrac:setup-shown')); + else location.href = '/'; +} + +const setupStatusRequest = api('/api/setup/status').catch((err) => { + console.error('Could not load setup status:', err); + return null; +}); +setupStatusRequest.then(setSetupStatus); + // -- Init -- +document.addEventListener('click', (e) => { + const menu = document.getElementById('nav-dropdown'); + if (menu && menu.open && (!menu.contains(e.target) || e.target.closest('ul a, ul button'))) menu.open = false; +}); + document.addEventListener('DOMContentLoaded', () => { initTheme(); - checkSystemStatus(); checkPiTracStatus(); - setInterval(checkSystemStatus, 5000); setInterval(checkPiTracStatus, 5000); }); diff --git a/Software/web-server/static/js/config.js b/Software/web-server/static/js/config.js index 60df8a2a..b684e313 100644 --- a/Software/web-server/static/js/config.js +++ b/Software/web-server/static/js/config.js @@ -1,1569 +1,755 @@ -// Configuration Manager JavaScript -/* global saveChanges, resetAll, reloadConfig, showDiff, - filterConfig, closeModal, setTheme, openImage, resetShot, controlPiTrac, - resetValueFromDiff, resetAllFromDiff, clearSearch, searchConfig, - resetToDefault */ - -let currentConfig = {}; -let defaultConfig = {}; -let userSettings = {}; -let categories = {}; -let configMetadata = {}; -const modifiedSettings = new Set(); -let ws = null; - -async function isPiTracRunning() { - try { - const res = await fetch('/api/pitrac/status'); - const status = await res.json(); - return !!status.is_running; - } catch (_) { - return false; - } -} - -// Initialize on page load -document.addEventListener('DOMContentLoaded', () => { - initWebSocket(); - loadConfiguration(); -}); - -// Warn before navigating away with unsaved changes -window.addEventListener('beforeunload', (e) => { - if (modifiedSettings.size > 0) { - e.preventDefault(); - e.returnValue = ''; - } -}); - -// Initialize WebSocket connection -function initWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${window.location.host}/ws`); - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - - if (data.type === 'config_update') { - updateStatus(`Configuration updated: ${data.key}`, 'success'); - if (data.requires_restart) { - isPiTracRunning().then(running => { - if (running) updateStatus('PiTrac restart needed for changes to take effect', 'warning'); - }); - } - } else if (data.type === 'config_reset') { - updateStatus('Configuration reset to defaults', 'success'); - loadConfiguration(); - } else if (data.type === 'config_import') { - updateStatus('Configuration imported', 'success'); - loadConfiguration(); - } - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - updateStatus('WebSocket connection error', 'error'); - }; -} - -// Load configuration from server -async function loadConfiguration() { - try { - modifiedSettings.clear(); - updateModifiedCount(); - - // Load all configuration data in parallel - const [configRes, defaultsRes, userRes, categoriesRes, metadataRes] = await Promise.all([ - fetch('/api/config'), - fetch('/api/config/defaults'), - fetch('/api/config/user'), - fetch('/api/config/categories'), - fetch('/api/config/metadata') - ]); - - const configData = await configRes.json(); - const defaultsData = await defaultsRes.json(); - const userData = await userRes.json(); - categories = await categoriesRes.json(); - configMetadata = await metadataRes.json(); - - currentConfig = configData.data || {}; - defaultConfig = defaultsData.data || {}; - userSettings = userData.data || {}; - - renderCategories(); - renderConfiguration(); - updateModifiedCount(); - - updateConditionalVisibility(); - setTimeout(updateConditionalVisibility, 100); - - updateStatus('Configuration loaded', 'success'); - } catch (error) { - console.error('Failed to load configuration:', error); - updateStatus('Failed to load configuration', 'error'); - } -} - -// Render category list -function renderCategories() { - const categoryList = document.getElementById('categoryList'); - categoryList.innerHTML = ''; - - // Add "All Settings" option first - const allItem = document.createElement('li'); - allItem.className = 'category-item'; - allItem.dataset.category = 'all'; - allItem.textContent = 'All Settings'; - allItem.onclick = () => selectCategory('all'); - categoryList.appendChild(allItem); - - // Add each category with its settings count - Object.keys(categories).forEach(category => { - const categoryData = categories[category]; - const basicCount = categoryData.basic ? categoryData.basic.length : 0; - const advancedCount = categoryData.advanced ? categoryData.advanced.length : 0; - const totalCount = basicCount + advancedCount; - - if (totalCount > 0) { - const li = document.createElement('li'); - li.className = 'category-item'; - li.dataset.category = category; - li.textContent = `${category} (${totalCount})`; - li.onclick = () => selectCategory(category); - categoryList.appendChild(li); - } - }); - - // Select 'all' by default - setTimeout(() => { - selectCategory('all'); - }, 100); -} - -// Select category -function selectCategory(category) { - // Update active category - document.querySelectorAll('.category-item').forEach(item => { - item.classList.remove('active'); - if (item.dataset.category === category) { - item.classList.add('active'); - } - }); - - // Render configuration for selected category - if (category === 'all') { - renderConfiguration(); - } else { - renderConfiguration(category); - } -} - -// Render configuration UI -function renderConfiguration(selectedCategory = null) { - const content = document.getElementById('configContent'); - content.innerHTML = ''; - - // Determine which categories to render - const categoriesToRender = selectedCategory && selectedCategory !== 'all' - ? { [selectedCategory]: categories[selectedCategory] } - : categories; - - if (selectedCategory === null || selectedCategory === 'all') { - const hasBasicSettings = Object.entries(categoriesToRender).some(([_, categoryData]) => - categoryData && categoryData.basic && categoryData.basic.length > 0 - ); - - if (hasBasicSettings) { - const basicSection = document.createElement('div'); - basicSection.className = 'config-section'; - - const basicHeader = document.createElement('div'); - basicHeader.className = 'config-main-section-header'; - basicHeader.innerHTML = '

Basic Settings

'; - basicSection.appendChild(basicHeader); - - Object.entries(categoriesToRender).forEach(([category, categoryData]) => { - if (!categoryData || !categoryData.basic || categoryData.basic.length === 0) return; - - const group = document.createElement('div'); - group.className = 'config-group'; - group.dataset.category = category; - - const title = document.createElement('h3'); - title.className = 'config-group-title'; - title.textContent = category; - group.appendChild(title); - - categoryData.basic.forEach(key => { - const value = getNestedValue(currentConfig, key); - const defaultValue = getNestedValue(defaultConfig, key); - const isModified = getNestedValue(userSettings, key) !== undefined; - - const item = createConfigItem(key, value, defaultValue, isModified); - group.appendChild(item); - }); - - basicSection.appendChild(group); - }); - - content.appendChild(basicSection); - } - - const hasAdvancedSettings = Object.entries(categoriesToRender).some(([_, categoryData]) => - categoryData && categoryData.advanced && categoryData.advanced.length > 0 - ); +// config.js - Setup card, category settings with an advanced toggle, search, diff, import and export +/* global api, toast, confirmDialog, openSocket, onPiTracStatus, controlPiTrac, openSimulatorEditor, Option */ +(function () { + const ADVANCED_KEY = 'pitrac-config-advanced'; + const GOLFER_KEYS = [ + 'gs_config.player.kGolferOrientation', + 'gs_config.player.kUsePracticeBalls', + 'gs_config.modes.kStartInPuttingMode', + ]; + // Model dropdowns list { name: path } instead of { value: label } + const MODEL_KEYS = ['gs_config.ball_identification.kModelPath', 'gs_config.spin_analysis.kSpinModelPath']; + const NUMERIC = ['integer', 'number', 'float']; + + let meta = {}; + let categories = {}; + let config = {}; + let defaults = {}; + let user = {}; + let calibrated = new Set(); + let dependencies = new Set(); + const pending = new Map(); + const errors = new Map(); + // Tags of our own writes (key, config_reset, config_import) to the time they were sent, so their /ws echo + // is not reported as a change from another device. Tags expire because a dropped socket never echoes. + const selfEcho = new Map(); + const ECHO_MS = 5000; + const view = { category: null, search: '' }; + let showAdvanced = false; + let piTracRunning = false; + let saving = false; + let remoteTimer = null; + + const byId = (id) => document.getElementById(id); + const getPath = (obj, key) => key.split('.').reduce((o, part) => o?.[part], obj); + const saved = (key) => getPath(config, key); + const defaultOf = (key) => getPath(defaults, key); + const current = (key) => (pending.has(key) ? pending.get(key) : saved(key)); + const isCustom = (key) => getPath(user, key) !== undefined; + const isAdvanced = (key) => meta[key]?.subcategory !== 'basic'; + const toBool = (v) => v === true || v === 1 || v === '1' || v === 'true'; + const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; + + function countLeaves(obj) { + return Object.values(obj).reduce( + (n, v) => n + (v && typeof v === 'object' && !Array.isArray(v) ? countLeaves(v) : 1), 0); + } + + function displayName(key) { + return meta[key]?.displayName + || key.split('.').pop().replace(/^k/, '').replace(/([A-Z])/g, ' $1').trim(); + } + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function badge(text, tone) { + return el('span', `badge badge-sm whitespace-nowrap ${tone}`, text); + } + + function smallButton(label, onClick) { + const btn = el('button', 'btn btn-ghost btn-xs', label); + btn.type = 'button'; + btn.addEventListener('click', onClick); + return btn; + } + + function normalizeJson(v) { + if (typeof v !== 'string') return JSON.stringify(v ?? null); + try { return JSON.stringify(JSON.parse(v)); } catch { return v; } + } + + function sameValue(key, a, b) { + const type = meta[key]?.type; + if (type === 'boolean') return toBool(a) === toBool(b); + if (a === '' || a == null || b === '' || b == null) return (a ?? '') === (b ?? ''); + if (NUMERIC.includes(type)) return Number(a) === Number(b); + if (type === 'array') return normalizeJson(a) === normalizeJson(b); + return String(a) === String(b); + } + + function modelName(path) { + return String(path).split('/').filter((p) => p && p !== 'weights').pop() || String(path); + } + + function optionsFor(key) { + const entries = Object.entries(meta[key]?.options || {}); + return MODEL_KEYS.includes(key) ? entries.map(([name, path]) => [path, name]) : entries; + } + + function formatValue(key, v) { + if (v === undefined || v === null || v === '') return 'Not set'; + const type = meta[key]?.type; + if (type === 'boolean') return toBool(v) ? 'On' : 'Off'; + if (type === 'select') { + const match = optionsFor(key).find(([value]) => String(value) === String(v)); + return match ? match[1] : MODEL_KEYS.includes(key) ? modelName(v) : String(v); + } + return typeof v === 'object' ? JSON.stringify(v) : String(v); + } + + function visible(key) { + return Object.entries(meta[key]?.visibleWhen || {}).every(([k, v]) => String(current(k)) === String(v)); + } - if (hasAdvancedSettings) { - const advancedSection = document.createElement('div'); - advancedSection.className = 'config-section'; - - const advancedHeader = document.createElement('div'); - advancedHeader.className = 'config-main-section-header'; - advancedHeader.innerHTML = '

Advanced Settings

'; - advancedSection.appendChild(advancedHeader); - - Object.entries(categoriesToRender).forEach(([category, categoryData]) => { - if (!categoryData || !categoryData.advanced || categoryData.advanced.length === 0) return; - - const group = document.createElement('div'); - group.className = 'config-group'; - group.dataset.category = category; - - const title = document.createElement('h3'); - title.className = 'config-group-title'; - title.textContent = category; - group.appendChild(title); - - categoryData.advanced.forEach(key => { - const value = getNestedValue(currentConfig, key); - const defaultValue = getNestedValue(defaultConfig, key); - const isModified = getNestedValue(userSettings, key) !== undefined; - - const item = createConfigItem(key, value, defaultValue, isModified); - group.appendChild(item); - }); - - advancedSection.appendChild(group); - }); - - content.appendChild(advancedSection); + function clientError(key, value) { + const m = meta[key] || {}; + if (NUMERIC.includes(m.type)) { + const n = Number(value); + if (value === '' || value === null || !Number.isFinite(n)) return 'Must be a number'; + if (m.type === 'integer' && !Number.isInteger(n)) return 'Must be a whole number'; + if (m.min !== undefined && n < m.min) return `Must be at least ${m.min}`; + if (m.max !== undefined && n > m.max) return `Must be at most ${m.max}`; } - } else { - Object.entries(categoriesToRender).forEach(([category, categoryData]) => { - if (!categoryData) return; - - const group = document.createElement('div'); - group.className = 'config-group'; - group.dataset.category = category; - - const title = document.createElement('h3'); - title.className = 'config-group-title'; - title.textContent = category; - group.appendChild(title); - - if (categoryData.basic && categoryData.basic.length > 0) { - const basicHeader = document.createElement('div'); - basicHeader.className = 'config-section-header'; - basicHeader.innerHTML = ''; - group.appendChild(basicHeader); - - categoryData.basic.forEach(key => { - const value = getNestedValue(currentConfig, key); - const defaultValue = getNestedValue(defaultConfig, key); - const isModified = getNestedValue(userSettings, key) !== undefined; - - const item = createConfigItem(key, value, defaultValue, isModified); - group.appendChild(item); - }); - } - - if (categoryData.advanced && categoryData.advanced.length > 0) { - if (categoryData.basic && categoryData.basic.length > 0) { - const advancedHeader = document.createElement('div'); - advancedHeader.className = 'config-section-header'; - advancedHeader.innerHTML = ''; - group.appendChild(advancedHeader); - } - - categoryData.advanced.forEach(key => { - const value = getNestedValue(currentConfig, key); - const defaultValue = getNestedValue(defaultConfig, key); - const isModified = getNestedValue(userSettings, key) !== undefined; - - const item = createConfigItem(key, value, defaultValue, isModified); - group.appendChild(item); - }); - } - - content.appendChild(group); + if (m.type === 'array' && typeof value === 'string') { + try { + if (!Array.isArray(JSON.parse(value))) return 'Must be a list like [1, 2, 3]'; + } catch { + return 'Must be a list like [1, 2, 3]'; + } + } + return null; + } + + // -- Controls and rows -- + + function setControl(control, value) { + if (control.type === 'checkbox') control.checked = toBool(value); + else if (value !== null && typeof value === 'object') control.value = JSON.stringify(value); + else control.value = value ?? ''; + } + + const controlValue = (control) => (control.type === 'checkbox' ? control.checked : control.value); + + function createControl(key, id) { + const m = meta[key] || {}; + const value = current(key); + let control; + if (m.type === 'boolean') { + control = el('input', 'toggle toggle-sm'); + control.type = 'checkbox'; + } else if (m.type === 'select') { + control = el('select', 'select select-sm w-full'); + const options = optionsFor(key); + if (value != null && value !== '' && !options.some(([v]) => String(v) === String(value))) { + options.unshift([String(value), formatValue(key, value)]); + } + control.append(...options.map(([v, label]) => new Option(label, v))); + } else if (NUMERIC.includes(m.type)) { + control = el('input', 'input input-sm w-full'); + control.type = 'number'; + if (m.min !== undefined) control.min = m.min; + if (m.max !== undefined) control.max = m.max; + control.step = m.step ?? (m.type === 'integer' ? 1 : 'any'); + } else if (m.type === 'array') { + control = el('textarea', 'textarea textarea-sm w-full font-mono'); + control.rows = 2; + } else { + control = el('input', 'input input-sm w-full'); + control.type = 'text'; + } + control.id = id; + control.dataset.key = key; + control.classList.add('cfg-control'); + setControl(control, value); + return control; + } + + function numberHint(key) { + const m = meta[key] || {}; + if (!NUMERIC.includes(m.type)) return ''; + const def = m.default ?? null; + const range = m.min !== undefined && m.max !== undefined ? `Range ${m.min} to ${m.max}` : ''; + if (def === null) return range; + return range ? `${range}, default ${def}` : `Default ${def}`; + } + + function renderRow(key, prefix, restartNoted) { + const m = meta[key] || {}; + const id = `${prefix}-${key}`; + const row = el('div', 'config-row'); + row.dataset.key = key; + row.hidden = !visible(key); + if (restartNoted) row.dataset.restartNoted = '1'; + + const info = el('div', 'min-w-0'); + const head = el('div', 'flex flex-wrap items-center gap-1.5'); + const label = el('label', 'text-sm font-medium', displayName(key)); + label.htmlFor = id; + head.append(label, el('span', 'row-badges flex flex-wrap gap-1')); + info.append(head); + if (m.description) info.append(el('p', 'text-xs opacity-60 mt-0.5', m.description)); + const hint = numberHint(key); + if (hint) info.append(el('p', 'text-xs opacity-50 mt-0.5', hint)); + + const control = createControl(key, id); + const error = el('p', 'row-error text-xs text-error'); + error.id = `${id}-error`; + control.setAttribute('aria-describedby', error.id); + // Saving the default over a calibrated value deletes the calibration entry + if (calibrated.has(key)) { + control.disabled = true; + const note = el('p', 'text-xs opacity-60 mt-0.5', 'Set by calibration. Redo it on the '); + const link = el('a', 'link', 'Calibration page'); + link.href = '/calibration'; + note.append(link, '.'); + info.append(note); + } + + const side = el('div', 'config-row-control'); + side.append(control, error, el('div', 'row-actions flex flex-wrap justify-end gap-1')); + row.append(info, side); + refreshRow(row); + return row; + } + + function refreshRow(row) { + const key = row.dataset.key; + const isPending = pending.has(key); + const isCalibrated = calibrated.has(key); + const tags = []; + if (isPending) tags.push(['Unsaved', 'badge-warning badge-soft']); + if (isCalibrated) tags.push(['Calibrated', 'badge-info badge-soft']); + else if (isCustom(key)) tags.push(['Custom', 'badge-primary badge-soft']); + if (meta[key]?.requiresRestart && !row.dataset.restartNoted) tags.push(['Needs restart', 'badge-outline opacity-60']); + if (isAdvanced(key)) tags.push(['Advanced', 'badge-outline opacity-60']); + row.querySelector('.row-badges').replaceChildren(...tags.map(([text, tone]) => badge(text, tone))); + + const actions = []; + if (isPending) actions.push(smallButton('Undo', () => setValue(key, saved(key)))); + if (isCustom(key) && !isCalibrated && !sameValue(key, current(key), defaultOf(key))) { + actions.push(smallButton('Reset to default', () => setValue(key, defaultOf(key)))); + } + row.querySelector('.row-actions').replaceChildren(...actions); + + const error = row.querySelector('.row-error'); + error.textContent = errors.get(key) || ''; + error.hidden = !errors.has(key); + row.classList.toggle('is-pending', isPending); + } + + function groupHeading(name, extra) { + const wrap = el('div', 'config-group-heading flex items-end justify-between gap-2 border-b border-base-300 pb-1'); + wrap.append(el('h4', 'text-xs font-semibold uppercase tracking-wide text-base-content/60', name)); + if (extra) wrap.append(extra); + return wrap; + } + + function group(name, keys, prefix, { extra, restartNote } = {}) { + const wrap = el('div', 'config-group'); + if (name) wrap.append(groupHeading(name, extra)); + const noted = restartNote && keys.every((key) => meta[key]?.requiresRestart); + if (noted) wrap.append(el('p', 'text-xs opacity-60 mt-1', 'Changes here apply after PiTrac restarts.')); + keys.forEach((key) => wrap.append(renderRow(key, prefix, noted))); + return wrap; + } + + // -- State changes -- + + function setValue(key, value, source) { + if (sameValue(key, value, saved(key))) pending.delete(key); + else pending.set(key, value); + const problem = clientError(key, value); + if (problem) errors.set(key, problem); + else errors.delete(key); + + document.querySelectorAll(`.cfg-control[data-key="${key}"]`).forEach((control) => { + if (control !== source) setControl(control, value); }); + document.querySelectorAll(`.config-row[data-key="${key}"]`).forEach(refreshRow); + if (dependencies.has(key)) { + document.querySelectorAll('.config-row').forEach((row) => { row.hidden = !visible(row.dataset.key); }); + renderNav(); + } + updateHeader(); } -} - -// Create configuration item element -function createConfigItem(key, value, defaultValue, isModified) { - const item = document.createElement('div'); - item.className = 'config-item'; - const isUserSet = getNestedValue(userSettings, key) !== undefined; - - if (isUserSet) { - item.classList.add('user-set'); - } else { - item.classList.add('using-default'); + function setAdvanced(on) { + showAdvanced = on; + byId('advanced-toggle').checked = on; + try { + localStorage.setItem(ADVANCED_KEY, on ? '1' : '0'); + } catch { /* storage blocked: the toggle resets on reload */ } + renderNav(); + renderRows(); } - if (isModified) { - item.classList.add('modified'); + function selectCategory(category) { + view.category = category; + view.search = ''; + byId('search-input').value = ''; + renderNav(); + renderRows(); } - item.dataset.key = key; - const metadata = configMetadata[key] || {}; + // -- Rendering -- - if (metadata.visibleWhen && !checkVisibilityCondition(metadata.visibleWhen)) { - item.style.display = 'none'; - item.dataset.hiddenByCondition = 'true'; + function categoryKeys(category) { + const { basic = [], advanced = [] } = categories[category] || {}; + return { basic, advanced: showAdvanced ? advanced : [] }; } - // Label - const label = document.createElement('div'); - label.className = 'config-label'; - - // Use display name from metadata or extract readable name from key - const displayName = metadata.displayName || (() => { - const parts = key.split('.'); - const name = parts[parts.length - 1] - .replace(/^k/, '') - .replace(/([A-Z])/g, ' $1') - .trim(); - return name; - })(); - - let labelHTML = '
'; - labelHTML += displayName; - - // Note: isUserSet already defined above - if (!isUserSet) { - labelHTML += ' DEFAULT'; + function renderNav() { + const items = Object.keys(categories).map((category) => { + const { basic, advanced } = categoryKeys(category); + return [category, [...basic, ...advanced].filter(visible).length]; + }); + byId('category-list').replaceChildren(...items.map(([category, count]) => { + const btn = el('button', 'justify-between'); + btn.type = 'button'; + const active = !view.search && category === view.category; + btn.classList.toggle('menu-active', active); + if (active) btn.setAttribute('aria-current', 'true'); + btn.append(el('span', '', category), el('span', 'opacity-60 tabular-nums', String(count))); + btn.addEventListener('click', () => selectCategory(category)); + const li = el('li'); + li.append(btn); + return li; + })); + const select = byId('category-select'); + select.replaceChildren(...items.map(([category, count]) => new Option(`${category} (${count})`, category))); + select.value = view.category; + } + + function categoryView(category) { + const frag = document.createDocumentFragment(); + frag.append(el('h3', 'text-base font-semibold', category)); + const { basic, advanced } = categoryKeys(category); + if (!basic.length && !showAdvanced) { + const empty = el('div', 'flex flex-col items-start gap-2 mt-3'); + const btn = el('button', 'btn btn-sm', 'Show advanced settings'); + btn.type = 'button'; + btn.addEventListener('click', () => setAdvanced(true)); + empty.append(el('p', 'text-sm opacity-70', 'Everything in this category is advanced.'), btn); + frag.append(empty); + return frag; + } + const groups = new Map(); + basic.forEach((key) => { + const name = meta[key]?.basicSubcategory || ''; + groups.set(name, [...(groups.get(name) || []), key]); + }); + [...groups].sort(([a], [b]) => (a ? 1 : 0) - (b ? 1 : 0)) + .forEach(([name, keys]) => frag.append(group(name === category ? '' : name, keys, 'cfg'))); + if (advanced.length) frag.append(group('Advanced', advanced, 'cfg')); + return frag; + } + + function searchResults() { + const query = view.search.toLowerCase(); + const matches = (key) => [key, displayName(key), meta[key]?.description || ''] + .some((text) => text.toLowerCase().includes(query)); + const frag = document.createDocumentFragment(); + const summary = el('p', 'text-sm opacity-70'); + frag.append(summary); + let total = 0; + let anyAdvanced = false; + Object.entries(categories).forEach(([category, { basic = [], advanced = [] }]) => { + const keys = [...basic, ...advanced].filter((key) => visible(key) && matches(key)); + if (!keys.length) return; + total += keys.length; + anyAdvanced = anyAdvanced || keys.some(isAdvanced); + const section = el('div', 'config-search-group'); + section.append(el('h3', 'text-base font-semibold', category)); + keys.forEach((key) => section.append(renderRow(key, 'cfg'))); + frag.append(section); + }); + summary.textContent = total + ? `${plural(total, 'result')}${anyAdvanced ? '. Includes advanced settings.' : ''}` + : `No settings match "${view.search}".`; + return frag; } - if (metadata.requiresRestart) { - labelHTML += ' ↻'; + function renderRows() { + byId('settings-rows').replaceChildren(view.search ? searchResults() : categoryView(view.category)); } - labelHTML += '
'; - - if (metadata.description) { - labelHTML += `
${metadata.description}
`; + function renderSetup() { + const keys = Object.keys(meta).filter((key) => meta[key].setup && !meta[key].internal); + const detect = el('button', 'btn btn-xs mb-1', 'Detect cameras'); + detect.type = 'button'; + detect.addEventListener('click', () => detectCameras(detect)); + byId('setup-groups').replaceChildren( + group('Hardware', keys.filter((key) => !GOLFER_KEYS.includes(key)), 'setup', { extra: detect, restartNote: true }), + group('Golfer', GOLFER_KEYS.filter((key) => keys.includes(key)), 'setup', { restartNote: true }), + ); } - labelHTML += `${key}`; - - label.innerHTML = labelHTML; - item.appendChild(label); - - // Input - const inputContainer = document.createElement('div'); - inputContainer.className = 'input-container'; - - const input = createInput(key, value, defaultValue, isUserSet); - input.className = 'config-input'; - input.dataset.key = key; - input.dataset.original = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value); - input.dataset.default = (typeof defaultValue === 'object' && defaultValue !== null) ? JSON.stringify(defaultValue) : String(defaultValue); - if (!isUserSet) { - input.classList.add('default-value'); - if (input.tagName === 'INPUT' && input.type === 'text') { - input.placeholder = `Default: ${defaultValue}`; + function updateHeader() { + const n = pending.size; + const save = byId('save-btn'); + save.disabled = !n || saving; + save.textContent = saving ? 'Saving' : n ? `Save ${plural(n, 'change')}` : 'Save'; + byId('discard-btn').disabled = !n || saving; + const custom = countLeaves(user); + byId('custom-count').textContent = custom ? `${plural(custom, 'custom setting')}` : 'No custom settings'; + } + + function render() { + renderSetup(); + renderNav(); + renderRows(); + updateHeader(); + } + + function showLoadError(err) { + console.error('Could not load configuration:', err); + const alert = el('div', 'alert alert-error alert-soft'); + const retry = el('button', 'btn btn-sm', 'Retry'); + retry.type = 'button'; + retry.addEventListener('click', load); + alert.append(el('span', '', 'Could not load the configuration.'), retry); + byId('setup-groups').replaceChildren(alert); + byId('settings-rows').replaceChildren(); + } + + // -- Server data -- + + async function refreshSaved() { + const [c, u, cal] = await Promise.all([api('/api/config'), api('/api/config/user'), api('/api/config/calibrated')]); + config = c.data || {}; + user = u.data || {}; + calibrated = new Set(cal || []); + for (const [key, value] of pending) { + if (sameValue(key, value, saved(key))) pending.delete(key); + } + render(); + } + + async function load() { + try { + const [m, cats, d] = await Promise.all([ + api('/api/config/metadata'), api('/api/config/categories'), api('/api/config/defaults'), + ]); + meta = m || {}; + categories = cats || {}; + defaults = d.data || {}; + dependencies = new Set(Object.values(meta).flatMap((s) => Object.keys(s.visibleWhen || {}))); + if (!categories[view.category]) view.category = Object.keys(categories)[0]; + await refreshSaved(); + } catch (err) { + showLoadError(err); + return; } + if (location.hash === '#setup') byId('setup').scrollIntoView(); } - const inputWrapper = document.createElement('div'); - inputWrapper.className = 'input-wrapper'; - inputWrapper.appendChild(input); - - if (isUserSet || isModified) { - const clearBtn = document.createElement('button'); - clearBtn.className = 'clear-value-btn'; - clearBtn.innerHTML = '×'; - clearBtn.title = 'Reset to default'; - clearBtn.onclick = (e) => { - e.preventDefault(); - resetValue(key); - }; - inputWrapper.appendChild(clearBtn); - } - - const validationError = document.createElement('div'); - validationError.className = 'validation-error'; - validationError.style.display = 'none'; - - const validateAndUpdate = async () => { - const isValid = await validateInput(key, input.value, validationError); - if (isValid) { - handleValueChange(key, input.value, input.dataset.original); - - if (input.value !== defaultValue) { - input.classList.remove('default-value'); - item.classList.remove('using-default'); - item.classList.add('user-set'); - - if (!inputWrapper.querySelector('.clear-value-btn')) { - const clearBtn = document.createElement('button'); - clearBtn.className = 'clear-value-btn'; - clearBtn.innerHTML = '×'; - clearBtn.title = 'Reset to default'; - clearBtn.onclick = (e) => { - e.preventDefault(); - resetValue(key); - }; - inputWrapper.appendChild(clearBtn); - } - } - } - }; - - if (input.tagName === 'SELECT') { - input.onchange = validateAndUpdate; - } else { - input.oninput = validateAndUpdate; - } + // -- Actions -- - inputContainer.appendChild(inputWrapper); - inputContainer.appendChild(validationError); - - if (key === 'cameras.slot1.type' || key === 'cameras.slot2.type' || - key === 'cameras.slot1_type' || key === 'cameras.slot2_type') { - inputContainer.style.display = 'flex'; - inputContainer.style.alignItems = 'center'; - inputContainer.style.gap = '0.75rem'; - - const detectBtn = document.createElement('button'); - detectBtn.className = 'btn btn-secondary btn-small'; - detectBtn.textContent = 'Detect'; - detectBtn.style.flexShrink = '0'; - detectBtn.title = 'Auto-detect connected camera'; - detectBtn.onclick = async () => { - detectBtn.disabled = true; - const originalText = detectBtn.textContent; - detectBtn.textContent = 'Detecting...'; + async function save() { + if (!pending.size || saving) return; + saving = true; + updateHeader(); + const failed = []; + let restart = false; + let count = 0; + for (const [key, value] of [...pending]) { + selfEcho.set(key, Date.now()); try { - await detectAndSetCameras(key); - } finally { - detectBtn.disabled = false; - detectBtn.textContent = originalText; - } - }; - inputContainer.appendChild(detectBtn); - } - - item.appendChild(inputContainer); - - // Actions - const actions = document.createElement('div'); - actions.className = 'config-actions'; - - // Only show reset button for user-set values (not for defaults) - if (isUserSet && !isModified) { - const resetBtn = document.createElement('button'); - resetBtn.className = 'btn btn-secondary btn-small'; - resetBtn.textContent = 'Reset'; - resetBtn.title = 'Reset to default value'; - resetBtn.onclick = () => resetValue(key); - actions.appendChild(resetBtn); - } - - item.appendChild(actions); - - return item; -} - -// Create appropriate input based on value type -function createInput(key, value, defaultValue, isUserSet) { - const metadata = configMetadata[key] || {}; - - if (key.includes('kModelPath')) { - const select = document.createElement('select'); - - if (metadata.options && Object.keys(metadata.options).length > 0) { - Object.entries(metadata.options).forEach(([modelName, modelPath]) => { - const option = document.createElement('option'); - option.value = modelPath; - option.textContent = modelName; - if (modelPath === value) { - option.selected = true; - } - select.appendChild(option); + const res = await api(`/api/config/${key}`, { method: 'PUT', body: { value } }); + if (sameValue(key, pending.get(key), value)) pending.delete(key); + errors.delete(key); + restart = restart || !!res?.requires_restart; + count += 1; + } catch (err) { + selfEcho.delete(key); + errors.set(key, err.message); + failed.push(key); + } + } + saving = false; + try { + await refreshSaved(); + } catch (err) { + render(); + toast(`Saved, but the page could not reload the settings: ${err.message}`, 'warning'); + } + if (failed.length === 1) { + toast(`Could not save ${displayName(failed[0])}: ${errors.get(failed[0])}`, 'error'); + } else if (failed.length) { + toast(`Could not save ${plural(failed.length, 'setting')}: ${failed.map(displayName).join(', ')}`, 'error'); + } + if (count) toast(`Saved ${plural(count, 'change')}`, 'success'); + if (restart && piTracRunning) { + toast('Restart PiTrac to apply', 'warning', { + sticky: true, actionLabel: 'Restart now', onAction: () => controlPiTrac('restart'), }); - } else { - if (value) { - const option = document.createElement('option'); - option.value = value; - const parts = value.split('/'); - let displayName = 'Unknown Model'; - for (let i = parts.length - 2; i >= 0; i--) { - if (parts[i] && parts[i] !== 'weights') { - displayName = parts[i]; - break; - } - } - option.textContent = displayName; - option.selected = true; - select.appendChild(option); - } } - - return select; } - if (metadata.type === 'select' && metadata.options) { - const select = document.createElement('select'); - Object.entries(metadata.options).forEach(([optValue, optDisplay]) => { - const option = document.createElement('option'); - option.value = optValue; - option.textContent = optDisplay; - if (String(value) === String(optValue)) { - option.selected = true; - } - select.appendChild(option); + async function discard() { + const ok = await confirmDialog({ + title: 'Discard changes?', + body: `Your ${plural(pending.size, 'unsaved change')} will be lost.`, + confirmLabel: 'Discard', + danger: true, }); - return select; - } - - if (metadata.type === 'ip_address') { - const input = document.createElement('input'); - input.type = 'text'; - input.value = value || ''; - input.pattern = '^(([0-9]{1,3}\\.){3}[0-9]{1,3})(:[0-9]{1,5})?$'; - input.placeholder = 'e.g., 192.168.1.100 or 192.168.1.100:921'; - if (!isUserSet && defaultValue !== undefined && defaultValue !== '') { - input.placeholder = `Default: ${defaultValue}`; - } - return input; - } - - // Handle arrays and complex objects - if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { - const textarea = document.createElement('textarea'); - textarea.value = JSON.stringify(value, null, 2); - textarea.rows = 3; - textarea.style.width = '100%'; - textarea.style.fontFamily = 'Monaco, Menlo, monospace'; - textarea.style.fontSize = '0.875rem'; - if (!isUserSet) { - textarea.placeholder = `Default: ${JSON.stringify(defaultValue, null, 2)}`; - } - return textarea; - } else if (typeof value === 'boolean' || value === '0' || value === '1') { - const select = document.createElement('select'); - select.innerHTML = ` - - - `; - return select; - } else if (typeof value === 'number' || !isNaN(value)) { - const input = document.createElement('input'); - input.type = 'number'; - input.value = value; - - // Set constraints based on key patterns - if (key.includes('Port')) { - input.min = 1; - input.max = 65535; - } else if (key.includes('Gain')) { - input.min = 0.5; - input.max = 16; - input.step = 0.1; - } - - return input; - } else { - const input = document.createElement('input'); - input.type = 'text'; - input.value = value || ''; - if (!isUserSet && defaultValue !== undefined) { - input.placeholder = `Default: ${defaultValue}`; - } - return input; - } -} - -// Handle value change -async function handleValueChange(key, currentValue, originalValue) { - try { - let current = currentValue; - let original = originalValue; - const defaultValue = getNestedValue(defaultConfig, key); - - if (current === 'true') current = true; - else if (current === 'false') current = false; - else if (!isNaN(current) && current !== '') current = Number(current); - else if (typeof current === 'string' && (current.trim().startsWith('[') || current.trim().startsWith('{'))) { - try { - current = JSON.parse(current); - } catch (e) { - // Keep as string if invalid JSON - } - } - - if (original === 'true') original = true; - else if (original === 'false') original = false; - else if (!isNaN(original) && original !== '') original = Number(original); - else if (typeof original === 'string' && (original.trim().startsWith('[') || original.trim().startsWith('{'))) { - try { - original = JSON.parse(original); - } catch (e) { - // Keep as string if invalid JSON - } - } - - let defaultVal = defaultValue; - if (defaultVal === 'true' || defaultVal === '1') defaultVal = true; - else if (defaultVal === 'false' || defaultVal === '0') defaultVal = false; - else if (!isNaN(defaultVal) && defaultVal !== '') defaultVal = Number(defaultVal); - - const isModified = JSON.stringify(current) !== JSON.stringify(original); - const isDifferentFromDefault = JSON.stringify(current) !== JSON.stringify(defaultVal); - - setNestedValue(currentConfig, key, current); - - if (isDifferentFromDefault) { - setNestedValue(userSettings, key, current); - } else { - deleteNestedValue(userSettings, key); - } - - if (key === 'system.mode') { - updateConditionalVisibility(); - } - - const item = document.querySelector(`[data-key="${key}"]`); - if (item) { - const inputEl = item.querySelector('.config-input'); - if (isModified) { - modifiedSettings.add(key); - item.classList.add('modified'); - if (inputEl) inputEl.classList.add('modified'); - } else { - modifiedSettings.delete(key); - item.classList.remove('modified'); - if (inputEl) inputEl.classList.remove('modified'); - } - - if (isDifferentFromDefault) { - item.classList.remove('using-default'); - item.classList.add('user-set'); - - const badge = item.querySelector('.default-badge'); - if (badge) badge.remove(); - - const inputWrapper = item.querySelector('.input-wrapper'); - if (inputWrapper && !inputWrapper.querySelector('.clear-value-btn')) { - const clearBtn = document.createElement('button'); - clearBtn.className = 'clear-value-btn'; - clearBtn.innerHTML = '×'; - clearBtn.title = 'Reset to default'; - clearBtn.onclick = (e) => { - e.preventDefault(); - resetValue(key); - }; - inputWrapper.appendChild(clearBtn); - } - - const input = item.querySelector('.config-input'); - if (input) { - input.classList.remove('default-value'); - } - } else { - item.classList.remove('user-set'); - item.classList.add('using-default'); - - const badge = item.querySelector('.default-badge'); - if (!badge) { - const labelName = item.querySelector('.config-label-name'); - if (labelName && !labelName.querySelector('.default-badge')) { - const badgeHtml = ' DEFAULT'; - labelName.insertAdjacentHTML('beforeend', badgeHtml); - } - } - - const clearBtn = item.querySelector('.clear-value-btn'); - if (clearBtn) clearBtn.remove(); - - const input = item.querySelector('.config-input'); - if (input) { - input.classList.add('default-value'); - } - } - } - - updateModifiedCount(); - - if (isModified) { - updateStatus(`Modified: ${key}`, 'info'); + if (!ok) return; + pending.clear(); + errors.clear(); + render(); + } + + async function putDefault(key) { + selfEcho.set(key, Date.now()); + try { + await api(`/api/config/${key}`, { method: 'PUT', body: { value: defaultOf(key) } }); + } catch (err) { + selfEcho.delete(key); + throw err; + } + pending.delete(key); + errors.delete(key); + } + + async function showDiff() { + let diff; + try { + diff = (await api('/api/config/diff')).data || {}; + } catch (err) { + toast(`Could not load the diff: ${err.message}`, 'error'); + return; } - } catch (error) { - console.error('Failed to handle value change:', error); - updateStatus('Failed to update value', 'error'); - } -} - -// Save all changes -async function saveChanges() { - if (modifiedSettings.size === 0) { - updateStatus('No changes to save', 'warning'); - return; + for (const [key, value] of pending) diff[key] = { user: value, default: defaultOf(key), source: 'unsaved' }; + + const entries = Object.entries(diff); + byId('diff-summary').textContent = entries.length + ? `${plural(entries.length, 'setting')} differ from the defaults.` + : 'Everything matches the defaults.'; + const list = el('ul', 'flex flex-col'); + entries.forEach(([key, { user: mine, default: def, source }]) => { + const item = el('li', 'flex flex-wrap items-start gap-x-4 gap-y-1 py-3 border-b border-base-300 last:border-0'); + const name = el('div', 'min-w-0 flex-1 basis-48'); + const title = el('div', 'flex flex-wrap items-center gap-1.5'); + title.append(el('span', 'font-medium', displayName(key))); + if (source === 'calibration') title.append(badge('Calibrated', 'badge-info badge-soft')); + if (source === 'unsaved') title.append(badge('Unsaved', 'badge-warning badge-soft')); + name.append(title, el('div', 'text-xs font-mono opacity-50 break-all', key)); + + const values = el('div', 'text-sm min-w-0 basis-full sm:basis-auto sm:max-w-[45%] break-all'); + const line = (label, value) => { + const row = el('div'); + row.append(el('span', 'opacity-60', `${label} `), el('code', '', formatValue(key, value))); + return row; + }; + values.append(line('Default', def), line('Yours', mine)); + + item.append(name, values); + if (source === 'user') { + item.append(smallButton('Reset', () => resetFromDiff(key))); + } else if (source === 'unsaved') { + item.append(smallButton('Undo', () => { + setValue(key, saved(key)); + showDiff(); + })); + } + list.append(item); + }); + byId('diff-body').replaceChildren(list); + const dialog = byId('diff-dialog'); + if (!dialog.open) dialog.showModal(); } - const saveBtn = document.getElementById('saveBtn'); - if (saveBtn) saveBtn.disabled = true; - - try { - - updateStatus('Saving changes...', ''); - - const errors = []; - const successfulKeys = []; - const requiresRestart = []; - let savedCount = 0; - let resetCount = 0; - - for (const key of modifiedSettings) { - const input = document.querySelector(`.config-input[data-key="${key}"]`); - if (!input) continue; - - let value = input.value; - const defaultValue = getNestedValue(defaultConfig, key); - - // Convert value type - if (value === 'true') value = true; - else if (value === 'false') value = false; - else if (!isNaN(value) && value !== '') value = Number(value); - else if (typeof value === 'string' && (value.trim().startsWith('[') || value.trim().startsWith('{'))) { - try { - value = JSON.parse(value); - } catch (e) { - // Keep as string if invalid JSON - } - } - - let defaultVal = defaultValue; - if (defaultVal === 'true' || defaultVal === '1') defaultVal = true; - else if (defaultVal === 'false' || defaultVal === '0') defaultVal = false; - else if (!isNaN(defaultVal) && defaultVal !== '') defaultVal = Number(defaultVal); - - try { - const response = await fetch(`/api/config/${key}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value }) - }); - - const result = await response.json(); - - if (result.error) { - errors.push(`${key}: ${result.error}`); - } else { - successfulKeys.push(key); - if (input) { - input.dataset.original = (typeof value === 'object' && value !== null) ? JSON.stringify(value) : String(value); - } - - const item = document.querySelector(`[data-key="${key}"]`); - if (item) { - item.classList.remove('modified'); - const inputEl = item.querySelector('.config-input'); - if (inputEl) inputEl.classList.remove('modified'); - } - - if (value === defaultVal) { - resetCount++; - } else { - savedCount++; - } - - if (result.requires_restart) { - requiresRestart.push(key); - } - } - } catch (error) { - errors.push(`${key}: ${error.message}`); - } - } - - // Remove successfully saved keys even if some failed - for (const key of successfulKeys) { - modifiedSettings.delete(key); - } - - if (errors.length > 0) { - updateStatus(`Errors: ${errors.join(', ')}`, 'error'); - } else { - updateModifiedCount(); - - let message = ''; - if (savedCount > 0) { - message += `Saved ${savedCount} custom setting${savedCount !== 1 ? 's' : ''}`; - } - if (resetCount > 0) { - if (message) message += ', '; - message += `Reset ${resetCount} to default${resetCount !== 1 ? 's' : ''}`; - } - - if (requiresRestart.length > 0) { - const running = await isPiTracRunning(); - if (running) { - updateStatus(message + '. Restart PiTrac for changes to take effect.', 'warning'); - } else { - updateStatus(message || 'All changes saved successfully', 'success'); - } - } else { - updateStatus(message || 'All changes saved successfully', 'success'); - } - } - - } catch (e) { - console.error('Save failed:', e); - updateStatus('Save failed: ' + e.message, 'error'); - } finally { - updateModifiedCount(); - } -} - -function resetToDefault(key) { - resetValue(key); -} - -// Reset single value -async function resetValue(key) { - try { - // If we have an unsaved local modification, just revert to last-saved value - if (modifiedSettings.has(key)) { - const item = document.querySelector(`[data-key="${key}"]`); - const input = item ? item.querySelector('.config-input') : null; - if (input) { - input.value = input.dataset.original; - input.classList.remove('modified'); - } - if (item) { - item.classList.remove('modified'); - } - modifiedSettings.delete(key); - // Restore currentConfig and userSettings to their pre-edit values - const originalRaw = input ? input.dataset.original : undefined; - if (originalRaw !== undefined) { - let restored = originalRaw; - try { restored = JSON.parse(originalRaw); } catch (_) { /* keep as string */ } - setNestedValue(currentConfig, key, restored); - - // Fix visual state: if this value matches default, show as default - const defaultValue = getNestedValue(defaultConfig, key); - const isDefault = JSON.stringify(restored) === JSON.stringify(defaultValue); - if (item) { - if (isDefault) { - item.classList.add('using-default'); - item.classList.remove('user-set'); - deleteNestedValue(userSettings, key); - } else { - item.classList.remove('using-default'); - item.classList.add('user-set'); - } - } - if (input && isDefault) { - input.classList.add('default-value'); - } - } - updateModifiedCount(); - updateStatus(`Reverted unsaved changes to ${key}`, 'info'); + async function resetFromDiff(key) { + const ok = await confirmDialog({ + title: `Reset ${displayName(key)}?`, + body: `It goes back to ${formatValue(key, defaultOf(key))}.`, + confirmLabel: 'Reset', + }); + if (!ok) return; + try { + await putDefault(key); + await refreshSaved(); + toast(`${displayName(key)} is back to its default`, 'success'); + } catch (err) { + toast(`Could not reset ${displayName(key)}: ${err.message}`, 'error'); + } + showDiff(); + } + + function exportConfig() { + const link = el('a'); + link.href = '/api/config/export'; + link.download = `pitrac-config-${new Date().toLocaleDateString('en-CA')}.json`; + link.click(); + } + + async function importConfig(file) { + let data; + try { + data = JSON.parse(await file.text()); + } catch { + toast('That file is not a PiTrac settings export.', 'error'); return; } - - // Otherwise, this is a saved user override -- reset to default on server - const defaultValue = getNestedValue(defaultConfig, key); - - const response = await fetch(`/api/config/${key}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ value: defaultValue }) + const ok = await confirmDialog({ + title: `Import ${file.name}?`, + body: data && data.calibration_data + ? 'Your custom settings and calibration are replaced by the ones in this file.' + : 'Your custom settings are replaced by the ones in this file.', + confirmLabel: 'Import', }); - - const result = await response.json(); - - if (result.error) { - updateStatus(`Failed to reset: ${result.error}`, 'error'); + if (!ok) return; + selfEcho.set('config_import', Date.now()); + let result; + try { + result = await api('/api/config/import', { method: 'POST', body: data }); + } catch (err) { + selfEcho.delete('config_import'); + toast(`Import failed: ${err.message}`, 'error'); + return; + } + await refreshSaved(); + const skipped = Object.entries(result?.skipped || {}); + if (skipped.length) { + const list = skipped.map(([key, reason]) => `${key.split('.').pop()} (${reason})`).join(', '); + const count = skipped.length === 1 ? 'one setting' : `${skipped.length} settings`; + toast(`Settings imported, except ${count} this PiTrac cannot use: ${list}.`, 'warning', { sticky: true }); } else { - updateStatus(`Reset ${key} to default`, 'success'); - modifiedSettings.delete(key); - updateModifiedCount(); - - deleteNestedValue(userSettings, key); - - // Update UI - const item = document.querySelector(`[data-key="${key}"]`); - if (item) { - item.classList.remove('modified', 'user-set'); - item.classList.add('using-default'); - - const input = item.querySelector('.config-input'); - if (input) { - input.value = defaultValue; - input.dataset.original = (typeof defaultValue === 'object' && defaultValue !== null) ? JSON.stringify(defaultValue) : String(defaultValue); - input.classList.remove('modified'); - input.classList.add('default-value'); - if (input.tagName === 'INPUT' && input.type === 'text') { - input.placeholder = `Default: ${defaultValue}`; - } - } - - const badge = item.querySelector('.default-badge'); - if (!badge) { - const labelName = item.querySelector('.config-label-name'); - if (labelName) { - const badgeHtml = ' DEFAULT'; - labelName.insertAdjacentHTML('beforeend', badgeHtml); - } - } - - const clearBtn = item.querySelector('.clear-value-btn'); - if (clearBtn) { - clearBtn.remove(); - } - - const actions = item.querySelector('.config-actions'); - if (actions) { - actions.innerHTML = ''; - } - } + toast('Settings imported', 'success'); } - } catch (error) { - console.error('Failed to reset value:', error); - updateStatus('Failed to reset value', 'error'); } -} - -// Reset all to defaults -function resetAll() { - showConfirm( - 'Reset All Settings', - 'Are you sure you want to reset all settings to defaults? This cannot be undone.', - async () => { - try { - const response = await fetch('/api/config/reset', { - method: 'POST' - }); - - const result = await response.json(); - - if (result.success) { - updateStatus('All settings reset to defaults', 'success'); - modifiedSettings.clear(); - loadConfiguration(); - } else { - updateStatus(`Failed to reset: ${result.message}`, 'error'); - } - } catch (error) { - console.error('Failed to reset all:', error); - updateStatus('Failed to reset configuration', 'error'); - } - } - ); -} -// Reload configuration -async function reloadConfig() { - if (modifiedSettings.size > 0) { - if (!confirm(`You have ${modifiedSettings.size} unsaved change(s). Reload anyway?`)) { + async function resetAll() { + const ok = await confirmDialog({ + title: 'Reset all settings?', + body: 'Your custom settings go back to defaults. Calibration is kept.', + confirmLabel: 'Reset all', + danger: true, + }); + if (!ok) return; + selfEcho.set('config_reset', Date.now()); + try { + await api('/api/config/reset', { method: 'POST' }); + } catch (err) { + selfEcho.delete('config_reset'); + toast(`Could not reset: ${err.message}`, 'error'); return; } - } - updateStatus('Reloading configuration...', ''); - await loadConfiguration(); -} - -// Show differences — merges saved server-side diffs with any unsaved local changes -async function showDiff() { - try { - const response = await fetch('/api/config/diff'); - const result = await response.json(); - const diff = result.data || {}; - - // Merge in unsaved local changes so the diff is complete - for (const key of modifiedSettings) { - const input = document.querySelector(`.config-input[data-key="${key}"]`); - if (!input) continue; - let current = input.value; - if (current === 'true') current = true; - else if (current === 'false') current = false; - else if (!isNaN(current) && current !== '') current = Number(current); - - const defaultVal = getNestedValue(defaultConfig, key); - if (JSON.stringify(current) !== JSON.stringify(defaultVal)) { - diff[key] = { user: current, default: defaultVal, unsaved: true }; - } - } - - if (Object.keys(diff).length === 0) { - updateStatus('No differences from defaults', ''); + pending.clear(); + errors.clear(); + await refreshSaved(); + toast('Settings are back to defaults. Calibration was kept.', 'success'); + } + + async function detectCameras(btn) { + btn.disabled = true; + try { + const result = await api('/api/cameras/detect'); + const warnings = result.warnings || []; + if (!result.success) { + toast([result.message || 'No cameras found', ...warnings].join('. '), 'error'); + return; + } + setValue('cameras.slot1.type', String(result.configuration.slot1.type)); + if (result.cameras.length > 1) setValue('cameras.slot2.type', String(result.configuration.slot2.type)); + toast(`${result.message}. Save to keep it.`, 'success'); + warnings.forEach((w) => toast(w, 'warning')); + } catch (err) { + toast(`Camera detection failed: ${err.message}`, 'error'); + } finally { + btn.disabled = false; + } + } + + async function manageSims() { + let sims = null; + try { + sims = await api('/api/sims'); + } catch { /* the menu shows its own load error */ } + if (Array.isArray(sims) && !sims.length) { + openSimulatorEditor(); return; } + window.scrollTo({ top: 0, behavior: 'smooth' }); + byId('sims-menu').open = true; + byId('sims-nav-btn').focus({ preventScroll: true }); + } - const unsavedCount = Object.values(diff).filter(v => v.unsaved).length; - - let diffHtml = ` -
-
-

Configuration Differences

-

${Object.keys(diff).length} settings differ from defaults

-
`; - - if (unsavedCount > 0) { - diffHtml += ` -
- ${unsavedCount} unsaved change(s) shown below — save to persist them. -
`; - } - - diffHtml += ` -
- - - - - - - - - - - `; - - Object.entries(diff).forEach(([key, values]) => { - const defaultVal = formatValue(values.default); - const userVal = formatValue(values.user); - const metadata = configMetadata[key] || {}; - const displayName = metadata.displayName || key.split('.').pop(); - const unsavedTag = values.unsaved - ? ' (unsaved)' - : ''; - - diffHtml += ` - - - - - - - `; + function onSocket(data) { + if (!data || typeof data.type !== 'string' || !data.type.startsWith('config_')) return; + const tag = data.type === 'config_update' ? data.key : data.type; + const sentAt = selfEcho.get(tag); + selfEcho.delete(tag); + if (sentAt !== undefined && Date.now() - sentAt < ECHO_MS) return; + clearTimeout(remoteTimer); + remoteTimer = setTimeout(async () => { + try { + await refreshSaved(); + } catch (err) { + console.error('Could not reload configuration:', err); + } + toast('Settings changed on another device', 'info'); + }, 300); + } + + // -- Wiring -- + + document.addEventListener('DOMContentLoaded', () => { + try { + showAdvanced = localStorage.getItem(ADVANCED_KEY) === '1'; + } catch { /* storage blocked: basic settings only */ } + byId('advanced-toggle').checked = showAdvanced; + byId('advanced-toggle').addEventListener('change', (e) => setAdvanced(e.target.checked)); + byId('category-select').addEventListener('change', (e) => selectCategory(e.target.value)); + byId('search-input').addEventListener('input', (e) => { + view.search = e.target.value.trim(); + renderNav(); + renderRows(); }); - - diffHtml += ` - -
SettingDefault ValueYour ValueAction
-
${displayName}${unsavedTag}
-
${key}
-
- ${defaultVal} - - ${userVal} - - -
-
- -
- `; - - showModal('Configuration Differences', diffHtml); - } catch (error) { - console.error('Failed to get diff:', error); - updateStatus('Failed to get differences', 'error'); - } -} - -function formatValue(value) { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (typeof value === 'boolean') return value ? 'true' : 'false'; - if (typeof value === 'string') return `"${value}"`; - if (typeof value === 'object') return JSON.stringify(value, null, 2); - return String(value); -} - -async function resetValueFromDiff(key) { - await resetValue(key); - closeModal(); - showDiff(); -} - -function resetAllFromDiff() { - closeModal(); - resetAll(); -} - -function searchConfig() { - const searchTerm = document.getElementById('searchInput').value.toLowerCase(); - - if (!searchTerm) { - document.querySelectorAll('.config-item').forEach(item => { - item.style.display = 'grid'; + byId('save-btn').addEventListener('click', save); + byId('discard-btn').addEventListener('click', discard); + byId('manage-sims-btn').addEventListener('click', manageSims); + + const more = byId('config-more'); + const moreActions = { diff: showDiff, export: exportConfig, import: () => byId('import-file').click(), 'reset-all': resetAll }; + more.addEventListener('click', (e) => { + const item = e.target.closest('[data-action]'); + if (!item) return; + more.open = false; + moreActions[item.dataset.action](); }); - document.querySelectorAll('.config-group').forEach(group => { - group.style.display = 'block'; + document.addEventListener('click', (e) => { + if (more.open && !more.contains(e.target)) more.open = false; + }); + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape' || !more.open) return; + more.open = false; + more.querySelector('summary').focus(); + }); + byId('import-file').addEventListener('change', (e) => { + const file = e.target.files[0]; + e.target.value = ''; + if (file) importConfig(file); }); - updateConditionalVisibility(); - return; - } - let hasVisibleItems = false; + const onEdit = (e) => { + const control = e.target.closest?.('.cfg-control'); + if (control) setValue(control.dataset.key, controlValue(control), control); + }; + document.addEventListener('input', onEdit); + document.addEventListener('change', onEdit); - document.querySelectorAll('.config-group').forEach(group => { - group.style.display = 'none'; + onPiTracStatus((status) => { piTracRunning = status.is_running; }); + openSocket('/ws', onSocket, { onOpen: () => selfEcho.clear() }); + load(); }); - document.querySelectorAll('.config-item').forEach(item => { - const key = item.dataset.key.toLowerCase(); - const labelName = item.querySelector('.config-label-name')?.textContent.toLowerCase() || ''; - const description = item.querySelector('.config-description')?.textContent.toLowerCase() || ''; - - if (key.includes(searchTerm) || labelName.includes(searchTerm) || description.includes(searchTerm)) { - item.style.display = 'grid'; - const parentGroup = item.closest('.config-group'); - if (parentGroup) { - parentGroup.style.display = 'block'; - } - hasVisibleItems = true; - } else { - item.style.display = 'none'; - } - }); - - if (!hasVisibleItems) { - updateStatus('No settings found matching: ' + searchTerm, 'warning'); - } -} - -function clearSearch() { - document.getElementById('searchInput').value = ''; - searchConfig(); -} - -function filterConfig() { - searchConfig(); -} - -async function validateInput(key, value, errorElement) { - try { - const metadata = configMetadata[key] || {}; - - if (metadata.type === 'number') { - const num = parseFloat(value); - if (isNaN(num)) { - errorElement.textContent = 'Must be a valid number'; - errorElement.style.display = 'block'; - return false; - } - if (metadata.min !== undefined && num < metadata.min) { - errorElement.textContent = `Minimum value is ${metadata.min}`; - errorElement.style.display = 'block'; - return false; - } - if (metadata.max !== undefined && num > metadata.max) { - errorElement.textContent = `Maximum value is ${metadata.max}`; - errorElement.style.display = 'block'; - return false; - } - } - - if (metadata.type === 'ip_address' && value) { - const ipPortPattern = /^(([0-9]{1,3}\.){3}[0-9]{1,3})(:[0-9]{1,5})?$/; - if (!ipPortPattern.test(value)) { - errorElement.textContent = 'Invalid IP address format. Use format: 192.168.1.100 or 192.168.1.100:921'; - errorElement.style.display = 'block'; - return false; - } - - const parts = value.split(':'); - const ipParts = parts[0].split('.'); - for (const octet of ipParts) { - const num = parseInt(octet, 10); - if (num < 0 || num > 255) { - errorElement.textContent = 'IP address octets must be between 0 and 255'; - errorElement.style.display = 'block'; - return false; - } - } - - if (parts[1]) { - const port = parseInt(parts[1], 10); - if (port < 1 || port > 65535) { - errorElement.textContent = 'Port must be between 1 and 65535'; - errorElement.style.display = 'block'; - return false; - } - } - } - - errorElement.style.display = 'none'; - errorElement.textContent = ''; - - checkDependencies(key, value); - - return true; - } catch (error) { - console.error('Validation error:', error); - return true; // Don't block on validation errors - } -} - -function checkDependencies(key, value) { - const dependencies = { - 'system.mode': { - 'single': ['cameras.slot2 settings will be used for dual camera on single Pi'], - 'dual_primary': ['This Pi will act as primary camera. Ensure secondary Pi is configured.'], - 'dual_secondary': ['This Pi will act as secondary camera. Ensure primary Pi is configured.'] - }, - 'cameras.slot1.type': { - '*': ['Camera type change may require recalibration'] - }, - 'cameras.slot2.type': { - '*': ['Camera type change may require recalibration'] - }, - 'network.broker_address': { - '*': ['Changing broker address will affect camera communication'] - } - }; - - const keyDeps = dependencies[key]; - if (keyDeps) { - const warnings = keyDeps[value] || keyDeps['*'] || []; - if (warnings.length > 0) { - showDependencyWarning(key, warnings); - } - } -} - -function showDependencyWarning(key, warnings) { - const message = `Changing ${key} affects:
` + warnings.join('
'); - - const notification = document.createElement('div'); - notification.className = 'dependency-warning'; - notification.innerHTML = message; - notification.style.cssText = ` - position: fixed; - top: 80px; - right: 20px; - background: var(--warning-bg, #fef3c7); - color: var(--warning-text, #92400e); - padding: 1rem; - border-radius: 8px; - box-shadow: 0 4px 6px rgba(0,0,0,0.1); - max-width: 300px; - z-index: 1000; - animation: slideIn 0.3s ease; - `; - - document.body.appendChild(notification); - - setTimeout(() => { - notification.style.animation = 'slideOut 0.3s ease'; - setTimeout(() => notification.remove(), 300); - }, 5000); -} - -// Utility functions -function getNestedValue(obj, path) { - return path.split('.').reduce((current, key) => current?.[key], obj); -} - -function setNestedValue(obj, path, value) { - const parts = path.split('.'); - let current = obj; - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!(part in current) || typeof current[part] !== 'object') { - current[part] = {}; - } - current = current[part]; - } - current[parts[parts.length - 1]] = value; -} - -function deleteNestedValue(obj, path) { - const parts = path.split('.'); - if (parts.length === 1) { - delete obj[parts[0]]; - return; - } - - let current = obj; - for (let i = 0; i < parts.length - 1; i++) { - if (!(parts[i] in current)) { - return; // Path doesn't exist - } - current = current[parts[i]]; - } - - delete current[parts[parts.length - 1]]; - - let parent = obj; - for (let i = 0; i < parts.length - 1; i++) { - const nextParent = parent[parts[i]]; - if (nextParent && Object.keys(nextParent).length === 0) { - delete parent[parts[i]]; - break; - } - parent = nextParent; - } -} - -function updateStatus(message, type = '') { - const statusEl = document.getElementById('statusMessage'); - statusEl.textContent = message; - statusEl.className = 'status-message ' + type; -} - -function updateModifiedCount() { - const modifiedCount = modifiedSettings.size; - document.getElementById('modifiedCount').textContent = modifiedCount; - - const saveBtn = document.getElementById('saveBtn'); - if (saveBtn) { - saveBtn.disabled = modifiedCount === 0; - } - - let userSetCount = 0; - const countUserSettings = (obj, depth = 0) => { - if (depth > 10) return; - for (const key in obj) { - if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) { - countUserSettings(obj[key], depth + 1); - } else { - userSetCount++; - } - } - }; - countUserSettings(userSettings); - - const totalSettings = document.querySelectorAll('.config-item').length; - const defaultCount = totalSettings - userSetCount; - - let counterEl = document.getElementById('settingsCounter'); - if (!counterEl) { - const statusBar = document.querySelector('.status-bar'); - if (statusBar) { - counterEl = document.createElement('div'); - counterEl.id = 'settingsCounter'; - counterEl.className = 'settings-counter'; - statusBar.insertBefore(counterEl, statusBar.firstChild); - } - } - - if (counterEl) { - counterEl.innerHTML = ` - ${userSetCount} custom - ${defaultCount} defaults - ${totalSettings} total - `; - } -} - -function showModal(title, body) { - document.getElementById('modalTitle').textContent = title; - document.getElementById('modalBody').innerHTML = body; - // Hide the confirm button -- diff view and other info modals have their own action buttons - const confirmBtn = document.getElementById('modalConfirmBtn'); - if (confirmBtn) confirmBtn.style.display = 'none'; - document.getElementById('confirmModal').classList.add('active'); -} - -function showConfirm(title, message, onConfirm) { - document.getElementById('modalTitle').textContent = title; - document.getElementById('modalBody').textContent = message; - - const confirmBtn = document.getElementById('modalConfirmBtn'); - confirmBtn.style.display = ''; - confirmBtn.onclick = () => { - closeModal(); - onConfirm(); - }; - - document.getElementById('confirmModal').classList.add('active'); -} - -function closeModal() { - document.getElementById('confirmModal').classList.remove('active'); -} - -async function detectAndSetCameras(targetKey = null) { - try { - updateStatus('Detecting cameras...', 'info'); - - const response = await fetch('/api/cameras/detect'); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const result = await response.json(); - console.log('Camera detection result:', result); - - if (result.success && result.cameras && result.cameras.length > 0) { - const config = result.configuration; - console.log('Configuration:', config); - - if (targetKey === 'cameras.slot1.type') { - const input = document.querySelector('.config-input[data-key="cameras.slot1.type"]'); - console.log('Found slot1 input:', input); - if (input) { - const typeValue = String(config.slot1.type); - console.log('Setting slot1 to:', typeValue); - input.value = typeValue; - - // Trigger change event for select elements - const event = new Event('change', { bubbles: true }); - input.dispatchEvent(event); - - await handleValueChange('cameras.slot1.type', typeValue, input.dataset.original); - } else { - console.error('Could not find input for cameras.slot1.type'); - } - updateStatus(`Camera 1 detected: Type ${config.slot1.type}`, 'success'); - } else if (targetKey === 'cameras.slot2.type') { - const input = document.querySelector('.config-input[data-key="cameras.slot2.type"]'); - console.log('Found slot2 input:', input); - if (input) { - const typeValue = String(config.slot2.type); - console.log('Setting slot2 to:', typeValue); - input.value = typeValue; - - // Trigger change event for select elements - const event = new Event('change', { bubbles: true }); - input.dispatchEvent(event); - - await handleValueChange('cameras.slot2.type', typeValue, input.dataset.original); - } else { - console.error('Could not find input for cameras.slot2.type'); - } - updateStatus(`Camera 2 detected: Type ${config.slot2.type}`, 'success'); - } else { - const input1 = document.querySelector('.config-input[data-key="cameras.slot1.type"]'); - const input2 = document.querySelector('.config-input[data-key="cameras.slot2.type"]'); - console.log('Found inputs - slot1:', input1, 'slot2:', input2); - - if (input1) { - const typeValue = String(config.slot1.type); - console.log('Setting slot1 to:', typeValue); - input1.value = typeValue; - - // Trigger change event for select elements - const event = new Event('change', { bubbles: true }); - input1.dispatchEvent(event); - - await handleValueChange('cameras.slot1.type', typeValue, input1.dataset.original); - } else { - console.warn('Could not find input for cameras.slot1.type'); - } - - if (input2) { - const typeValue = String(config.slot2.type); - console.log('Setting slot2 to:', typeValue); - input2.value = typeValue; - - // Trigger change event for select elements - const event = new Event('change', { bubbles: true }); - input2.dispatchEvent(event); - - await handleValueChange('cameras.slot2.type', typeValue, input2.dataset.original); - } else { - console.warn('Could not find input for cameras.slot2.type'); - } - - updateStatus(`Detected cameras - Slot 1: Type ${config.slot1.type}, Slot 2: Type ${config.slot2.type}`, 'success'); - } - - } else { - const errorMsg = result.message || 'No cameras detected'; - updateStatus(`Camera detection failed: ${errorMsg}`, 'error'); - console.error('Camera detection failed:', result); - - if (result.warnings && result.warnings.length > 0) { - showModal('Camera Detection Failed', - `

${errorMsg}

` + - '

Warnings:

' + - '
    ' + - result.warnings.map(w => `
  • ${w}
  • `).join('') + - '
' + - '

Troubleshooting:

' + - '
    ' + - '
  • Check ribbon cable connections and orientation
  • ' + - '
  • Verify camera_auto_detect=1 in /boot/firmware/config.txt
  • ' + - '
  • Power cycle the Raspberry Pi
  • ' + - '
  • Ensure cameras are compatible (IMX296 recommended)
  • ' + - '
' - ); - } - } - } catch (error) { - console.error('Camera detection error:', error); - updateStatus('Failed to detect cameras - check connection', 'error'); - showModal('Connection Error', - '

Failed to connect to camera detection service.

' + - `

Error: ${error.message}

` + - '

Please ensure:

' + - '
    ' + - '
  • The PiTrac web service is running
  • ' + - '
  • You have a stable network connection
  • ' + - '
  • Try refreshing the page
  • ' + - '
' - ); - } -} - -function checkVisibilityCondition(condition) { - for (const [condKey, condValue] of Object.entries(condition)) { - let actualValue = getNestedValue(currentConfig, condKey); - // If value is not in currentConfig, use the default value - if (actualValue === undefined) { - actualValue = getNestedValue(defaultConfig, condKey); - } - if (actualValue !== condValue) { - return false; - } - } - return true; -} - -function updateConditionalVisibility() { - document.querySelectorAll('.config-item').forEach(item => { - const key = item.dataset.key; - const metadata = configMetadata[key]; - - if (metadata && metadata.visibleWhen) { - const shouldBeVisible = checkVisibilityCondition(metadata.visibleWhen); - if (shouldBeVisible) { - item.style.display = ''; - delete item.dataset.hiddenByCondition; - } else { - item.style.display = 'none'; - item.dataset.hiddenByCondition = 'true'; - } - } + window.addEventListener('beforeunload', (e) => { + if (pending.size) e.preventDefault(); }); -} - -window.saveChanges = saveChanges; -window.reloadConfig = reloadConfig; -window.showDiff = showDiff; -window.resetAll = resetAll; -window.searchConfig = searchConfig; -window.clearSearch = clearSearch; -window.resetToDefault = resetToDefault; -window.resetValueFromDiff = resetValueFromDiff; -window.resetAllFromDiff = resetAllFromDiff; +})(); diff --git a/Software/web-server/static/js/dashboard.js b/Software/web-server/static/js/dashboard.js index 4352f225..d3ae04d7 100644 --- a/Software/web-server/static/js/dashboard.js +++ b/Software/web-server/static/js/dashboard.js @@ -1,230 +1,202 @@ -// Dashboard-specific functionality (theme and dropdown handled by common.js) -let ws = null; -let piTracRunning = false; - -function connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - ws = new WebSocket(`${protocol}//${window.location.host}/ws`); - - ws.onopen = () => { - document.getElementById('ws-status-dot').classList.remove('disconnected'); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (!piTracRunning) { - piTracRunning = true; - const metricsPanel = document.getElementById('metrics-panel'); - if (metricsPanel) metricsPanel.style.opacity = '1'; - } - if (data.type === 'image_ready') { - handleImageReady(data.filename); - } else { - updateDisplay(data); - } - }; - - ws.onclose = () => { - document.getElementById('ws-status-dot').classList.add('disconnected'); - setTimeout(connectWebSocket, 3000); - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - }; +// Dashboard: status strip, shot metrics, shot image, setup readiness +/* global openSocket, onPiTracStatus, formatNumber, formatSided, api, controlPiTrac, + setupStatusRequest, setupHidden, setSetupHidden, setupChecklist, renderSetupChecklist, setupSummaryHtml, loadCameraLabels */ + +// Keys are the exact result_type strings from parsers.py; anything else leaves the strip as it is +const STRIP_STATES = { + 'Initializing': ['initializing', 'Starting up', 'PiTrac is starting.'], + 'Waiting For Ball': ['waiting', 'Place a ball', 'Put a ball on the tee.'], + 'Waiting For Simulator': ['waiting', 'Waiting for simulator', 'Connect a simulator to continue.'], + 'Waiting For Placement To Stabilize': ['stabilizing', 'Ball detected', 'Let the ball settle.'], + 'Ball Placed': ['ready', 'Ready. Hit it.', ''], + 'Hit': ['hit', 'Shot recorded', ''], + 'Multiple Balls Present': ['error', 'More than one ball', 'Remove the extra balls.'], + 'Error': ['error', 'Error', ''], +}; +const STRIP_CLASSES = ['initializing', 'waiting', 'stabilizing', 'ready', 'hit', 'error']; + +let running = null; +let offline = false; +let shotState = null; +let setup = null; +let cameraLabels = {}; +let freshSocket = true; +let hitTime = ''; +let shownImage = null; + +const byId = (id) => document.getElementById(id); + +function stripState() { + if (running === null) return ['initializing', 'Checking PiTrac', '']; + if (offline) return ['initializing', 'PiTrac is offline', "Can't reach the PiTrac web server."]; + if (!running) { + const message = strobeBlocked() ? setup.strobe.reason || 'Finish setup before starting.' : 'Start it to begin.'; + return ['initializing', 'PiTrac is stopped', message]; + } + return shotState || ['initializing', 'PiTrac is running', '']; } -function updateDisplay(data) { - const updateMetric = (id, value) => { - const element = document.getElementById(id); - if (!element) return; - const unitSpan = element.querySelector('.metric-unit'); - const currentText = element.firstChild && element.firstChild.nodeType === Node.TEXT_NODE - ? element.firstChild.textContent.trim() - : ''; - const newValue = String(value); - - if (currentText !== newValue) { - if (element.firstChild && element.firstChild.nodeType === Node.TEXT_NODE) { - element.firstChild.textContent = newValue; - } else { - element.insertBefore(document.createTextNode(newValue), unitSpan); - } - element.classList.add('updated'); - setTimeout(() => { - element.classList.remove('updated'); - }, 500); - } - }; - - updateMetric('speed', data.speed || '0.0'); - updateMetric('launch_angle', data.launch_angle || '0.0'); - updateMetric('side_angle', data.side_angle || '0.0'); - updateMetric('back_spin', data.back_spin || '0'); - updateMetric('side_spin', data.side_spin || '0'); - - // Update ball status strip - updateBallStatus(data.result_type, data.message, data.pitrac_running); - - if (data.timestamp) { - const date = new Date(data.timestamp); - document.getElementById('timestamp').textContent = date.toLocaleTimeString(); - } +function strobeBlocked() { + return !!setup && !setup.strobe.safe; +} - const resultType = (data.result_type || '').toLowerCase(); - if (resultType.includes('stabilization') || resultType.includes('pausing')) { - const imageInner = document.getElementById('image-panel-inner'); - imageInner.className = 'image-panel-inner'; - imageInner.innerHTML = - '
' + - '
' + - '
Waiting for shot...
' + - '
'; +function setText(el, text) { + if (el.textContent !== text) el.textContent = text; +} + +function renderStrip() { + const [state, title, message] = stripState(); + const stopped = running === false && !offline; + const strip = byId('status-strip'); + if (!strip.classList.contains(state)) { + strip.classList.remove(...STRIP_CLASSES); + strip.classList.add(state); } + setText(byId('status-strip-title'), title); + setText(byId('status-strip-message'), message); + document.querySelector('.status-strip-separator').hidden = !message; + byId('strip-start-btn').hidden = !stopped || strobeBlocked(); + byId('strip-setup-btn').hidden = !stopped || !strobeBlocked(); + byId('btn-reset').hidden = !running || state !== 'hit'; } -function handleImageReady(filename) { - const imageInner = document.getElementById('image-panel-inner'); - const ts = Date.now(); - imageInner.className = 'image-panel-inner'; - imageInner.innerHTML = - `Shot image`; +function shotStateFor(data) { + const def = STRIP_STATES[data.result_type]; + if (!def) return null; + const [state, title, message] = def; + if (state === 'hit') { + hitTime = data.timestamp ? new Date(data.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : ''; + return [state, title, hitTime ? `Shot at ${hitTime}` : '']; + } + // The C++ sends Waiting For Ball right after every Hit; keep the shot up until the next ball shows + if (data.result_type === 'Waiting For Ball' && shotState && shotState[0] === 'hit') { + return ['hit', shotState[1], hitTime ? `Shot at ${hitTime}. Place the next ball.` : 'Place the next ball.']; + } + return [state, title, message || (state === 'error' ? data.message || '' : '')]; } -function updateBallStatus(resultType, message, isPiTracRunning) { - const strip = document.getElementById('status-strip'); - const title = document.getElementById('status-strip-title'); - const msg = document.getElementById('status-strip-message'); - const resetBtn = document.getElementById('btn-reset'); +// -- Metrics -- - strip.classList.remove('initializing', 'waiting', 'stabilizing', 'ready', 'hit', 'error'); +function setMetric(id, text, dir = '') { + const el = byId(id); + const value = el.querySelector('.metric-value'); + el.querySelector('.metric-dir').textContent = dir ? ` ${dir}` : ''; + if (value.textContent === text) return; + value.textContent = text; + el.classList.add('updated'); + setTimeout(() => el.classList.remove('updated'), 500); +} - resetBtn.style.display = 'none'; +function setSidedMetric(id, value, decimals) { + const [text, dir = ''] = formatSided(value, decimals).split(' '); + setMetric(id, text, dir); +} - if (isPiTracRunning === false) { - strip.classList.add('error'); - title.textContent = 'System Stopped'; - msg.textContent = 'PiTrac is not running \u2014 click Start to begin'; - return; - } +function renderMetrics(data) { + setMetric('speed', formatNumber(data.speed, 1)); + setMetric('launch_angle', formatNumber(data.launch_angle, 1)); + setSidedMetric('side_angle', data.side_angle, 1); + setMetric('back_spin', formatNumber(data.back_spin, 0)); + setSidedMetric('side_spin', data.side_spin, 0); +} - if (resultType) { - const normalizedType = resultType.toLowerCase(); - - if (normalizedType.includes('initializing')) { - strip.classList.add('initializing'); - title.textContent = 'System Initializing'; - msg.textContent = message || 'Starting up PiTrac system...'; - } else if (normalizedType.includes('waiting for ball')) { - strip.classList.add('waiting'); - title.textContent = 'Waiting for Ball'; - msg.textContent = message || 'Please place ball on tee'; - } else if (normalizedType.includes('waiting for simulator')) { - strip.classList.add('waiting'); - title.textContent = 'Waiting for Simulator'; - msg.textContent = message || 'Waiting for simulator to be ready'; - } else if (normalizedType.includes('pausing') || normalizedType.includes('stabilization')) { - strip.classList.add('stabilizing'); - title.textContent = 'Ball Detected'; - msg.textContent = message || 'Waiting for ball to stabilize...'; - } else if (normalizedType.includes('ball ready') || normalizedType.includes('ready')) { - strip.classList.add('ready'); - title.textContent = 'Ready to Hit!'; - msg.textContent = message || 'Ball is ready, take your shot'; - } else if (normalizedType.includes('hit')) { - strip.classList.add('hit'); - title.textContent = 'Ball Hit!'; - msg.textContent = message || 'Processing shot data...'; - resetBtn.style.display = ''; - } else if (normalizedType.includes('error')) { - strip.classList.add('error'); - title.textContent = 'Error'; - msg.textContent = message || 'An error occurred'; - } else if (normalizedType.includes('multiple balls')) { - strip.classList.add('error'); - title.textContent = 'Multiple Balls Detected'; - msg.textContent = message || 'Please remove extra balls'; - } else { - title.textContent = 'System Status'; - msg.textContent = message || resultType; - } - } +// -- Image panel -- + +function renderEmptyImage(text) { + const inner = byId('image-panel-inner'); + inner.innerHTML = '
'; + inner.querySelector('.empty-text').textContent = text; } -function openImage(imgPath) { - window.open(`/images/${imgPath}`, '_blank'); +function renderImage(path) { + const img = document.createElement('img'); + img.src = `/images/${encodeURI(path)}?t=${Date.now()}`; + img.alt = 'Shot image'; + img.className = 'shot-image'; + img.addEventListener('click', () => window.open(`/images/${encodeURI(path)}`, '_blank')); + byId('image-panel-inner').replaceChildren(img); + shownImage = path; } -async function resetShot() { - try { - const response = await fetch('/api/reset', { method: 'POST' }); - if (response.ok) { - // Clear the image panel on explicit reset - const imageInner = document.getElementById('image-panel-inner'); - imageInner.className = 'image-panel-inner'; - imageInner.innerHTML = - '
' + - '
' + - '
Waiting for shot...
' + - '
'; - } - } catch (error) { - console.error('Error resetting shot:', error); - } +const shotDir = (path) => path.slice(0, path.lastIndexOf('/')); + +function renderStoredShot(shot) { + renderMetrics(shot); + if (shot.images && shot.images.length) renderImage(shot.images[0]); + else renderEmptyImage('Hit a shot to see the image here'); } -let originalCheckPiTracStatus; -const dashboardCheckPiTracStatus = async function() { - if (!originalCheckPiTracStatus) { - originalCheckPiTracStatus = window.checkPiTracStatus; - } - const isRunning = await originalCheckPiTracStatus(); - piTracRunning = isRunning; +// -- Socket -- - const metricsPanel = document.getElementById('metrics-panel'); - if (metricsPanel) { - metricsPanel.style.opacity = isRunning ? '1' : '0.3'; +function onMessage(data) { + if (data.type === 'image_ready') { + renderImage(data.filename); + return; } + if (!('result_type' in data) || data.type) return; + renderMetrics(data); + if (freshSocket) { + freshSocket = false; + const stored = data.images && data.images[0]; + if (stored && !(shownImage && shotDir(shownImage) === shotDir(stored))) renderImage(stored); + } + const next = shotStateFor(data); + if (next) { + shotState = next; + renderStrip(); + } +} - if (!isRunning) { - updateBallStatus(null, null, false); +async function resetShot() { + try { + await api('/api/reset', { method: 'POST' }); + renderEmptyImage('Waiting for shot...'); + shotState = null; + renderStrip(); + } catch (err) { + console.error('Error resetting shot:', err); } +} - return isRunning; -}; +// -- Setup readiness -- -function showStatusMessage(message, type = 'info') { - const statusMessage = document.getElementById('status-strip-message'); - if (statusMessage) { - const originalMessage = statusMessage.textContent; - statusMessage.textContent = message; - statusMessage.className = `status-strip-message ${type}`; - - setTimeout(() => { - statusMessage.textContent = originalMessage; - statusMessage.className = 'status-strip-message'; - }, 3000); - } +function renderSetup() { + const card = byId('setup-card'); + card.hidden = !setup || setup.complete || setupHidden(); + if (card.hidden) return; + byId('setup-summary').innerHTML = setupSummaryHtml(setup, cameraLabels); + renderSetupChecklist(byId('setup-rows'), setupChecklist(setup), { base: '/calibration' }); } -document.addEventListener('DOMContentLoaded', () => { - connectWebSocket(); +async function loadSetup() { + setup = await setupStatusRequest; + if (!setup) return; + renderStrip(); + if (setup.complete) return; + cameraLabels = await loadCameraLabels(); + renderSetup(); +} - updateBallStatus(null, null, false); +// -- Init -- - // Check if a shot image already exists on disk (e.g. page refresh after a shot) - const img = new Image(); - img.onload = () => handleImageReady('ball_exposure_candidates.png'); - img.src = '/images/ball_exposure_candidates.png?t=' + Date.now(); +onPiTracStatus((s) => { + const now = s.is_running && !s.offline; + if (running && !now) shotState = null; + running = now; + offline = s.offline; + renderStrip(); +}); - if (window.checkPiTracStatus) { - originalCheckPiTracStatus = window.checkPiTracStatus; - window.checkPiTracStatus = dashboardCheckPiTracStatus; - } +renderStoredShot(JSON.parse(byId('initial-shot').textContent)); +renderStrip(); - document.addEventListener('visibilitychange', () => { - if (!document.hidden && (!ws || ws.readyState !== WebSocket.OPEN)) { - connectWebSocket(); - } - }); +byId('strip-start-btn').addEventListener('click', () => controlPiTrac('start')); +byId('btn-reset').addEventListener('click', resetShot); +byId('setup-hide-btn').addEventListener('click', () => { + setSetupHidden(true); + renderSetup(); }); +document.addEventListener('pitrac:setup-shown', renderSetup); + +openSocket('/ws', onMessage, { onOpen: () => { freshSocket = true; } }); +loadSetup(); diff --git a/Software/web-server/static/js/distortion_calibration.js b/Software/web-server/static/js/distortion_calibration.js index e4192e24..042975ce 100644 --- a/Software/web-server/static/js/distortion_calibration.js +++ b/Software/web-server/static/js/distortion_calibration.js @@ -1,135 +1,176 @@ +/* global calibration, escapeHtml, toast, confirmDialog, api, openSocket */ +/* exported distortionCalibration */ + const distortionCalibration = { - currentCamera: null, + camera: null, pollInterval: null, - feedSocket: null, - undistortSocket: null, - undistortMode: 'side_by_side', + feed: null, + feedPending: null, + preview: null, + previewMode: 'side_by_side', + + init() { + this._showOnLoad('distortion-feed', 'distortion-feed-placeholder'); + this._showOnLoad('undistort-feed', 'undistort-placeholder'); + document.querySelectorAll('input[name="lens-camera"]').forEach(input => { + input.addEventListener('change', () => { + this._stopPreview(); + this.showSetup(); + }); + }); + }, - async startCalibration(camera) { - this.currentCamera = camera; + isActive(status) { + return ['distortion_calibrating', 'stopping'].includes(status?.status); + }, - const cameraLabel = camera === 'camera1' ? 'Camera 1' : 'Camera 2'; - document.getElementById('distortion-camera-title').textContent = - `Distortion Calibration - ${cameraLabel}`; + selectedCamera() { + return document.querySelector('input[name="lens-camera"]:checked').value; + }, - document.getElementById('distortion-camera-selection').style.display = 'none'; - document.getElementById('distortion-progress').style.display = 'block'; - document.getElementById('distortion-results').style.display = 'none'; + _setPicker(camera, disabled) { + document.querySelectorAll('input[name="lens-camera"]').forEach(input => { + if (camera) input.checked = input.value === camera; + input.disabled = disabled; + }); + }, - document.getElementById('distortion-progress-bar').style.width = '0%'; - const statusEl = document.getElementById('distortion-status'); - statusEl.textContent = 'Starting calibration...'; - statusEl.classList.remove('text-error', 'text-success'); - document.getElementById('distortion-details').textContent = ''; - document.getElementById('distortion-log-content').innerHTML = ''; + // A run keeps its status poll while the section is closed, so it can finish there; entering only reopens the feed. + async enter() { + if (this.camera) { + if (!this.feed) this._startFeed(this.camera).catch(() => {}); + return; + } + await calibration.refresh(); + const running = ['camera1', 'camera2'].find(c => this.isActive(calibration.status?.distortion?.[c])); + if (!running) { + this.showSetup(); + return; + } + const attached = this.attach(running); + this.startStatusPolling(); + try { + await attached; + } catch (error) { + if (!error.cancelled) document.getElementById('distortion-status').textContent = error.message; + } + }, - this._initCoverageGrid(); - this.log('Starting distortion calibration for ' + cameraLabel); + leave() { + this._stopFeed(); + this._stopPreview(); + }, - try { - // Start the live feed FIRST so it owns the camera device, - // then start calibration which reads from the shared frame buffer. - await this._startFeed(camera); - this.log('Camera feed connected'); - - const response = await fetch(`/api/calibration/distortion/${camera}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ target_images: 40 }) - }); + renderLock() { + const note = calibration.lockNote('lens', ['camera1', 'camera2']); + const lockEl = document.getElementById('lens-locked'); + lockEl.hidden = !note; + lockEl.textContent = note || ''; + document.getElementById('lens-start-btn').hidden = !!note; + this._renderPreviewButton(); + }, - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } + _renderPreviewButton() { + const calibrated = calibration.setup?.cameras?.[this.selectedCamera()]?.lens_calibrated; + document.getElementById('undistort-preview-btn').hidden = !calibrated; + }, - const result = await response.json(); - if (result.status === 'error') { - throw new Error(result.message); - } + showSetup() { + this._setPicker(null, false); + document.getElementById('lens-setup').hidden = false; + document.getElementById('lens-progress').hidden = true; + this.renderLock(); + }, - this.log('Calibration task started'); - this.startStatusPolling(); + // Opens the live feed and progress view for a run. The feed must be open before the run starts: + // the run reads its frames from this feed. + async attach(camera) { + this.camera = camera; + this._setPicker(camera, true); + this._stopPreview(); + document.getElementById('lens-setup').hidden = true; + document.getElementById('lens-progress').hidden = false; + document.getElementById('lens-progress-title').textContent = + `Calibrating the lens on ${camera === 'camera1' ? 'camera 1' : 'camera 2'}`; + document.getElementById('distortion-status').textContent = 'Starting'; + document.getElementById('distortion-hint').textContent = ''; + this._initCoverageGrid(); + this._updateProgress({ progress: 0, images_captured: 0, target_images: 40, + requirements: { coverage: 0, coverage_target: 0.8, tilt: 0, tilt_target: 0.4, + bins: { small: 0, medium: 0, large: 0 }, bin_target: 3 } }); + await this._startFeed(camera); + this.log('Camera feed connected'); + }, + async startCalibration() { + const camera = this.selectedCamera(); + document.getElementById('lens-result').replaceChildren(); + document.getElementById('distortion-log-content').textContent = ''; + this.log(`Starting lens calibration for ${camera === 'camera1' ? 'camera 1' : 'camera 2'}`); + try { + await this.attach(camera); + const result = await api(`/api/calibration/distortion/${camera}`, { method: 'POST', body: { target_images: 40 } }); + if (result.status === 'error') throw new Error(result.message); + this.log('Calibration started'); + this.startStatusPolling(); } catch (error) { - console.error('Error starting distortion calibration:', error); - this._stopFeed(); - statusEl.textContent = 'Could not connect to camera'; - statusEl.classList.add('text-error'); - const safe = this._escapeHtml(error.message || 'Check that the camera is connected and not in use by another program.'); - document.getElementById('distortion-details').innerHTML = ` -
- ${safe} -
- - `; + if (error.cancelled) { + this.camera = null; + this.showSetup(); + return; + } + this.finish('error', error.message || 'Check that the camera is connected and not in use by another program.'); } }, startStatusPolling() { + this._stopPolling(); this.pollInterval = setInterval(async () => { - try { - const response = await fetch('/api/calibration/status'); - const data = await response.json(); - - if (!this.currentCamera) return; - - const status = data[this.currentCamera]; - if (!status) return; - - this._updateProgress(status); - - if (status.status === 'completed') { - this._handleSuccess(status); - } else if (status.status === 'failed' || status.status === 'error') { - this._handleFailure(status.message || 'Calibration failed'); - } - } catch (error) { - console.error('Error polling status:', error); + const data = await api('/api/calibration/status').catch(() => null); + if (!data || !this.camera) return; + calibration.status = data; + calibration.renderChecklist(); + + const status = data.distortion[this.camera]; + if (!status) return; + this._updateProgress(status); + + if (status.status === 'completed') { + this.finish('success', status.message); + } else if (status.status === 'failed' || status.status === 'error') { + this.finish('error', status.message || 'Calibration failed'); + } else if (status.status === 'stopped') { + this.finish('stopped'); } }, 2000); }, _updateProgress(status) { - const progressBar = document.getElementById('distortion-progress-bar'); - const statusText = document.getElementById('distortion-status'); - const detailsText = document.getElementById('distortion-details'); - const pctLabel = document.getElementById('distortion-progress-pct'); - const hintEl = document.getElementById('distortion-hint'); - if (status.progress !== undefined) { - progressBar.style.width = status.progress + '%'; - if (pctLabel) pctLabel.textContent = status.progress + '%'; + document.getElementById('distortion-progress-bar').value = status.progress; + document.getElementById('distortion-progress-pct').textContent = `${status.progress}%`; } - if (status.message) { - statusText.textContent = status.message; + document.getElementById('distortion-status').textContent = status.message; } - - if (status.hint && hintEl) { - hintEl.textContent = status.hint; + if (status.hint) { + document.getElementById('distortion-hint').textContent = status.hint; } - if (status.images_captured !== undefined) { + const r = status.requirements; + if (r) { + const pct = v => `${Math.round((v || 0) * 100)}%`; const target = status.target_images || 40; - detailsText.textContent = - `${status.images_captured} of ${target} good images captured`; - - const imagesOk = status.images_captured >= target; - this._setRequirement('req-images', imagesOk, - `${status.images_captured}/${target} images captured`); - } - if (status.coverage && status.coverage.fraction !== undefined) { - const coverageOk = status.coverage.fraction >= 1.0; - const cellsCovered = Math.round(status.coverage.fraction * 9); - this._setRequirement('req-coverage', coverageOk, - `${cellsCovered}/9 areas covered`); - } - if (status.tilt_fraction !== undefined) { - const tiltOk = status.tilt_fraction >= 0.40; - this._setRequirement('req-tilt', tiltOk, - tiltOk ? 'Tilted angles used' : 'Need more tilted angles'); + const captured = status.images_captured || 0; + const bins = { near: r.bins.large || 0, mid: r.bins.medium || 0, far: r.bins.small || 0 }; + const binText = Object.entries(bins).map(([k, n]) => `${k} ${Math.min(n, r.bin_target)}/${r.bin_target}`).join(', '); + this._setRequirement('req-images', captured >= target, `${captured} of ${target} good images`); + this._setRequirement('req-coverage', r.coverage >= r.coverage_target, + `Frame covered: ${pct(r.coverage)} of ${pct(r.coverage_target)}`); + this._setRequirement('req-tilt', r.tilt >= r.tilt_target, + `Tilted shots: ${pct(r.tilt)} of ${pct(r.tilt_target)}`); + this._setRequirement('req-distance', Object.values(bins).every(n => n >= r.bin_target), + `Distances: ${binText}`); } if (status.coverage && status.coverage.grid) { @@ -139,11 +180,8 @@ const distortionCalibration = { _setRequirement(id, satisfied, label) { const el = document.getElementById(id); - if (!el) return; - el.classList.toggle('text-success', satisfied); - el.classList.toggle('opacity-60', !satisfied); - const icon = satisfied ? 'check-square' : 'square'; - el.innerHTML = `${this._escapeHtml(label)}`; + el.className = `flex items-center gap-1.5 ${satisfied ? 'text-success' : 'opacity-70'}`; + el.innerHTML = `${escapeHtml(label)}`; if (window.lucide && window.lucide.createIcons) { window.lucide.createIcons({ nodes: [el] }); } @@ -151,242 +189,145 @@ const distortionCalibration = { _initCoverageGrid() { const grid = document.getElementById('distortion-coverage-grid'); - grid.innerHTML = ''; + grid.replaceChildren(); for (let i = 0; i < 9; i++) { const cell = document.createElement('div'); cell.className = 'coverage-cell'; cell.dataset.count = '0'; - cell.dataset.index = i; grid.appendChild(cell); } - document.getElementById('distortion-coverage-text').textContent = 'Coverage: 0%'; }, _updateCoverageGrid(coverage) { - const grid = document.getElementById('distortion-coverage-grid'); - const cells = grid.children; - - for (let r = 0; r < 3; r++) { - for (let c = 0; c < 3; c++) { - const idx = r * 3 + c; - // Mirror columns so grid matches the camera's perspective - const mirroredCol = 2 - c; - const count = coverage.grid[r][mirroredCol]; - if (cells[idx]) { - // Clamp to 0..3 for styling bucket - cells[idx].dataset.count = String(Math.min(count, 3)); - } - } - } - - const fraction = coverage.fraction || 0; - const suggested = coverage.suggested_region || ''; - const cellsCovered = Math.round(fraction * 9); - const coverageText = document.getElementById('distortion-coverage-text'); - coverageText.textContent = `${cellsCovered} of 9 areas covered`; - if (fraction < 1.0 && suggested) { - coverageText.textContent += ` \u2014 Try the ${suggested} area next`; - } + const cells = document.getElementById('distortion-coverage-grid').children; + coverage.grid.flat().forEach((count, idx) => { + if (cells[idx]) cells[idx].dataset.count = String(Math.min(count, 3)); + }); }, - async _handleSuccess(status) { + async finish(outcome, message = '') { this._stopPolling(); this._stopFeed(); - this.log('Calibration completed successfully!'); - - try { - document.getElementById('distortion-progress').style.display = 'none'; - document.getElementById('distortion-results').style.display = 'block'; - - const cameraLabel = this.currentCamera === 'camera1' ? 'Camera 1' : 'Camera 2'; - const rmsText = this._escapeHtml(status.message || ''); + const camera = this.camera; + calibration.runEnded('lens', camera, outcome === 'error' ? message || 'Calibration failed' : null); + this.camera = null; + if (calibration.section !== 'lens' && outcome !== 'stopped') { + const label = camera === 'camera1' ? 'camera 1' : 'camera 2'; + toast(outcome === 'success' ? `Lens calibration for ${label} finished` : `Lens calibration for ${label} did not finish`, + outcome === 'success' ? 'success' : 'error', { actionLabel: 'View', onAction: () => calibration.open('lens', camera) }); + } + this.log(outcome === 'success' ? 'Calibration complete' : `Calibration ended: ${message || outcome}`); - const resultsDiv = document.getElementById('distortion-results-data'); - resultsDiv.innerHTML = ` -
- + const result = document.getElementById('lens-result'); + if (outcome === 'success') { + result.innerHTML = ` +
-
${cameraLabel} — Calibration Complete
-
${rmsText}
-
Calibration data has been saved automatically.
+
Lens calibrated and saved
+
${escapeHtml(message)}
+
Use Preview to check that straight lines look straight.
-
-
-
What's next?
-
    -
  • Use "Show Undistort Preview" below to visually verify straight lines look straight
  • -
  • This calibration is saved — you only need to redo it if you change the lens
  • -
  • Switch to the Ball Calibration tab when ready
  • -
-
- `; - if (window.lucide && window.lucide.createIcons) { - window.lucide.createIcons({ nodes: [resultsDiv] }); - } - - } catch (error) { - console.error('Error rendering calibration results:', error); - this.log('Calibration completed but could not render results: ' + error.message); +
`; + } else if (outcome === 'error') { + result.innerHTML = ` +
+
+
Lens calibration did not finish
+
${escapeHtml(message)}
+
    +
  • Check the pattern was printed at 100% scale
  • +
  • Check the camera is in focus and the light is even
  • +
  • Keep the whole board in the frame and hold it still
  • +
+
+
`; + } else { + result.replaceChildren(); + toast('Lens calibration stopped'); } - }, - - _escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - }, - - _handleFailure(message) { - this._stopPolling(); - this._stopFeed(); - this.log('Calibration failed: ' + message); - - const safeMessage = this._escapeHtml(message); - const statusEl = document.getElementById('distortion-status'); - statusEl.textContent = 'Calibration Failed'; - statusEl.classList.remove('text-success'); - statusEl.classList.add('text-error'); - const details = document.getElementById('distortion-details'); - details.innerHTML = ` -
-
-
Error
-
${safeMessage}
-
-
-
-
Troubleshooting:
-
    -
  • Ensure the ChArUco board is printed at 100% scale
  • -
  • Check camera is focused properly
  • -
  • Ensure good, even lighting
  • -
  • Keep the entire board visible in the frame
  • -
  • Hold the board steady during capture
  • -
-
- - `; + await calibration.refresh(); + this.showSetup(); }, async stopCalibration() { - if (!confirm('Stop calibration? All progress for this session will be lost.')) { - return; - } + const camera = this.camera; + const ok = await confirmDialog({ + title: 'Stop lens calibration?', + body: 'The images captured so far are discarded.', + confirmLabel: 'Stop', + danger: true, + }); + if (!ok || !camera) return; try { - await fetch('/api/calibration/stop', { method: 'POST' }); - this.log('Calibration stopped by user'); + await api('/api/calibration/stop', { method: 'POST', body: { camera, kind: 'distortion' } }); + document.getElementById('distortion-status').textContent = 'Stopping'; } catch (error) { - console.error('Error stopping calibration:', error); + toast(error.message, 'error'); } - this._stopPolling(); - this._stopFeed(); - this.reset(); }, - reset() { - this._stopPolling(); - this._stopFeed(); - this._stopUndistortPreview(); - this.currentCamera = null; - - document.getElementById('distortion-camera-selection').style.display = 'block'; - document.getElementById('distortion-progress').style.display = 'none'; - document.getElementById('distortion-results').style.display = 'none'; + _stopPolling() { + clearInterval(this.pollInterval); + this.pollInterval = null; + }, - const statusEl = document.getElementById('distortion-status'); - if (statusEl) statusEl.classList.remove('text-error', 'text-success'); + _showOnLoad(imgId, placeholderId) { + const img = document.getElementById(imgId); + img.addEventListener('load', () => { + img.hidden = false; + document.getElementById(placeholderId).hidden = true; + }); + }, - document.getElementById('distortion-log-content').innerHTML = ''; - this.checkExistingCalibrations(); + _resetView(imgId, placeholderId, text) { + const img = document.getElementById(imgId); + if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); + img.removeAttribute('src'); + img.hidden = true; + const placeholder = document.getElementById(placeholderId); + placeholder.textContent = text; + placeholder.hidden = false; }, - _stopPolling() { - if (this.pollInterval) { - clearInterval(this.pollInterval); - this.pollInterval = null; - } + _showFrame(imgId, data) { + const img = document.getElementById(imgId); + const oldSrc = img.src; + img.src = URL.createObjectURL(new Blob([data], { type: 'image/jpeg' })); + if (oldSrc.startsWith('blob:')) URL.revokeObjectURL(oldSrc); }, - // Resolves on first frame, guaranteeing camera is open before calibration starts. + // Resolves on the first frame, so the camera is open before the run starts. Rejects if the feed is closed first. _startFeed(camera) { this._stopFeed(); + this._resetView('distortion-feed', 'distortion-feed-placeholder', 'Connecting to camera'); return new Promise((resolve, reject) => { - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${proto}//${location.host}/ws/distortion-feed`); - ws.binaryType = 'arraybuffer'; - let firstFrame = true; - const timeout = setTimeout(() => { - reject(new Error('Camera feed timed out -- check that the camera is connected')); - }, 10000); - - ws.onopen = () => { - ws.send(JSON.stringify({ camera })); - }; - - ws.onmessage = (event) => { - if (typeof event.data === 'string') { - let msg; - try { msg = JSON.parse(event.data); } catch (e) { console.warn('Bad JSON from feed:', e); return; } - - if (msg.error) { + const timeout = setTimeout(() => this._stopFeed('The camera feed did not start. Check that the camera is connected.'), 10000); + this.feedPending = { timeout, reject }; + const feed = openSocket('/ws/distortion-feed', (msg) => { + if (this.feed !== feed) return; + if (msg instanceof ArrayBuffer) { + if (this.feedPending) { clearTimeout(timeout); - const ph = document.getElementById('distortion-feed-placeholder'); - if (ph) { - ph.textContent = msg.error; - ph.style.display = 'block'; - } - reject(new Error(msg.error)); - return; + this.feedPending = null; + resolve(); } - if (msg.type === 'metrics') { - this._updateFeedOverlay(msg); - } - return; - } - if (firstFrame) { - firstFrame = false; - clearTimeout(timeout); - document.getElementById('distortion-feed-placeholder').style.display = 'none'; - resolve(); + this._showFrame('distortion-feed', msg); + } else if (msg.error) { + this._stopFeed(msg.error); + } else if (msg.type === 'metrics') { + this._updateFeedOverlay(msg); } - this._updateFeedImage('distortion-feed', event.data); - }; - - ws.onerror = () => { - clearTimeout(timeout); - const ph = document.getElementById('distortion-feed-placeholder'); - ph.textContent = 'Camera feed unavailable'; - ph.style.display = 'block'; - reject(new Error('Camera feed connection failed')); - }; - - ws.onclose = () => { - this.feedSocket = null; - const overlay = document.getElementById('distortion-feed-overlay'); - if (overlay) overlay.style.display = 'none'; - }; - - this.feedSocket = ws; + }, { + onOpen: () => feed.send({ camera }), + onClose: () => { document.getElementById('distortion-feed-overlay').hidden = true; }, + }); + this.feed = feed; }); }, - _updateFeedImage(elementId, data) { - const blob = new Blob([data], { type: 'image/jpeg' }); - const url = URL.createObjectURL(blob); - const img = document.getElementById(elementId); - const oldSrc = img.src; - img.src = url; - if (oldSrc && oldSrc.startsWith('blob:')) URL.revokeObjectURL(oldSrc); - }, - _updateFeedOverlay(metrics) { const el = document.getElementById('distortion-feed-overlay'); - if (!el) return; - el.style.display = 'block'; + el.hidden = false; el.classList.remove('text-success', 'text-warning', 'text-error'); if (metrics.corners > 0) { el.classList.add(metrics.is_good ? 'text-success' : 'text-warning'); @@ -397,30 +338,35 @@ const distortionCalibration = { } }, - _stopFeed() { - if (this.feedSocket) { - this.feedSocket.close(); - this.feedSocket = null; + _stopFeed(error) { + if (this.feedPending) { + clearTimeout(this.feedPending.timeout); + this.feedPending.reject(error ? new Error(error) : Object.assign(new Error('The camera feed was closed'), { cancelled: true })); + this.feedPending = null; } - const img = document.getElementById('distortion-feed'); - if (img && img.src && img.src.startsWith('blob:')) { - URL.revokeObjectURL(img.src); - img.src = ''; + if (this.feed) { + this.feed.close(); + this.feed = null; } - const overlay = document.getElementById('distortion-feed-overlay'); - if (overlay) overlay.style.display = 'none'; + const img = document.getElementById('distortion-feed'); + if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); + img.removeAttribute('src'); + img.hidden = true; + const placeholder = document.getElementById('distortion-feed-placeholder'); + placeholder.hidden = false; + if (error) placeholder.textContent = error; + document.getElementById('distortion-feed-overlay').hidden = true; }, async printBoard() { try { const response = await fetch('/api/calibration/charuco-board'); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); + const url = URL.createObjectURL(await response.blob()); const printWindow = window.open('', '_blank'); printWindow.document.write(` - ChArUco Board - Print at 100% + ChArUco board, print at 100%