diff --git a/CMakeLists.txt b/CMakeLists.txt index cb71e8c..0c81c34 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -189,7 +189,9 @@ endif() # Add third-party dependencies if(ENABLE_TESTS) - add_subdirectory(third_party) + add_subdirectory(third_party) + set(LIBSPINSCALE_BUILD_TESTS ON CACHE BOOL + "Build libspinscale unit tests" FORCE) endif() add_subdirectory(buildmach) add_subdirectory(libspinscale) diff --git a/commonLibs/lcameraDev/CMakeLists.txt b/commonLibs/lcameraDev/CMakeLists.txt index ec92f0b..1e1c162 100644 --- a/commonLibs/lcameraDev/CMakeLists.txt +++ b/commonLibs/lcameraDev/CMakeLists.txt @@ -13,6 +13,9 @@ if(ENABLE_LIB_lcameraDev) add_library(lcameraDev SHARED lcameraDev.cpp cameraIdentity.cpp + cameraModeRequest.cpp + planarYuvFormatPolicy.cpp + sessionModeConfigure.cpp selectorParse.cpp selectorResolve.cpp cameraManagerState.cpp @@ -59,7 +62,8 @@ if(ENABLE_LIB_lcameraDev) if(NOT TARGET spinscale_test_support) message(FATAL_ERROR "lcameraDev probe tools require spinscale_test_support. " - "Configure with -DENABLE_TESTS=ON.") + "Configure with -DLIBSPINSCALE_BUILD_TESTS=ON " + "(salmanoff sets this automatically when -DENABLE_TESTS=ON).") endif() add_executable(lcameraDev_list_cameras @@ -99,6 +103,25 @@ if(ENABLE_LIB_lcameraDev) Boost::system Boost::log ) + + add_executable(lcameraDev_configure_probe + tools/lcameraDevConfigureProbe.cpp + tools/probeRunner.cpp + ) + target_include_directories(lcameraDev_configure_probe PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/tools + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_BINARY_DIR}/include + ${CMAKE_SOURCE_DIR}/libspinscale/tests + ) + target_link_libraries(lcameraDev_configure_probe PRIVATE + lcameraDev + spinscale + spinscale_test_support + Boost::system + Boost::log + ) endif() if(ENABLE_TESTS) diff --git a/commonLibs/lcameraDev/cameraModeRequest.cpp b/commonLibs/lcameraDev/cameraModeRequest.cpp new file mode 100644 index 0000000..d6da1c4 --- /dev/null +++ b/commonLibs/lcameraDev/cameraModeRequest.cpp @@ -0,0 +1,50 @@ +#include +#include +#include + +namespace lcamera_dev { + +namespace { + +constexpr const char *incompleteOptionalPlanarMessage = + "lcameraDev: fullPlanarIsOptional/opt-planar is not honored yet " + "(non-planar producer deinterleaving is not implemented)"; + +} // namespace + +void validateCameraModeRequest(const LcameraDevCameraModeRequest& request) +{ + if (request.width == 0 || request.height == 0) + { + throw std::runtime_error( + "lcameraDev: camera mode request width and height must be " + "non-zero"); + } + + if (request.colourSpace != LcameraDevColourSpace::Yuv) + { + throw std::runtime_error( + "lcameraDev: unsupported colour-space for camera mode request"); + } +} + +void rejectFullPlanarOptionalAtConfigureApi( + const LcameraDevCameraModeRequest& request) +{ + if (request.fullPlanarIsOptional) + { + throw std::runtime_error(incompleteOptionalPlanarMessage); + } +} + +bool cameraModeRequestsEqual( + const LcameraDevCameraModeRequest& left, + const LcameraDevCameraModeRequest& right) +{ + return left.width == right.width + && left.height == right.height + && left.colourSpace == right.colourSpace + && left.fullPlanarIsOptional == right.fullPlanarIsOptional; +} + +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/cameraModeRequest.h b/commonLibs/lcameraDev/cameraModeRequest.h new file mode 100644 index 0000000..2a9a143 --- /dev/null +++ b/commonLibs/lcameraDev/cameraModeRequest.h @@ -0,0 +1,48 @@ +#ifndef LCAMERA_DEV_CAMERA_MODE_REQUEST_H +#define LCAMERA_DEV_CAMERA_MODE_REQUEST_H + +#include + +namespace lcamera_dev { + +enum class LcameraDevColourSpace +{ + Yuv, +}; + +struct LcameraDevCameraModeRequest +{ + unsigned width = 0; + unsigned height = 0; + LcameraDevColourSpace colourSpace = LcameraDevColourSpace::Yuv; + /** EXPLANATION: + * When false, configure must select a fully planar YUV pixel format. + * When true, relaxed non-planar formats are not honored at the configure + * API yet — producer-side deinterleaving is not implemented (Stage 2). + */ + bool fullPlanarIsOptional = false; +}; + +struct LcameraDevConfiguredCameraMode +{ + unsigned width = 0, height = 0; + LcameraDevColourSpace colourSpace = LcameraDevColourSpace::Yuv; + std::string pixelFormatName; + bool isFullyPlanar = false; + unsigned planeCount = 0; +}; + +void validateCameraModeRequest(const LcameraDevCameraModeRequest& request); + +/** Rejects fullPlanarIsOptional at the configure API until non-planar fallback + * paths exist in lcameraBuff. */ +void rejectFullPlanarOptionalAtConfigureApi( + const LcameraDevCameraModeRequest& request); + +bool cameraModeRequestsEqual( + const LcameraDevCameraModeRequest& left, + const LcameraDevCameraModeRequest& right); + +} // namespace lcamera_dev + +#endif // LCAMERA_DEV_CAMERA_MODE_REQUEST_H diff --git a/commonLibs/lcameraDev/cameraSession.cpp b/commonLibs/lcameraDev/cameraSession.cpp index c10aba7..e535bdd 100644 --- a/commonLibs/lcameraDev/cameraSession.cpp +++ b/commonLibs/lcameraDev/cameraSession.cpp @@ -1,4 +1,7 @@ +#include + #include +#include #include namespace lcamera_dev { @@ -26,4 +29,56 @@ bool CameraSession::decrementRefcount() return s.rsrc.refcount == 0; } +sscl::co::ViralNonPostingInvoker +CameraSession::configureSessionModeCReq( + const LcameraDevCameraModeRequest& request) +{ + sscl::co::CoQutex::ReleaseHandle sessionGuard = + co_await s.lock.getAcquireInvocationAndSuspensionPolicy(); + + if (s.rsrc.isStreaming) + { + throw std::runtime_error( + "lcameraDev: cannot configure session mode while streaming"); + } + + validateCameraModeRequest(request); + rejectFullPlanarOptionalAtConfigureApi(request); + + if (s.rsrc.configuredMode.has_value() + && cameraModeRequestsEqual(s.rsrc.configuredRequest, request)) + { + co_return *s.rsrc.configuredMode; + } + + if (s.rsrc.configuredMode.has_value()) + { + throw std::runtime_error( + "lcameraDev: conflicting camera mode request on configured " + "session"); + } + + std::shared_ptr heldConfiguration; + const LcameraDevConfiguredCameraMode resolvedMode = + configureLibcameraSessionMode( + s.rsrc.camera, + request, + heldConfiguration); + + const ConfigureSessionModeStatus status = + applyModeRequestToSessionState( + s.rsrc, + request, + resolvedMode, + heldConfiguration); + + if (status != ConfigureSessionModeStatus::Configured) + { + throw std::logic_error( + "lcameraDev: unexpected configure session mode status"); + } + + co_return *s.rsrc.configuredMode; +} + } // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/cameraSession.h b/commonLibs/lcameraDev/cameraSession.h index f4ea14f..490e5bf 100644 --- a/commonLibs/lcameraDev/cameraSession.h +++ b/commonLibs/lcameraDev/cameraSession.h @@ -2,9 +2,13 @@ #define LCAMERA_DEV_CAMERA_SESSION_H #include +#include #include #include +#include +#include #include +#include #include namespace lcamera_dev { @@ -20,6 +24,12 @@ struct CameraSessionResources int refcount = 0; CameraIdentityRecord identity; std::shared_ptr camera; + + bool isStreaming = false; + LcameraDevCameraModeRequest configuredRequest; + std::optional configuredMode; + std::shared_ptr heldConfiguration; + int libcameraConfigureCallCount = 0; }; class CameraSession @@ -35,9 +45,29 @@ public: const std::shared_ptr& getCamera() const { return s.rsrc.camera; } + bool isModeConfigured() const + { return s.rsrc.configuredMode.has_value(); } + + const LcameraDevConfiguredCameraMode& getConfiguredMode() const + { + if (!s.rsrc.configuredMode.has_value()) + { + throw std::logic_error( + "lcameraDev: session mode is not configured"); + } + + return *s.rsrc.configuredMode; + } + + int getLibcameraConfigureCallCount() const + { return s.rsrc.libcameraConfigureCallCount; } + void incrementRefcount(); bool decrementRefcount(); + sscl::co::ViralNonPostingInvoker + configureSessionModeCReq(const LcameraDevCameraModeRequest& request); + sscl::SharedResourceGroup s; }; diff --git a/commonLibs/lcameraDev/lcameraDev.cpp b/commonLibs/lcameraDev/lcameraDev.cpp index 65ce030..d061b83 100644 --- a/commonLibs/lcameraDev/lcameraDev.cpp +++ b/commonLibs/lcameraDev/lcameraDev.cpp @@ -58,4 +58,26 @@ lcameraDev_enumerateCamerasCReq(void) co_return co_await lcamera_dev::enumerateCamerasCReq(); } +sscl::co::ViralNonPostingInvoker +lcameraDev_configureSessionModeCReq( + const std::shared_ptr& deviceSession, + const lcamera_dev::LcameraDevCameraModeRequest& request) +{ + lcamera_dev::LcameraDevState& state = lcamera_dev::getLcameraDevState(); + if (!state.isInitialized) + { + throw std::runtime_error( + "lcameraDev_configureSessionModeCReq: call lcameraDev_main " + "first"); + } + + if (!deviceSession) + { + throw std::runtime_error( + "lcameraDev_configureSessionModeCReq: deviceSession is null"); + } + + co_return co_await deviceSession->configureSessionModeCReq(request); +} + } // extern "C" diff --git a/commonLibs/lcameraDev/lcameraDev.h b/commonLibs/lcameraDev/lcameraDev.h index de2939c..b606a07 100644 --- a/commonLibs/lcameraDev/lcameraDev.h +++ b/commonLibs/lcameraDev/lcameraDev.h @@ -2,6 +2,7 @@ #define LCAMERA_DEV_H #include +#include #include #include #include @@ -46,11 +47,17 @@ typedef sscl::co::ViralNonPostingInvoker typedef sscl::co::ViralNonPostingInvoker> lcameraDev_enumerateCamerasCReqFn(void); +typedef sscl::co::ViralNonPostingInvoker + lcameraDev_configureSessionModeCReqFn( + const std::shared_ptr& deviceSession, + const lcamera_dev::LcameraDevCameraModeRequest& request); + lcameraDev_mainFn lcameraDev_main; lcameraDev_exitFn lcameraDev_exit; lcameraDev_getOrCreateDeviceCReqFn lcameraDev_getOrCreateDeviceCReq; lcameraDev_releaseDeviceCReqFn lcameraDev_releaseDeviceCReq; lcameraDev_enumerateCamerasCReqFn lcameraDev_enumerateCamerasCReq; +lcameraDev_configureSessionModeCReqFn lcameraDev_configureSessionModeCReq; #ifdef __cplusplus } diff --git a/commonLibs/lcameraDev/planarYuvFormatPolicy.cpp b/commonLibs/lcameraDev/planarYuvFormatPolicy.cpp new file mode 100644 index 0000000..4352510 --- /dev/null +++ b/commonLibs/lcameraDev/planarYuvFormatPolicy.cpp @@ -0,0 +1,146 @@ +#include +#include +#include +#include + +namespace lcamera_dev { + +namespace { + +using libcamera::formats::NV12; +using libcamera::formats::NV16; +using libcamera::formats::NV21; +using libcamera::formats::NV24; +using libcamera::formats::NV42; +using libcamera::formats::NV61; +using libcamera::formats::UYVY; +using libcamera::formats::VYUY; +using libcamera::formats::YUYV; +using libcamera::formats::YUV420; +using libcamera::formats::YUV422; +using libcamera::formats::YUV444; +using libcamera::formats::YVU420; +using libcamera::formats::YVU422; +using libcamera::formats::YVU444; +using libcamera::formats::YVYU; + +bool pixelFormatMatches( + const libcamera::PixelFormat& pixelFormat, + const libcamera::PixelFormat& expectedFormat) +{ + return pixelFormat == expectedFormat; +} + +bool isFullyPlanarYuvFourcc(const libcamera::PixelFormat& pixelFormat) +{ + return pixelFormatMatches(pixelFormat, YUV420) + || pixelFormatMatches(pixelFormat, YVU420) + || pixelFormatMatches(pixelFormat, YUV422) + || pixelFormatMatches(pixelFormat, YVU422) + || pixelFormatMatches(pixelFormat, YUV444) + || pixelFormatMatches(pixelFormat, YVU444); +} + +bool isSemiPlanarYuvFourcc(const libcamera::PixelFormat& pixelFormat) +{ + return pixelFormatMatches(pixelFormat, NV12) + || pixelFormatMatches(pixelFormat, NV21) + || pixelFormatMatches(pixelFormat, NV16) + || pixelFormatMatches(pixelFormat, NV61) + || pixelFormatMatches(pixelFormat, NV24) + || pixelFormatMatches(pixelFormat, NV42); +} + +bool isPackedYuvFourcc(const libcamera::PixelFormat& pixelFormat) +{ + return pixelFormatMatches(pixelFormat, YUYV) + || pixelFormatMatches(pixelFormat, YVYU) + || pixelFormatMatches(pixelFormat, UYVY) + || pixelFormatMatches(pixelFormat, VYUY); +} + +} // namespace + +bool isFullyPlanarYuv(const libcamera::PixelFormat& pixelFormat) +{ + return isFullyPlanarYuvFourcc(pixelFormat); +} + +bool isKnownYuvCaptureFormat(const libcamera::PixelFormat& pixelFormat) +{ + return isFullyPlanarYuvFourcc(pixelFormat) + || isSemiPlanarYuvFourcc(pixelFormat) + || isPackedYuvFourcc(pixelFormat); +} + +unsigned yuvCapturePlaneCount(const libcamera::PixelFormat& pixelFormat) +{ + if (isFullyPlanarYuvFourcc(pixelFormat)) { + return 3; + } + + if (isSemiPlanarYuvFourcc(pixelFormat)) { + return 2; + } + + if (isPackedYuvFourcc(pixelFormat)) { + return 1; + } + + return 0; +} + +std::string formatCandidateListForDiagnostics( + const std::vector& candidates) +{ + std::ostringstream stream; + for (std::size_t i = 0; i < candidates.size(); ++i) + { + if (i > 0) { + stream << ", "; + } + + stream << candidates[i].toString(); + } + + return stream.str(); +} + +std::optional +selectYuvCaptureFormat( + const std::vector& candidates, + bool fullPlanarIsOptional) +{ + if (candidates.empty()) + { + throw std::runtime_error( + "lcameraDev: no YUV pixel-format candidates available"); + } + + if (!fullPlanarIsOptional) + { + for (const libcamera::PixelFormat& candidate : candidates) + { + if (isFullyPlanarYuv(candidate)) { + return candidate; + } + } + + throw std::runtime_error( + "lcameraDev: no fully planar YUV format among candidates: " + + formatCandidateListForDiagnostics(candidates)); + } + + for (const libcamera::PixelFormat& candidate : candidates) + { + if (isKnownYuvCaptureFormat(candidate)) { + return candidate; + } + } + + throw std::runtime_error( + "lcameraDev: no known YUV capture format among candidates: " + + formatCandidateListForDiagnostics(candidates)); +} + +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/planarYuvFormatPolicy.h b/commonLibs/lcameraDev/planarYuvFormatPolicy.h new file mode 100644 index 0000000..51f729d --- /dev/null +++ b/commonLibs/lcameraDev/planarYuvFormatPolicy.h @@ -0,0 +1,26 @@ +#ifndef LCAMERA_DEV_PLANAR_YUV_FORMAT_POLICY_H +#define LCAMERA_DEV_PLANAR_YUV_FORMAT_POLICY_H + +#include +#include +#include + +namespace lcamera_dev { + +bool isFullyPlanarYuv(const libcamera::PixelFormat& pixelFormat); + +bool isKnownYuvCaptureFormat(const libcamera::PixelFormat& pixelFormat); + +unsigned yuvCapturePlaneCount(const libcamera::PixelFormat& pixelFormat); + +std::optional +selectYuvCaptureFormat( + const std::vector& candidates, + bool fullPlanarIsOptional); + +std::string formatCandidateListForDiagnostics( + const std::vector& candidates); + +} // namespace lcamera_dev + +#endif // LCAMERA_DEV_PLANAR_YUV_FORMAT_POLICY_H diff --git a/commonLibs/lcameraDev/sessionModeConfigure.cpp b/commonLibs/lcameraDev/sessionModeConfigure.cpp new file mode 100644 index 0000000..0f71796 --- /dev/null +++ b/commonLibs/lcameraDev/sessionModeConfigure.cpp @@ -0,0 +1,143 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace lcamera_dev { + +namespace { + +LcameraDevConfiguredCameraMode buildConfiguredModeFromStreamConfig( + const LcameraDevCameraModeRequest& request, + const libcamera::StreamConfiguration& streamConfig) +{ + LcameraDevConfiguredCameraMode configuredMode; + configuredMode.width = streamConfig.size.width; + configuredMode.height = streamConfig.size.height; + configuredMode.colourSpace = request.colourSpace; + configuredMode.pixelFormatName = streamConfig.pixelFormat.toString(); + configuredMode.isFullyPlanar = + isFullyPlanarYuv(streamConfig.pixelFormat); + configuredMode.planeCount = + yuvCapturePlaneCount(streamConfig.pixelFormat); + return configuredMode; +} + +std::unique_ptr generateCaptureConfiguration( + const std::shared_ptr& camera) +{ + std::unique_ptr config = + camera->generateConfiguration( + {libcamera::StreamRole::VideoRecording}); + + if (!config || config->empty()) + { + config = camera->generateConfiguration( + {libcamera::StreamRole::Viewfinder}); + } + + if (!config || config->empty()) + { + throw std::runtime_error( + "lcameraDev: camera does not support VideoRecording or " + "Viewfinder stream roles"); + } + + return config; +} + +void validateConfigurationStatus( + libcamera::CameraConfiguration::Status status, + const std::string& cameraId) +{ + if (status == libcamera::CameraConfiguration::Valid + || status == libcamera::CameraConfiguration::Adjusted) + { + return; + } + + throw std::runtime_error( + "lcameraDev: libcamera configuration invalid for camera " + + cameraId); +} + +} // namespace + +ConfigureSessionModeStatus applyModeRequestToSessionState( + CameraSessionResources& resources, + const LcameraDevCameraModeRequest& request, + const LcameraDevConfiguredCameraMode& resolvedMode, + std::shared_ptr heldConfiguration) +{ + if (resources.configuredMode.has_value()) + { + if (cameraModeRequestsEqual(resources.configuredRequest, request)) { + return ConfigureSessionModeStatus::NoOpAlreadyConfigured; + } + + throw std::runtime_error( + "lcameraDev: conflicting camera mode request on configured " + "session"); + } + + resources.configuredRequest = request; + resources.configuredMode = resolvedMode; + resources.heldConfiguration = heldConfiguration; + ++resources.libcameraConfigureCallCount; + return ConfigureSessionModeStatus::Configured; +} + +LcameraDevConfiguredCameraMode configureLibcameraSessionMode( + const std::shared_ptr& camera, + const LcameraDevCameraModeRequest& request, + std::shared_ptr& heldConfiguration) +{ + if (!camera) + { + throw std::runtime_error( + "lcameraDev: configureSessionModeCReq camera is null"); + } + + std::unique_ptr config = + generateCaptureConfiguration(camera); + + libcamera::StreamConfiguration& streamConfig = config->at(0); + streamConfig.size = libcamera::Size(request.width, request.height); + + const std::vector pixelFormatCandidates = + streamConfig.formats().pixelformats(); + const std::optional selectedPixelFormat = + selectYuvCaptureFormat(pixelFormatCandidates, false); + + if (!selectedPixelFormat.has_value()) + { + throw std::runtime_error( + "lcameraDev: failed to select YUV capture format"); + } + + streamConfig.pixelFormat = *selectedPixelFormat; + + const libcamera::CameraConfiguration::Status validateStatus = + config->validate(); + validateConfigurationStatus(validateStatus, camera->id()); + + const int configureRc = camera->configure(config.get()); + if (configureRc != 0) + { + throw std::runtime_error( + "lcameraDev: libcamera configure failed for camera " + + camera->id()); + } + + const LcameraDevConfiguredCameraMode configuredMode = + buildConfiguredModeFromStreamConfig(request, streamConfig); + heldConfiguration = std::move(config); + return configuredMode; +} + +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/sessionModeConfigure.h b/commonLibs/lcameraDev/sessionModeConfigure.h new file mode 100644 index 0000000..6e59ee7 --- /dev/null +++ b/commonLibs/lcameraDev/sessionModeConfigure.h @@ -0,0 +1,34 @@ +#ifndef LCAMERA_DEV_SESSION_MODE_CONFIGURE_H +#define LCAMERA_DEV_SESSION_MODE_CONFIGURE_H + +#include +#include +#include + +namespace libcamera { +class CameraConfiguration; +} + +namespace lcamera_dev { + +enum class ConfigureSessionModeStatus +{ + Configured, + NoOpAlreadyConfigured, + RejectedConflictingRequest, +}; + +ConfigureSessionModeStatus applyModeRequestToSessionState( + CameraSessionResources& resources, + const LcameraDevCameraModeRequest& request, + const LcameraDevConfiguredCameraMode& resolvedMode, + std::shared_ptr heldConfiguration); + +LcameraDevConfiguredCameraMode configureLibcameraSessionMode( + const std::shared_ptr& camera, + const LcameraDevCameraModeRequest& request, + std::shared_ptr& heldConfiguration); + +} // namespace lcamera_dev + +#endif // LCAMERA_DEV_SESSION_MODE_CONFIGURE_H diff --git a/commonLibs/lcameraDev/tests/CMakeLists.txt b/commonLibs/lcameraDev/tests/CMakeLists.txt index 9c84aa1..33d3680 100644 --- a/commonLibs/lcameraDev/tests/CMakeLists.txt +++ b/commonLibs/lcameraDev/tests/CMakeLists.txt @@ -2,6 +2,9 @@ add_executable(lcameraDev_unit_tests selectorParse_tests.cpp selectorResolve_tests.cpp cameraIdentity_tests.cpp + cameraModeRequest_tests.cpp + planarYuvFormatPolicy_tests.cpp + sessionModeConfigure_state_tests.cpp ) target_include_directories(lcameraDev_unit_tests PRIVATE @@ -9,18 +12,26 @@ target_include_directories(lcameraDev_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/commonLibs/lcameraDev ${CMAKE_SOURCE_DIR}/commonLibs/lcameraDev/tests ${CMAKE_SOURCE_DIR}/tests/fixtures + ${CMAKE_SOURCE_DIR}/libspinscale/tests ${CMAKE_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/include + ${LIBCAMERA_INCLUDE_DIRS} ) target_link_libraries(lcameraDev_unit_tests gtest_main lcameraDev spinscale + spinscale_test_support ${Boost_LIBRARIES} + ${LIBCAMERA_LIBRARIES} ) -add_dependencies(lcameraDev_unit_tests gtest_main) +target_link_directories(lcameraDev_unit_tests PRIVATE + ${LIBCAMERA_LIBRARY_DIRS} +) + +add_dependencies(lcameraDev_unit_tests gtest_main spinscale_test_support) add_test(NAME lcameraDev_unit_tests COMMAND lcameraDev_unit_tests) @@ -50,3 +61,30 @@ add_dependencies(lcameraDev_hil_tests gtest_main spinscale_test_support) add_test(NAME lcameraDev_hil_tests COMMAND lcameraDev_hil_tests) set_tests_properties(lcameraDev_hil_tests PROPERTIES LABELS "HIL") + +add_executable(lcameraDev_configure_hil_tests + lcameraDev_configure_hil_tests.cpp +) + +target_include_directories(lcameraDev_configure_hil_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/commonLibs/lcameraDev + ${CMAKE_SOURCE_DIR}/commonLibs/lcameraDev/tests + ${CMAKE_SOURCE_DIR}/tests/fixtures + ${CMAKE_SOURCE_DIR}/libspinscale/tests + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_BINARY_DIR}/include +) + +target_link_libraries(lcameraDev_configure_hil_tests + gtest_main + lcameraDev + spinscale + spinscale_test_support + ${Boost_LIBRARIES} +) + +add_dependencies(lcameraDev_configure_hil_tests gtest_main spinscale_test_support) + +add_test(NAME lcameraDev_configure_hil_tests COMMAND lcameraDev_configure_hil_tests) +set_tests_properties(lcameraDev_configure_hil_tests PROPERTIES LABELS "HIL") diff --git a/commonLibs/lcameraDev/tests/cameraModeRequest_tests.cpp b/commonLibs/lcameraDev/tests/cameraModeRequest_tests.cpp new file mode 100644 index 0000000..ebd89f5 --- /dev/null +++ b/commonLibs/lcameraDev/tests/cameraModeRequest_tests.cpp @@ -0,0 +1,90 @@ +#include +#include +#include + +namespace lcamera_dev { +namespace { + +TEST(CameraModeRequestTest, DefaultFullPlanarIsOptionalIsFalse) +{ + LcameraDevCameraModeRequest request; + EXPECT_FALSE(request.fullPlanarIsOptional); +} + +TEST(CameraModeRequestTest, ZeroWidthThrows) +{ + LcameraDevCameraModeRequest request; + request.width = 0; + request.height = 480; + + EXPECT_THROW( + validateCameraModeRequest(request), + std::runtime_error); +} + +TEST(CameraModeRequestTest, ZeroHeightThrows) +{ + LcameraDevCameraModeRequest request; + request.width = 640; + request.height = 0; + + EXPECT_THROW( + validateCameraModeRequest(request), + std::runtime_error); +} + +TEST(CameraModeRequestTest, UnsupportedColourSpaceThrows) +{ + LcameraDevCameraModeRequest request; + request.width = 640; + request.height = 480; + + static_assert( + static_cast(LcameraDevColourSpace::Yuv) == 0, + "test assumes Yuv is first enumerator"); + + const LcameraDevColourSpace unsupportedColourSpace = + static_cast(1); + request.colourSpace = unsupportedColourSpace; + + EXPECT_THROW( + validateCameraModeRequest(request), + std::runtime_error); +} + +TEST(CameraModeRequestTest, FullPlanarOptionalRejectedAtConfigureApi) +{ + LcameraDevCameraModeRequest request; + request.width = 640; + request.height = 480; + request.fullPlanarIsOptional = true; + + try { + rejectFullPlanarOptionalAtConfigureApi(request); + FAIL() << "Expected runtime_error"; + } + catch (const std::runtime_error& exception) + { + sscl::tests::expectExceptionMessageContains( + exception, + "not honored yet"); + } +} + +TEST(CameraModeRequestTest, CameraModeRequestsEqualComparesAllFields) +{ + LcameraDevCameraModeRequest left; + left.width = 640; + left.height = 480; + left.colourSpace = LcameraDevColourSpace::Yuv; + left.fullPlanarIsOptional = false; + + LcameraDevCameraModeRequest right = left; + EXPECT_TRUE(cameraModeRequestsEqual(left, right)); + + right.height = 720; + EXPECT_FALSE(cameraModeRequestsEqual(left, right)); +} + +} // namespace +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/tests/lcameraDev_configure_hil_tests.cpp b/commonLibs/lcameraDev/tests/lcameraDev_configure_hil_tests.cpp new file mode 100644 index 0000000..479db0e --- /dev/null +++ b/commonLibs/lcameraDev/tests/lcameraDev_configure_hil_tests.cpp @@ -0,0 +1,541 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lcamera_dev { +namespace { + +constexpr const char *hilEnvVar = "LCAMERADEV_HIL"; +constexpr const char *machineEnvVar = "LCAMERADEV_MACHINE"; +constexpr const char *configureWidthEnvVar = "LCAMERADEV_CONFIGURE_W"; +constexpr const char *configureHeightEnvVar = "LCAMERADEV_CONFIGURE_H"; +constexpr const char *defaultMachineTag = "dell-laptop"; +constexpr unsigned defaultConfigureWidth = 640; +constexpr unsigned defaultConfigureHeight = 480; + +bool hilTestsEnabled() +{ + const char *value = std::getenv(hilEnvVar); + return value != nullptr && std::string(value) == "1"; +} + +std::string machineTagFromEnvironment() +{ + const char *value = std::getenv(machineEnvVar); + if (value != nullptr && std::string(value).size() > 0) { + return value; + } + + return defaultMachineTag; +} + +unsigned unsignedFromEnvironmentOrDefault( + const char *envVar, + unsigned defaultValue) +{ + const char *value = std::getenv(envVar); + if (value == nullptr || std::string(value).empty()) { + return defaultValue; + } + + return static_cast(std::stoul(value)); +} + +LcameraDevCameraModeRequest configureRequestFromEnvironment() +{ + LcameraDevCameraModeRequest request; + request.width = unsignedFromEnvironmentOrDefault( + configureWidthEnvVar, + defaultConfigureWidth); + request.height = unsignedFromEnvironmentOrDefault( + configureHeightEnvVar, + defaultConfigureHeight); + request.colourSpace = LcameraDevColourSpace::Yuv; + request.fullPlanarIsOptional = false; + return request; +} + +sscl::co::NonViralNonPostingInvoker getOrCreateCInd( + std::exception_ptr& exceptionStorage, + std::function callerLambda, + const char *deviceSelector, + lcamera_dev::LcameraDevGetOrCreateResult& createResult) +{ + (void)exceptionStorage; + (void)callerLambda; + + createResult = co_await lcameraDev_getOrCreateDeviceCReq(deviceSelector); + co_return; +} + +sscl::co::NonViralNonPostingInvoker configureCInd( + std::exception_ptr& exceptionStorage, + std::function callerLambda, + const std::shared_ptr& deviceSession, + const LcameraDevCameraModeRequest& request, + LcameraDevConfiguredCameraMode& configuredMode) +{ + (void)callerLambda; + + configuredMode = + co_await lcameraDev_configureSessionModeCReq(deviceSession, request); + + if (exceptionStorage) { + std::rethrow_exception(exceptionStorage); + } + + co_return; +} + +sscl::co::NonViralNonPostingInvoker releaseCInd( + std::exception_ptr& exceptionStorage, + std::function callerLambda, + const std::shared_ptr& deviceSession) +{ + (void)exceptionStorage; + (void)callerLambda; + + co_await lcameraDev_releaseDeviceCReq(deviceSession); + co_return; +} + +void runLcameraDevMainAndNurseryTask( + const std::function&)>& work) +{ + sscl::tests::ProbeComponentThreadHarness harness("lcameraDev-configure-hil"); + harness.runSync( + [&work](const std::shared_ptr& componentThread) + { + lcameraDev_main(componentThread); + work(componentThread); + lcameraDev_exit(); + }); +} + +void runNonViralNurseryRethrowingOnComponentThread( + const std::shared_ptr& componentThread, + const std::function& invokerFactory) +{ + std::exception_ptr slotException; + + sscl::co::NonViralTaskNursery nursery; + nursery.openAdmission(); + nursery.launch( + invokerFactory, + [&slotException](std::exception_ptr& exceptionPtr) + { + slotException = exceptionPtr; + }); + nursery.closeAdmission(); + nursery.syncAwaitAllSettlements(componentThread->getIoContext()); + + if (slotException) { + std::rethrow_exception(slotException); + } +} + +bool configureSessionOrExpectPlanarFailure( + const std::shared_ptr& componentThread, + const std::shared_ptr& deviceSession, + const LcameraDevCameraModeRequest& request, + LcameraDevConfiguredCameraMode& configuredMode, + const char *profileTag) +{ + try { + runNonViralNurseryRethrowingOnComponentThread( + componentThread, + [&deviceSession, &request, &configuredMode]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return configureCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + deviceSession, + request, + configuredMode); + }); + + EXPECT_TRUE(configuredMode.isFullyPlanar) << profileTag; + EXPECT_GE(configuredMode.width, 1u) << profileTag; + EXPECT_GE(configuredMode.height, 1u) << profileTag; + return true; + } + catch (const std::exception& exception) + { + sscl::tests::expectExceptionMessageContains( + exception, + "planar"); + return false; + } +} + +void configureProfileExpectingPlanarOrExplicitFailure( + const test_fixtures::BakedCameraProfile *profile, + const LcameraDevCameraModeRequest& request, + const std::shared_ptr& componentThread) +{ + lcamera_dev::LcameraDevGetOrCreateResult createResult; + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [profile, &createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return getOrCreateCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + profile->exampleSelector, + createResult); + }); + + EXPECT_TRUE(createResult.deviceSession != nullptr) + << profile->profileTag; + + LcameraDevConfiguredCameraMode configuredMode; + configureSessionOrExpectPlanarFailure( + componentThread, + createResult.deviceSession, + request, + configuredMode, + profile->profileTag); + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [&createResult](sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return releaseCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession); + }); +} + +class LcameraDevConfigureHilTest : public ::testing::Test +{ +protected: + void SetUp() override + { + if (!hilTestsEnabled()) { + GTEST_SKIP() << "Set " << hilEnvVar << "=1 to run hardware tests"; + } + + machineTag = machineTagFromEnvironment(); + requiredProfiles = + sscl::tests::requiredProfilesForMachine(machineTag.c_str()); + configureRequest = configureRequestFromEnvironment(); + + if (requiredProfiles.empty()) { + GTEST_SKIP() << "No baked profiles for machine tag " + << machineTag; + } + } + + const test_fixtures::BakedCameraProfile *findProfile( + const char *profileTag) const + { + for (const test_fixtures::BakedCameraProfile *profile : + requiredProfiles) + { + if (std::string(profile->profileTag) == profileTag) { + return profile; + } + } + + return nullptr; + } + + std::string machineTag; + std::vector requiredProfiles; + LcameraDevCameraModeRequest configureRequest; +}; + +TEST_F(LcameraDevConfigureHilTest, ConfigureUsbHdmiYuvRequiresPlanar) +{ + const test_fixtures::BakedCameraProfile *profile = + findProfile("usb_hdmi_camera"); + if (!profile) { + GTEST_SKIP() << "usb_hdmi_camera profile not available"; + } + + runLcameraDevMainAndNurseryTask( + [this, profile]( + const std::shared_ptr& componentThread) + { + configureProfileExpectingPlanarOrExplicitFailure( + profile, + configureRequest, + componentThread); + }); +} + +TEST_F(LcameraDevConfigureHilTest, ConfigureIntegratedWebcamYuv) +{ + const test_fixtures::BakedCameraProfile *profile = + findProfile("integrated_webcam"); + if (!profile) { + GTEST_SKIP() << "integrated_webcam profile not available"; + } + + runLcameraDevMainAndNurseryTask( + [this, profile]( + const std::shared_ptr& componentThread) + { + configureProfileExpectingPlanarOrExplicitFailure( + profile, + configureRequest, + componentThread); + }); +} + +TEST_F(LcameraDevConfigureHilTest, ConfiguredModeMatchesRequestDimensions) +{ + const test_fixtures::BakedCameraProfile *profile = + findProfile("integrated_webcam"); + if (!profile) { + GTEST_SKIP() << "integrated_webcam profile not available"; + } + + bool skipForMissingPlanarYuv = false; + + runLcameraDevMainAndNurseryTask( + [this, profile, &skipForMissingPlanarYuv]( + const std::shared_ptr& componentThread) + { + lcamera_dev::LcameraDevGetOrCreateResult createResult; + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [profile, &createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return getOrCreateCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + profile->exampleSelector, + createResult); + }); + + LcameraDevConfiguredCameraMode configuredMode; + const bool configureSucceeded = + configureSessionOrExpectPlanarFailure( + componentThread, + createResult.deviceSession, + configureRequest, + configuredMode, + profile->profileTag); + + if (!configureSucceeded) { + skipForMissingPlanarYuv = true; + } + else + { + EXPECT_GE(configuredMode.width, 1u); + EXPECT_GE(configuredMode.height, 1u); + EXPECT_LE( + configuredMode.width, + configureRequest.width); + EXPECT_LE( + configuredMode.height, + configureRequest.height); + } + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [&createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return releaseCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession); + }); + }); + + if (skipForMissingPlanarYuv) { + GTEST_SKIP() << "Camera lacks fully planar YUV at requested resolution"; + } +} + +TEST_F(LcameraDevConfigureHilTest, IdenticalReconfigureIsNoOp) +{ + const test_fixtures::BakedCameraProfile *profile = + findProfile("integrated_webcam"); + if (!profile) { + GTEST_SKIP() << "integrated_webcam profile not available"; + } + + bool skipForMissingPlanarYuv = false; + + runLcameraDevMainAndNurseryTask( + [this, profile, &skipForMissingPlanarYuv]( + const std::shared_ptr& componentThread) + { + lcamera_dev::LcameraDevGetOrCreateResult createResult; + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [profile, &createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return getOrCreateCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + profile->exampleSelector, + createResult); + }); + + LcameraDevConfiguredCameraMode firstMode; + LcameraDevConfiguredCameraMode secondMode; + + const bool firstConfigureSucceeded = + configureSessionOrExpectPlanarFailure( + componentThread, + createResult.deviceSession, + configureRequest, + firstMode, + profile->profileTag); + + if (!firstConfigureSucceeded) { + skipForMissingPlanarYuv = true; + } + else + { + const int configureCallCountAfterFirst = + createResult.deviceSession + ->getLibcameraConfigureCallCount(); + + runNonViralNurseryRethrowingOnComponentThread( + componentThread, + [&createResult, &secondMode, this]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return configureCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession, + configureRequest, + secondMode); + }); + + EXPECT_EQ( + createResult.deviceSession + ->getLibcameraConfigureCallCount(), + configureCallCountAfterFirst); + EXPECT_EQ(secondMode.pixelFormatName, firstMode.pixelFormatName); + EXPECT_EQ(secondMode.width, firstMode.width); + EXPECT_EQ(secondMode.height, firstMode.height); + } + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [&createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return releaseCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession); + }); + }); + + if (skipForMissingPlanarYuv) { + GTEST_SKIP() << "Camera lacks fully planar YUV at requested resolution"; + } +} + +TEST_F(LcameraDevConfigureHilTest, ConflictingReconfigureThrows) +{ + const test_fixtures::BakedCameraProfile *profile = + findProfile("integrated_webcam"); + if (!profile) { + GTEST_SKIP() << "integrated_webcam profile not available"; + } + + bool skipForMissingPlanarYuv = false; + + runLcameraDevMainAndNurseryTask( + [this, profile, &skipForMissingPlanarYuv]( + const std::shared_ptr& componentThread) + { + lcamera_dev::LcameraDevGetOrCreateResult createResult; + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [profile, &createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return getOrCreateCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + profile->exampleSelector, + createResult); + }); + + LcameraDevConfiguredCameraMode configuredMode; + const bool configureSucceeded = + configureSessionOrExpectPlanarFailure( + componentThread, + createResult.deviceSession, + configureRequest, + configuredMode, + profile->profileTag); + + if (!configureSucceeded) { + skipForMissingPlanarYuv = true; + } + else + { + LcameraDevCameraModeRequest conflictingRequest = + configureRequest; + conflictingRequest.width = configureRequest.width + 64; + + EXPECT_THROW( + runNonViralNurseryRethrowingOnComponentThread( + componentThread, + [&createResult, &conflictingRequest]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + LcameraDevConfiguredCameraMode ignoredMode; + return configureCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession, + conflictingRequest, + ignoredMode); + }), + std::runtime_error); + } + + sscl::tests::runNonViralNurseryOnComponentThread( + componentThread, + [&createResult]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return releaseCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession); + }); + }); + + if (skipForMissingPlanarYuv) { + GTEST_SKIP() << "Camera lacks fully planar YUV at requested resolution"; + } +} + +} // namespace +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/tests/planarYuvFormatPolicy_tests.cpp b/commonLibs/lcameraDev/tests/planarYuvFormatPolicy_tests.cpp new file mode 100644 index 0000000..edbd02c --- /dev/null +++ b/commonLibs/lcameraDev/tests/planarYuvFormatPolicy_tests.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include + +namespace lcamera_dev { +namespace { + +using libcamera::formats::NV12; +using libcamera::formats::YUYV; +using libcamera::formats::YUV420; + +TEST(PlanarYuvFormatPolicyTest, FullyPlanarRequiredPicksYuv420OverNv12) +{ + const std::vector candidates = {YUV420, NV12}; + + const std::optional selected = + selectYuvCaptureFormat(candidates, false); + + EXPECT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, YUV420); + EXPECT_TRUE(isFullyPlanarYuv(*selected)); +} + +TEST(PlanarYuvFormatPolicyTest, FullyPlanarRequiredThrowsWhenOnlyNonPlanar) +{ + const std::vector candidates = {NV12, YUYV}; + + try { + selectYuvCaptureFormat(candidates, false); + FAIL() << "Expected runtime_error"; + } + catch (const std::runtime_error& exception) + { + sscl::tests::expectExceptionMessageContains( + exception, + "planar"); + } +} + +TEST(PlanarYuvFormatPolicyTest, FullyPlanarOptionalPicksNv12) +{ + const std::vector candidates = {NV12}; + + const std::optional selected = + selectYuvCaptureFormat(candidates, true); + + EXPECT_TRUE(selected.has_value()); + EXPECT_EQ(*selected, NV12); + EXPECT_FALSE(isFullyPlanarYuv(*selected)); + EXPECT_EQ(yuvCapturePlaneCount(*selected), 2u); +} + +TEST(PlanarYuvFormatPolicyTest, EmptyCandidateListThrows) +{ + const std::vector candidates; + + EXPECT_THROW( + selectYuvCaptureFormat(candidates, false), + std::runtime_error); +} + +TEST(PlanarYuvFormatPolicyTest, IsFullyPlanarYuvRecognizesYuv420) +{ + EXPECT_TRUE(isFullyPlanarYuv(YUV420)); + EXPECT_FALSE(isFullyPlanarYuv(NV12)); +} + +} // namespace +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/tests/sessionModeConfigure_state_tests.cpp b/commonLibs/lcameraDev/tests/sessionModeConfigure_state_tests.cpp new file mode 100644 index 0000000..11f60b4 --- /dev/null +++ b/commonLibs/lcameraDev/tests/sessionModeConfigure_state_tests.cpp @@ -0,0 +1,138 @@ +#include +#include +#include +#include +#include + +namespace lcamera_dev { +namespace { + +LcameraDevConfiguredCameraMode syntheticResolvedMode( + unsigned width, + unsigned height, + const char *pixelFormatName) +{ + LcameraDevConfiguredCameraMode mode; + mode.width = width; + mode.height = height; + mode.colourSpace = LcameraDevColourSpace::Yuv; + mode.pixelFormatName = pixelFormatName; + mode.isFullyPlanar = true; + mode.planeCount = 3; + return mode; +} + +LcameraDevCameraModeRequest makeRequest(unsigned width, unsigned height) +{ + LcameraDevCameraModeRequest request; + request.width = width; + request.height = height; + request.colourSpace = LcameraDevColourSpace::Yuv; + request.fullPlanarIsOptional = false; + return request; +} + +TEST(SessionModeConfigureStateTest, FirstConfigureMarksSessionConfigured) +{ + CameraSessionResources resources( + CameraIdentityRecord{}, + std::shared_ptr()); + + const LcameraDevCameraModeRequest request = makeRequest(640, 480); + const LcameraDevConfiguredCameraMode resolvedMode = + syntheticResolvedMode(640, 480, "YU12"); + + const ConfigureSessionModeStatus status = + applyModeRequestToSessionState( + resources, + request, + resolvedMode, + nullptr); + + EXPECT_EQ(status, ConfigureSessionModeStatus::Configured); + EXPECT_TRUE(resources.configuredMode.has_value()); + EXPECT_EQ(resources.configuredMode->width, 640u); + EXPECT_EQ(resources.configuredMode->pixelFormatName, "YU12"); + EXPECT_EQ(resources.libcameraConfigureCallCount, 1); +} + +TEST(SessionModeConfigureStateTest, IdenticalReconfigureIsNoOp) +{ + CameraSessionResources resources( + CameraIdentityRecord{}, + std::shared_ptr()); + + const LcameraDevCameraModeRequest request = makeRequest(640, 480); + const LcameraDevConfiguredCameraMode resolvedMode = + syntheticResolvedMode(640, 480, "YU12"); + + applyModeRequestToSessionState( + resources, + request, + resolvedMode, + nullptr); + + const ConfigureSessionModeStatus status = + applyModeRequestToSessionState( + resources, + request, + resolvedMode, + nullptr); + + EXPECT_EQ(status, ConfigureSessionModeStatus::NoOpAlreadyConfigured); + EXPECT_EQ(resources.libcameraConfigureCallCount, 1); + EXPECT_EQ(resources.configuredMode->pixelFormatName, "YU12"); +} + +TEST(SessionModeConfigureStateTest, ConflictingReconfigureThrows) +{ + CameraSessionResources resources( + CameraIdentityRecord{}, + std::shared_ptr()); + + const LcameraDevCameraModeRequest firstRequest = makeRequest(640, 480); + const LcameraDevConfiguredCameraMode firstMode = + syntheticResolvedMode(640, 480, "YU12"); + + applyModeRequestToSessionState( + resources, + firstRequest, + firstMode, + nullptr); + + const LcameraDevCameraModeRequest conflictingRequest = makeRequest(1280, 720); + + EXPECT_THROW( + applyModeRequestToSessionState( + resources, + conflictingRequest, + syntheticResolvedMode(1280, 720, "YU12"), + nullptr), + std::runtime_error); +} + +TEST(SessionModeConfigureStateTest, GetConfiguredModeReturnsStoredValues) +{ + CameraSessionResources resources( + CameraIdentityRecord{}, + std::shared_ptr()); + + const LcameraDevCameraModeRequest request = makeRequest(800, 600); + const LcameraDevConfiguredCameraMode resolvedMode = + syntheticResolvedMode(800, 600, "YU12"); + + applyModeRequestToSessionState( + resources, + request, + resolvedMode, + nullptr); + + EXPECT_EQ(resources.configuredMode->width, 800u); + EXPECT_EQ(resources.configuredMode->height, 600u); + EXPECT_EQ(resources.configuredMode->colourSpace, LcameraDevColourSpace::Yuv); + EXPECT_TRUE(resources.configuredMode->isFullyPlanar); + EXPECT_EQ(resources.configuredMode->planeCount, 3u); +} + +} // namespace +} // namespace lcamera_dev diff --git a/commonLibs/lcameraDev/tools/lcameraDevConfigureProbe.cpp b/commonLibs/lcameraDev/tools/lcameraDevConfigureProbe.cpp new file mode 100644 index 0000000..86aaf51 --- /dev/null +++ b/commonLibs/lcameraDev/tools/lcameraDevConfigureProbe.cpp @@ -0,0 +1,376 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr const char *optPlanarFlag = "--opt-planar"; +constexpr const char *fullPlanarOptionalFlag = "--full-planar-is-optional"; +constexpr const char *reconfigureTwiceFlag = "--reconfigure-twice"; +constexpr const char *colourSpacePrefix = "--colour-space="; +constexpr const char *colorSpacePrefix = "--color-space="; + +struct ConfigureProbeArgs +{ + std::string deviceSelector; + unsigned width = 0; + unsigned height = 0; + lcamera_dev::LcameraDevColourSpace colourSpace = + lcamera_dev::LcameraDevColourSpace::Yuv; + bool fullPlanarIsOptional = false; + bool reconfigureTwice = false; +}; + +void printUsage(std::ostream& stream) +{ + stream << + "Usage: lcameraDev_configure_probe " + "[options]\n" + "Options:\n" + " --colour-space=yuv Semantic colour-space (only yuv today)\n" + " --opt-planar Set fullPlanarIsOptional=true on request\n" + " --full-planar-is-optional Same as --opt-planar\n" + " --reconfigure-twice Configure twice with identical request " + "(no-op check)\n" + "Examples:\n" + " lcameraDev_configure_probe model-substr:Integrated 640 480\n" + " lcameraDev_configure_probe model-substr:HDMI 1280 720 --opt-planar\n" + " lcameraDev_configure_probe index:0 640 480 --reconfigure-twice\n"; +} + +std::string colourSpaceToString(lcamera_dev::LcameraDevColourSpace colourSpace) +{ + switch (colourSpace) + { + case lcamera_dev::LcameraDevColourSpace::Yuv: + return "yuv"; + default: + return "unknown"; + } +} + +bool parseColourSpaceValue(const std::string& value) +{ + if (value == "yuv" || value == "YUV") { + return true; + } + + throw std::runtime_error( + "unsupported colour-space \"" + value + "\" (only yuv is supported)"); +} + +unsigned parseDimensionArg(const char *arg, const char *label) +{ + try { + const unsigned long parsed = std::stoul(arg); + if (parsed == 0) { + throw std::runtime_error( + std::string(label) + " must be non-zero"); + } + + return static_cast(parsed); + } + catch (const std::exception&) + { + throw std::runtime_error( + std::string("invalid ") + label + " \"" + arg + "\""); + } +} + +ConfigureProbeArgs parseConfigureProbeArgs(int argc, char *argv[]) +{ + if (argc < 4) { + throw std::runtime_error("missing required arguments"); + } + + ConfigureProbeArgs args; + args.deviceSelector = argv[1]; + args.width = parseDimensionArg(argv[2], "width"); + args.height = parseDimensionArg(argv[3], "height"); + + for (int i = 4; i < argc; ++i) + { + const std::string token = argv[i]; + + if (token == optPlanarFlag || token == fullPlanarOptionalFlag) { + args.fullPlanarIsOptional = true; + continue; + } + + if (token == reconfigureTwiceFlag) { + args.reconfigureTwice = true; + continue; + } + + const std::string colourSpacePrefixStr = colourSpacePrefix; + const std::string colorSpacePrefixStr = colorSpacePrefix; + + if (token.rfind(colourSpacePrefixStr, 0) == 0) { + parseColourSpaceValue(token.substr(colourSpacePrefixStr.size())); + continue; + } + + if (token.rfind(colorSpacePrefixStr, 0) == 0) { + parseColourSpaceValue(token.substr(colorSpacePrefixStr.size())); + continue; + } + + throw std::runtime_error("unknown option \"" + token + "\""); + } + + return args; +} + +void printRequest(const lcamera_dev::LcameraDevCameraModeRequest& request) +{ + std::cout << "request:" + << " width=" << request.width + << " height=" << request.height + << " colour-space=" << colourSpaceToString(request.colourSpace) + << " fullPlanarIsOptional=" + << (request.fullPlanarIsOptional ? "true" : "false") + << '\n'; +} + +void printConfiguredMode( + const lcamera_dev::LcameraDevConfiguredCameraMode& configuredMode) +{ + std::cout << "configured:" + << " width=" << configuredMode.width + << " height=" << configuredMode.height + << " colour-space=" << colourSpaceToString(configuredMode.colourSpace) + << " pixelFormat=" << configuredMode.pixelFormatName + << " isFullyPlanar=" + << (configuredMode.isFullyPlanar ? "true" : "false") + << " planeCount=" << configuredMode.planeCount + << '\n'; +} + +void runNurseryRethrowing( + const std::shared_ptr& componentThread, + const std::function& invokerFactory) +{ + std::exception_ptr slotException; + + sscl::co::NonViralTaskNursery nursery; + nursery.openAdmission(); + nursery.launch( + invokerFactory, + [&slotException](std::exception_ptr& exceptionPtr) + { + slotException = exceptionPtr; + }); + nursery.closeAdmission(); + nursery.syncAwaitAllSettlements(componentThread->getIoContext()); + + if (slotException) { + std::rethrow_exception(slotException); + } +} + +sscl::co::NonViralNonPostingInvoker getOrCreateCInd( + std::exception_ptr& exceptionStorage, + std::function callerLambda, + const std::string& deviceSelector, + lcamera_dev::LcameraDevGetOrCreateResult& createResult) +{ + (void)callerLambda; + + createResult = co_await lcameraDev_getOrCreateDeviceCReq(deviceSelector); + + if (exceptionStorage) { + std::rethrow_exception(exceptionStorage); + } + + co_return; +} + +sscl::co::NonViralNonPostingInvoker configureProbeCInd( + std::exception_ptr& exceptionStorage, + std::function callerLambda, + const std::shared_ptr& deviceSession, + const lcamera_dev::LcameraDevCameraModeRequest& request, + lcamera_dev::LcameraDevConfiguredCameraMode& configuredMode) +{ + (void)callerLambda; + + configuredMode = + co_await lcameraDev_configureSessionModeCReq(deviceSession, request); + + if (exceptionStorage) { + std::rethrow_exception(exceptionStorage); + } + + co_return; +} + +sscl::co::NonViralNonPostingInvoker releaseCInd( + std::exception_ptr& exceptionStorage, + std::function callerLambda, + const std::shared_ptr& deviceSession) +{ + (void)exceptionStorage; + (void)callerLambda; + + co_await lcameraDev_releaseDeviceCReq(deviceSession); + co_return; +} + +void releaseSessionAndExit( + const std::shared_ptr& componentThread, + std::shared_ptr& deviceSession) +{ + if (!deviceSession) { + lcameraDev_exit(); + return; + } + + runNurseryRethrowing( + componentThread, + [&deviceSession](sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return releaseCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + deviceSession); + }); + + std::cout << "lcameraDev_configure_probe: released session\n"; + + /** Drop the session before stopping CameraManager so libcamera does not + * tear down media devices while a Camera handle is still alive. */ + deviceSession.reset(); + + lcameraDev_exit(); +} + +int runConfigureProbe( + const std::shared_ptr& componentThread, + const ConfigureProbeArgs& args) +{ + lcameraDev_main(componentThread); + + lcamera_dev::LcameraDevGetOrCreateResult createResult; + + runNurseryRethrowing( + componentThread, + [&args, &createResult](sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return getOrCreateCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + args.deviceSelector, + createResult); + }); + + std::cout << "lcameraDev_configure_probe: opened session for camera id=" + << createResult.resolvedIdentity.id << '\n'; + + lcamera_dev::LcameraDevCameraModeRequest request; + request.width = args.width; + request.height = args.height; + request.colourSpace = args.colourSpace; + request.fullPlanarIsOptional = args.fullPlanarIsOptional; + + printRequest(request); + + lcamera_dev::LcameraDevConfiguredCameraMode firstMode; + + try { + runNurseryRethrowing( + componentThread, + [&createResult, &request, &firstMode]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return configureProbeCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession, + request, + firstMode); + }); + + printConfiguredMode(firstMode); + + const int configureCallCountAfterFirst = + createResult.deviceSession->getLibcameraConfigureCallCount(); + std::cout << "libcameraConfigureCallCount=" + << configureCallCountAfterFirst << '\n'; + + if (args.reconfigureTwice) + { + lcamera_dev::LcameraDevConfiguredCameraMode secondMode; + + runNurseryRethrowing( + componentThread, + [&createResult, &request, &secondMode]( + sscl::co::NonViralTaskNursery::Slot::Lease& lease) + { + return configureProbeCInd( + lease.getExceptionStorage(), + lease.getCallerLambda(), + createResult.deviceSession, + request, + secondMode); + }); + + printConfiguredMode(secondMode); + std::cout << "libcameraConfigureCallCount=" + << createResult.deviceSession->getLibcameraConfigureCallCount() + << " (after identical reconfigure)\n"; + } + } + catch (const std::exception& configureException) + { + std::cerr << "lcameraDev_configure_probe: configure failed: " + << configureException.what() << '\n'; + + releaseSessionAndExit( + componentThread, + createResult.deviceSession); + return 1; + } + + releaseSessionAndExit( + componentThread, + createResult.deviceSession); + return 0; +} + +} // namespace + +int main(int argc, char *argv[]) +{ + try { + const ConfigureProbeArgs args = parseConfigureProbeArgs(argc, argv); + + int exitCode = 0; + + lcamera_dev_probe::runOnComponentThread( + [&args, &exitCode]( + const std::shared_ptr& componentThread) + { + exitCode = runConfigureProbe(componentThread, args); + }); + + return exitCode; + } + catch (const std::runtime_error& exception) + { + std::cerr << "lcameraDev_configure_probe: " << exception.what() << '\n'; + printUsage(std::cerr); + return 1; + } + catch (const std::exception& exception) + { + std::cerr << "lcameraDev_configure_probe: " << exception.what() << '\n'; + return 1; + } +} diff --git a/docs/lcamera-dev-lib.md b/docs/lcamera-dev-lib.md index 5eafa02..6eb45c0 100644 --- a/docs/lcamera-dev-lib.md +++ b/docs/lcamera-dev-lib.md @@ -36,8 +36,10 @@ Channel splitting, colourspace conversion, threshold masks, and stencils belong in a separate shared raster library (`rasterStimulus`, future) and in `lcameraBuff`; `lcameraDev` stops at selector resolution, `CameraManager` lifecycle, and a refcounted, **acquired** `libcamera::Camera` handle per -resolved device. Stream negotiation, pixel-format selection, frame buffers, -and capture timing belong in `lcameraBuff` (and supporting libraries), not here. +resolved device. Session mode negotiation (width, height, colour-space, +fully-planar YUV requirement) happens in `lcameraDev` before capture starts. +Frame buffers, request queues, and capture timing belong in `lcameraBuff` (and +supporting libraries), not here. ## Why libcamera (for now) @@ -228,9 +230,39 @@ Rules: the same physical device from SMO’s perspective; channel differences come from the qualeIface name on each DAP line. -Streaming, frame delivery, and colourspace work are **out of scope** for -`lcameraDev`; `lcameraBuff` uses the session’s acquired camera handle to set up -capture on attach. +## Session mode configuration (Stage 1) + +Before `lcameraBuff` starts capture, each producer calls +`lcameraDev_configureSessionModeCReq` on the shared `CameraSession` with the +requested mode: + +| Field | Role | +|---|---| +| `width` / `height` | Requested capture dimensions (non-zero) | +| `colourSpace` | Semantic colour model (`Yuv` in v1) | +| `fullPlanarIsOptional` | Default `false` — must select fully planar YUV | + +`lcameraDev` chooses a concrete libcamera pixel format (e.g. YUV420) from the +camera’s supported formats. DAPS lines name the semantic colour-space, not a +raw fourcc. + +Rules: + +* Configure only while the session exists and capture has not started. +* **Identical** configure request on an already-configured session is a **no-op** + (multiple `lcameraBuff` producers sharing the same device selector each call + get-or-create then configure with the same mode). +* **Different** configure request on an already-configured session throws + (conflicting mode requests on the same physical camera). +* `fullPlanarIsOptional == false` (default): must select a fully planar YUV + format or throw with candidate-format diagnostics. +* `fullPlanarIsOptional == true`: rejected at the configure API until + `lcameraBuff` implements non-planar producer deinterleaving (Stage 2). The + policy helper still accepts optional planar selection for future use. + +Streaming, frame delivery, and per-frame colourspace work remain **out of scope** +for `lcameraDev`; `lcameraBuff` uses the configured session to allocate buffers +and start capture on attach. ## dlopen API @@ -273,8 +305,42 @@ typedef sscl::co::ViralNonPostingInvoker Failures throw `std::exception`. `lcameraBuff` holds the returned `shared_ptr` and passes it back to `releaseDeviceCReq`. The -session wraps the acquired `libcamera::Camera`; higher layers configure and -stream from that handle — `lcameraDev` does not expose frame or stream APIs. +session wraps the acquired `libcamera::Camera`; higher layers configure the mode +then stream from that handle. + +### Session mode configuration + +```cpp +enum class LcameraDevColourSpace { Yuv }; + +struct LcameraDevCameraModeRequest +{ + unsigned width = 0; + unsigned height = 0; + LcameraDevColourSpace colourSpace = LcameraDevColourSpace::Yuv; + bool fullPlanarIsOptional = false; +}; + +struct LcameraDevConfiguredCameraMode +{ + unsigned width; + unsigned height; + LcameraDevColourSpace colourSpace; + std::string pixelFormatName; + bool isFullyPlanar; + unsigned planeCount; +}; + +typedef sscl::co::ViralNonPostingInvoker + lcameraDev_configureSessionModeCReqFn( + const std::shared_ptr& deviceSession, + const LcameraDevCameraModeRequest& request); +``` + +`lcameraDev_configureSessionModeCReq` delegates to +`CameraSession::configureSessionModeCReq`, which runs libcamera +`generateConfiguration` + `configure` and stores the result on the session. +Identical reconfigure is a no-op; conflicting reconfigure throws. ### Enumeration (discovery) @@ -298,6 +364,10 @@ When built with `-DENABLE_LIB_lcameraDev=ON`: `ComponentThread`. * `lcameraDev_probe ` — `getOrCreateDeviceCReq`, then `releaseDeviceCReq` (selector and session attach/detach only). +* `lcameraDev_configure_probe [options]` — + `getOrCreateDeviceCReq`, `configureSessionModeCReq`, print resolved mode (or + exception), then `releaseDeviceCReq`. Options: `--colour-space=yuv`, + `--opt-planar` / `--full-planar-is-optional`, `--reconfigure-twice`. ## Module layout @@ -310,7 +380,8 @@ commonLibs/lcameraDev/ cameraIdentity.h / .cpp Discovery identity records selectorParse.h / .cpp Compound selector parsing selectorResolve.h / .cpp AND-match resolution - tools/ lcameraDev_list_cameras, lcameraDev_probe + tools/ lcameraDev_list_cameras, lcameraDev_probe, + lcameraDev_configure_probe ``` Build links against `libcamera` (pkg-config). Does **not** link Salmanoff diff --git a/tests/fixtures/bakedCameraProfiles.h b/tests/fixtures/bakedCameraProfiles.h index 588d47f..c600af9 100644 --- a/tests/fixtures/bakedCameraProfiles.h +++ b/tests/fixtures/bakedCameraProfiles.h @@ -28,6 +28,10 @@ struct BakedCameraProfile // MACHINE: dell-laptop // Captured via lcameraDev_list_cameras on 2026-06-10. +// Configure HIL note (2026-06-10): both cameras expose MJPEG and packed YUYV +// via libcamera VideoRecording/Viewfinder — no fully planar YUV420/NV12 at +// 640x480. configureSessionModeCReq correctly throws when fullPlanarIsOptional +// is false; success-path HIL tests skip on this machine. inline constexpr BakedCameraProfile bakedCameraProfiles[] = { { "dell-laptop",