diff --git a/.github/conan_profiles/macos-15-armv8 b/.github/conan_profiles/macos-15-armv8 new file mode 100644 index 0000000..b750648 --- /dev/null +++ b/.github/conan_profiles/macos-15-armv8 @@ -0,0 +1,10 @@ +[settings] +arch=armv8 +compiler=clang +compiler.cppstd=gnu20 +compiler.libcxx=libc++ +compiler.version=18 +os=Macos +os.version=15.0 +[conf] +tools.cmake.cmaketoolchain:generator=Ninja \ No newline at end of file diff --git a/.github/conan_profiles/windows-2022-x64 b/.github/conan_profiles/windows-2022-x64 new file mode 100644 index 0000000..5c3887e --- /dev/null +++ b/.github/conan_profiles/windows-2022-x64 @@ -0,0 +1,9 @@ +[settings] +arch=x86_64 +compiler=msvc +compiler.cppstd=20 +compiler.runtime=dynamic +compiler.version=194 +os=Windows +[conf] +tools.cmake.cmaketoolchain:generator=Ninja \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b39a88d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,137 @@ +name: build + +on: + workflow_dispatch: + +jobs: + build: + strategy: + matrix: + include: + - os: windows-2022 + arch: x64 + base_name: windows-x86_64 + - os: macos-15 + arch: armv8 + base_name: macos-armv8 + + runs-on: ${{ matrix.os }} + + env: + gst_version: 1.26.8 + + steps: + - uses: actions/checkout@v5 + + - name: Setup Compiler (Windows) + if: matrix.os == 'windows-2022' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Setup Compiler (macOS) + if: matrix.os == 'macos-15' + run: | + echo "CC=$(brew --prefix llvm@18)/bin/clang" >> $GITHUB_ENV + echo "CXX=$(brew --prefix llvm@18)/bin/clang++" >> $GITHUB_ENV + + - name: Install build tools + run: pipx install cmake conan ninja + + - name: Install pkg-config (Windows) + if: matrix.os == 'windows-2022' + run: choco install pkgconfiglite + + - name: Install conan profile + run: conan config install .github/conan_profiles/${{ matrix.os }}-${{ matrix.arch }} -tf profiles + + - name: Cache conan + uses: actions/cache@v4 + with: + path: ~/.conan2 + key: ${{ matrix.base_name }}-conan-${{ hashFiles('conanfile.py') }} + restore-keys: ${{ matrix.base_name }}-conan- + + - name: Cache gstreamer + uses: actions/cache@v4 + id: cache-gstreamer + with: + path: | + C:\Program Files\gstreamer + /Library/Frameworks/GStreamer.framework + key: ${{ matrix.base_name }}-gstreamer-${{ env.gst_version }} + restore-keys: ${{ matrix.base_name }}-gstreamer- + + - name: Install gstreamer (Windows) + if: matrix.os == 'windows-2022' && steps.cache-gstreamer.outputs.cache-hit != 'true' + env: + gst_archive: gstreamer-1.0-msvc-x86_64-${{ env.gst_version }}.msi + gst_devel_archive: gstreamer-1.0-devel-msvc-x86_64-${{ env.gst_version }}.msi + run: | + curl -o "${{ env.gst_archive }}" "https://gstreamer.freedesktop.org/data/pkg/windows/${{ env.gst_version }}/msvc/${{ env.gst_archive }}" + curl -o "${{ env.gst_devel_archive }}" "https://gstreamer.freedesktop.org/data/pkg/windows/${{ env.gst_version }}/msvc/${{ env.gst_devel_archive }}" + + $log = "install.log" + + $process = Start-Process "msiexec" "/i `"${{ env.gst_archive }}`" /qn /l*! `"$log`"" -NoNewWindow -PassThru + $process_log = Start-Process "powershell" "Get-Content -Path `"$log`" -Wait" -NoNewWindow -PassThru + $process.WaitForExit() + $process_log.Kill() + + $process = Start-Process "msiexec" "/i `"${{ env.gst_devel_archive }}`" /qn /l*! `"$log`"" -NoNewWindow -PassThru + $process_log = Start-Process "powershell" "Get-Content -Path `"$log`" -Wait" -NoNewWindow -PassThru + $process.WaitForExit() + $process_log.Kill() + + - name: Install gstreamer (macOS) + if: matrix.os == 'macos-15' && steps.cache-gstreamer.outputs.cache-hit != 'true' + shell: bash + run: | + gst_archive=gstreamer-1.0-${{ env.gst_version }}-universal.pkg + gst_devel_archive=gstreamer-1.0-devel-${{ env.gst_version }}-universal.pkg + + curl -o "$gst_archive" "https://gstreamer.freedesktop.org/data/pkg/osx/${{ env.gst_version }}/$gst_archive" + curl -o "$gst_devel_archive" "https://gstreamer.freedesktop.org/data/pkg/osx/${{ env.gst_version }}/$gst_devel_archive" + + sudo installer -pkg $gst_archive -target / + sudo installer -pkg $gst_devel_archive -target / + + - name: Setup conan index + run: | + git clone --depth 1 --branch master https://github.com/kontex-neuro/kontex-conan.git kontex-conan + conan remote add --force kontex-neuro ./kontex-conan + + - name: Build & Install (Windows) + if: matrix.os == 'windows-2022' + env: + PKG_CONFIG_PATH: "C:\\Program Files\\gstreamer\\1.0\\msvc_x86_64\\lib\\pkgconfig" + run: | + conan install . -b missing -pr:a ${{ matrix.os }}-${{ matrix.arch }} -s build_type=Release + cmake -S . -B build/Release --preset conan-release -G "Ninja" -DCMAKE_INSTALL_PREFIX=build/install -DCMAKE_BUILD_TYPE=Release + cmake --build build/Release --target install + + - name: Build & Install (macOS) + if: matrix.os == 'macos-15' + env: + PKG_CONFIG_PATH: /Library/Frameworks/GStreamer.framework/Versions/1.0/lib/pkgconfig + run: | + conan install . -b missing -pr:a ${{ matrix.os }}-${{ matrix.arch }} -s build_type=Release + cmake -S . -B build/Release --preset conan-release -G "Ninja" -DCMAKE_INSTALL_PREFIX=build/install -DCMAKE_BUILD_TYPE=Release + cmake --build build/Release --target install + + - name: Get & Set version + shell: bash + run: | + version=$(conan inspect -f json . | jq -r '.version') + echo $version + echo "version=$version" >> $GITHUB_ENV + + - name: Zip + shell: bash + run: | + mv build/install ${{ matrix.base_name }} + 7z a ${{ matrix.base_name }}-${{ env.version }}.zip ${{ matrix.base_name }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.base_name }} + path: ${{ matrix.base_name }}-${{ env.version }}.zip \ No newline at end of file diff --git a/.gitignore b/.gitignore index f6d94aa..19a90ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ # IDE, Editor +__pycache__/ +*.py[cod] /*.pro.user* /.vscode @@ -6,13 +8,15 @@ .DS_Store # Build -/build* -/.venv -/.cache -/CMakeUserPresets.json +dist/ +build/ +.venv/ +.cache/ +CMakeUserPresets.json # clang .clangd *.log -*.mkv \ No newline at end of file +*.mkv +*.sh \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 040a60b..d92b6eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,50 +1,146 @@ cmake_minimum_required(VERSION 3.25) -set(libxvc_VERSION 0.1.0) +set(libxvc_VERSION 0.2.1) project(libxvc LANGUAGES CXX VERSION "${libxvc_VERSION}" - DESCRIPTION "Thor Vision Video Capture Library" - HOMEPAGE_URL "https://www.kontex.io/" ) -if(APPLE) - add_compile_options($<$:-fexperimental-library>) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(Boost_USE_STATIC_LIBS ON) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${CMAKE_BINARY_DIR}/install" CACHE PATH "default install path" FORCE) endif() -list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +option(BUILD_SHARED_LIBS "Build as shared library" OFF) +option(BUILD_TESTS "Build tests" OFF) +option(BUILD_TOOLS "Build tools" OFF) +# option(BUILD_PYTHON_BINDINGS "Build python bindings" ON) +# option(BUILD_PYTHON_STUBS "Build python stubs" OFF) +option(USE_ASAN "Enable AddressSanitizer (ASan)" OFF) +option(USE_UBSAN "Enable UndefinedBehaviorSanitizer (UBSan)" OFF) +option(USE_TSAN "Enable ThreadSanitizer (TSan)" OFF) + +if(USE_ASAN AND USE_TSAN) + message( + FATAL_ERROR + "AddressSanitizer (ASan) and ThreadSanitizer (TSan) are mutually exclusive and cannot be enabled at the same time." + ) +endif() + +set(SANITIZER_COMPILE_FLAGS "") +set(SANITIZER_LINK_FLAGS "") + +if(USE_ASAN) + if(WIN32 AND MSVC) + list(APPEND SANITIZER_COMPILE_FLAGS /fsanitize=address) + list(APPEND SANITIZER_LINK_FLAGS /fsanitize=address) + else() + list(APPEND SANITIZER_COMPILE_FLAGS -fsanitize=address) + list(APPEND SANITIZER_LINK_FLAGS -fsanitize=address) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(APPEND SANITIZER_LINK_FLAGS -lpthread) + endif() + endif() +endif() + +if(USE_UBSAN) + if(WIN32 AND MSVC) + if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") + list(APPEND SANITIZER_COMPILE_FLAGS -fsanitize=undefined) + list(APPEND SANITIZER_LINK_FLAGS -fsanitize=undefined) + else() + message( + WARNING + "UndefinedBehaviorSanitizer (UBSan) is not directly supported via a simple flag in MSVC." + ) + endif() + else() + list(APPEND SANITIZER_COMPILE_FLAGS -fsanitize=undefined) + list(APPEND SANITIZER_LINK_FLAGS -fsanitize=undefined) + endif() +endif() + +if(USE_TSAN) + if(WIN32 AND MSVC) + message( + FATAL_ERROR + "ThreadSanitizer (TSan) is not supported by the MSVC compiler. Please use Clang or GCC." + ) + elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + message(FATAL_ERROR "ThreadSanitizer (TSan) is not supported on macOS.") + else() + list(APPEND SANITIZER_COMPILE_FLAGS -fsanitize=thread) + list(APPEND SANITIZER_LINK_FLAGS -fsanitize=thread) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(APPEND SANITIZER_LINK_FLAGS -lpthread) + endif() + endif() +endif() + +add_library(xvc) + +target_compile_options(xvc PUBLIC ${SANITIZER_COMPILE_FLAGS}) +target_link_options(xvc PUBLIC ${SANITIZER_LINK_FLAGS}) -set(Boost_USE_STATIC_LIBS ON) -find_package(fmt REQUIRED) find_package(spdlog REQUIRED) find_package(nlohmann_json REQUIRED) +find_package(nlohmann_json_schema_validator REQUIRED) find_package(cpr REQUIRED) -find_package(xdaqmetadata REQUIRED) find_package(Boost 1.81.0 REQUIRED) -find_package(CLI11 REQUIRED) -find_package(OpenSSL REQUIRED) - find_package(PkgConfig REQUIRED) pkg_search_module(gstreamer REQUIRED IMPORTED_TARGET gstreamer-1.0>=1.4) -pkg_search_module(gstreamer-app REQUIRED IMPORTED_TARGET gstreamer-app-1.0>=1.4) -pkg_search_module(gstreamer-video REQUIRED IMPORTED_TARGET gstreamer-video-1.0>=1.4) add_subdirectory(xdaqvc) -add_subdirectory(tool) -if(BUILD_TESTING) +if(BUILD_TESTS) enable_testing() - find_package(GTest REQUIRED) find_package(Catch2 REQUIRED) - include(CTest) - add_subdirectory(test) + add_subdirectory(tests) endif() -include(CMakePackageConfigHelpers) +if(BUILD_TOOLS) + find_package(xdaqmetadata REQUIRED) + find_package(CLI11 REQUIRED) + pkg_search_module(gstreamer-app REQUIRED IMPORTED_TARGET gstreamer-app-1.0>=1.4) + pkg_search_module(gstreamer-video REQUIRED IMPORTED_TARGET gstreamer-video-1.0>=1.4) + add_subdirectory(tools) +endif() + +# if(BUILD_PYTHON_BINDINGS) +# find_package(Python 3.9 +# REQUIRED COMPONENTS Interpreter Development.Module +# # OPTIONAL_COMPONENTS Development.SABIModule +# ) +# find_package(nanobind CONFIG REQUIRED) +# add_subdirectory(python/src) +# endif() + include(GNUInstallDirs) +install( + TARGETS xvc + EXPORT libxvc-targets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + FILE_SET "public_headers" DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}/xdaqvc +) +install( + EXPORT libxvc-targets + FILE libxvc-targets.cmake + NAMESPACE libxvc:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libxvc" +) +export( + EXPORT libxvc-targets + FILE libxvc-config.cmake + NAMESPACE libxvc:: +) + +include(CMakePackageConfigHelpers) + configure_package_config_file( "${CMAKE_CURRENT_SOURCE_DIR}/cmake/libxvc-config.cmake.in" "${CMAKE_CURRENT_BINARY_DIR}/libxvc-config.cmake" diff --git a/conanfile.py b/conanfile.py index 2440344..8dd389c 100644 --- a/conanfile.py +++ b/conanfile.py @@ -4,30 +4,26 @@ class libxvc(ConanFile): name = "libxvc" - version = "0.1.0" + version = "0.3.0" settings = "os", "compiler", "build_type", "arch" generators = "VirtualRunEnv" license = "LGPL-3.0-or-later" url = "https://github.com/kontex-neuro/libxvc.git" description = "Thor Vision Video Capture library" - options = {"build_testing": [True, False]} - default_options = {"build_testing": False} + exports_sources = "CMakeLists.txt", "cmake/*", "xdaqvc/*", "tools/*" def build_requirements(self): self.tool_requires("cmake/[>=3.25.0 <3.30.0]") self.tool_requires("ninja/[>=1.12.0]") - if self.options.build_testing: - # self.test_requires("catch2/3.8.0") - self.test_requires("gtest/1.14.0") + self.test_requires("catch2/3.8.0") def requirements(self): self.requires("boost/1.81.0") - self.requires("fmt/10.2.1") self.requires("spdlog/1.13.0") self.requires("nlohmann_json/3.11.3") - self.requires("cpr/1.10.5") - self.requires("xdaqmetadata/0.1.0") - self.requires("openssl/3.4.1") + self.requires("json-schema-validator/2.3.0") + self.requires("cpr/1.14.2") + self.requires("xdaqmetadata/0.2.0") self.requires("cli11/2.5.0") def configure(self): @@ -82,7 +78,6 @@ def generate(self): deps.generate() tc = CMakeToolchain(self) tc.generator = "Ninja" - tc.variables["BUILD_TESTING"] = self.options.build_testing tc.generate() def build(self): @@ -95,4 +90,4 @@ def package(self): cmake.install() def package_info(self): - self.cpp_info.libs = ["libxvc"] + self.cpp_info.libs = ["xvc"] diff --git a/scripts/plot.py b/scripts/plot.py deleted file mode 100644 index 83eb800..0000000 --- a/scripts/plot.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -import re -import click -import matplotlib.pyplot as plt -from collections import defaultdict - - -FPS_PATTERN = re.compile(r"fps(\d+):.*average:\s*([\d.]+)") - - -def parse_filename(filename): - match = re.match(r"decode_(\d+)_(\d+)_(\d+)\.log", filename) - if match: - width, height, instances = match.groups() - resolution = f"{width}x{height}" - return resolution, int(instances) - return None, None - - -def parse_fps_from_log(filepath): - results = {} - - with open(filepath, "r") as f: - for line in f: - match = FPS_PATTERN.search(line) - if match: - idx, fps = int(match.group(1)), float(match.group(2)) - results[idx] = fps - - return sum(results.values()) / len(results) if results else None - - -def collect_log_data(folder_path): - data = defaultdict(list) - - for filename in os.listdir(folder_path): - if filename.startswith("decode_") and filename.endswith(".log"): - resolution, instances = parse_filename(filename) - if resolution: - filepath = os.path.join(folder_path, filename) - avg_fps = parse_fps_from_log(filepath) - if avg_fps is not None: - data[resolution].append((instances, avg_fps)) - - for resolution in data: - data[resolution].sort() - - return data - - -def plot_fps_data(data, log_scale=True): - plt.figure(figsize=(12, 7)) - - for resolution, values in data.items(): - x = [inst for inst, _ in values] - y = [fps for _, fps in values] - plt.plot(x, y, marker="o", label=resolution) - - for inst, fps in values: - plt.annotate( - f"{inst}x\n{fps:.1f} FPS", - (inst, fps), - textcoords="offset points", - xytext=(0, 5), - ha="center", - fontsize=8, - ) - - for line in [30, 60, 120]: - plt.axhline(y=line, color="gray", linestyle="--", linewidth=1) - plt.text(plt.xlim()[0], line * 1.05, f"{line} FPS", color="gray", fontsize=9) - - plt.xlabel("Decoder Instances") - plt.ylabel("Average FPS (log scale)" if log_scale else "Average FPS") - if log_scale: - plt.yscale("log") - plt.title("Decoder Performance vs Instance Count") - plt.legend() - plt.grid(True, which="both", linestyle="--", linewidth=0.5) - plt.tight_layout() - plt.show() - - -@click.command() -@click.argument("folder", type=click.Path(exists=True, file_okay=False)) -@click.option("--linear", is_flag=True, help="Use linear scale for FPS (default: log).") -def main(folder, linear): - """ - Visualize decoder performance logs in FOLDER. - - \b - FOLDER: Path to the folder containing average FPS log files. - """ - data = collect_log_data(folder) - if not data: - click.echo("No valid log files found.") - return - - plot_fps_data(data, log_scale=not linear) - - -if __name__ == "__main__": - main() diff --git a/scripts/test_decode.py b/scripts/test_decode.py deleted file mode 100644 index eb4066e..0000000 --- a/scripts/test_decode.py +++ /dev/null @@ -1,220 +0,0 @@ -import subprocess -import re -import os -import time -import click - -FPS_PATTERN = re.compile(r"fps(\d+):.*average:\s*([\d.]+)") - -CODEC_MAP = { - "jpeg": {"encoder": "jpegenc", "decoder": "jpegdec"}, - "h264": { - "encoder": "x264enc speed-preset=ultrafast tune=zerolatency", - "decoder": "avdec_h264", - }, - "h265": { - "encoder": "x265enc speed-preset=ultrafast tune=zerolatency", - "decoder": "avdec_h265", - }, -} - - -def get_codec_elements(codec): - if codec not in CODEC_MAP: - raise ValueError(f"Unsupported codec: {codec}") - return CODEC_MAP[codec]["encoder"], CODEC_MAP[codec]["decoder"] - - -def run_decoder_pipeline( - resolution, - instances, - codec, - log_path, - mode, - video_path=None, -): - encoder, decoder = get_codec_elements(codec) - - decode_branches = [ - f"t. ! queue name=dec{i} ! {decoder} ! " - f"fpsdisplaysink name=fps{i} text-overlay=false video-sink=fakesink sync=false" - for i in range(instances) - ] - - if mode == "file": - pipeline = f"filesrc location={video_path} ! " f"tee name=t " + " ".join( - decode_branches - ) - else: - width, height = resolution.lower().split("x") - pipeline = ( - f"videotestsrc is-live=false pattern=1 ! " - f"video/x-raw,width={width},height={height} ! {encoder} ! " - f"tee name=t " + " ".join(decode_branches) - ) - - cmd = f"gst-launch-1.0 -v {pipeline}" - - with open(log_path, "w") as logfile: - process = subprocess.Popen( - cmd, - shell=True, - stdout=logfile, - stderr=subprocess.STDOUT, - text=True, - ) - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - print("Pipeline timeout. Terminating...") - process.terminate() - process.wait() - - -def analyze_fps_log(log_path, instances, expected_fps): - results = {} - - with open(log_path, "r") as log_file: - for line in reversed(log_file.readlines()): - match = FPS_PATTERN.search(line) - if match: - idx, fps = int(match.group(1)), float(match.group(2)) - if idx not in results: - results[idx] = fps - if len(results) == instances: - break - - all_ok = all(results.get(i, 0) >= expected_fps for i in range(instances)) - return all_ok, results - - -def run_resolution_test( - resolution, - expected_fps, - codec, - max_instances, - mode, - video_path, - run_dir, - label="", -): - for instances in range(1, max_instances + 1): - log_name = ( - f"decode_{label}_{instances}.log" - if label - else f"decode_{resolution.replace('x', '_')}_{instances}.log" - ) - log_path = os.path.join(run_dir, log_name) - - run_decoder_pipeline(resolution, instances, codec, log_path, mode, video_path) - all_ok, fps_data = analyze_fps_log(log_path, instances, expected_fps) - - print(f"\n--- {resolution} | {instances} instance(s) ---") - for i in range(instances): - avg_fps = fps_data.get(i) - if avg_fps is not None: - status = "OK" if avg_fps >= expected_fps else "SLOW" - print( - f"Pipeline {i}: average FPS = {avg_fps:.2f} >= {expected_fps:.2f} => {status}" - ) - else: - print(f"Pipeline {i}: No FPS data found") - - if not all_ok: - print( - f"\nMax sustainable instances for {label or resolution} at {expected_fps:.2f} FPS: {instances - 1}" - ) - return instances - 1 - - instances += 1 - - -def auto_scale_test( - resolutions, - expected_fps=30, - codec="jpeg", - max_instances=64, - mode="encode", - video_path=None, -): - - timestamp = time.strftime("%Y%m%d_%H%M%S") - run_dir = f"test_logs_{timestamp}" - os.makedirs(run_dir, exist_ok=True) - - if mode == "file": - print(f"\n--- Testing file input: {video_path} ---") - resolution = resolutions[0] - run_resolution_test( - resolution, - expected_fps, - codec, - max_instances, - mode, - video_path, - run_dir, - label="file", - ) - else: - for resolution in resolutions: - print(f"\n--- Testing resolution: {resolution} ---") - run_resolution_test( - resolution, - expected_fps, - codec, - max_instances, - mode, - video_path, - run_dir, - ) - - -@click.command() -@click.argument("mode", default="encode", type=click.Choice(["encode", "file"])) -@click.argument( - "resolutions", - nargs=-1, - required=False, -) -@click.argument("fps", default=30, type=int) -@click.option( - "-c", - "--codec", - default="jpeg", - type=click.Choice(["jpeg", "h264", "h265"]), - help="Codec to use.", -) -@click.argument("max-instances", default=64, type=int) -@click.option("-p", "--path", default=None, help="Path to video file (file mode only).") -def main(mode, resolutions, fps, codec, max_instances, path): - """ - Benchmark max sustainable decode branches at a given FPS. - - \b - MODE: 'encode' or 'file' - RESOLUTIONS: e.g., 1920x1080 1280x720 (for 'encode' mode) - FPS: Expected average FPS per pipeline - INSTANCES: Maximum number of decode branches to test - """ - - if mode == "file": - if not path: - raise click.UsageError("In 'file' mode, --path is required.") - resolution_list = ["dummy"] - else: - if not resolutions: - raise click.UsageError("In 'encode' mode, --resolutions is required.") - resolution_list = list(resolutions) - - auto_scale_test( - resolution_list, - fps, - codec, - max_instances, - mode, - path, - ) - - -if __name__ == "__main__": - main() diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt deleted file mode 100644 index e28b43c..0000000 --- a/test/CMakeLists.txt +++ /dev/null @@ -1,47 +0,0 @@ -add_executable(xvc_updater_tests) - -target_sources(xvc_updater_tests - PRIVATE - updater_test_base.h - updater_test.cc -) -target_link_libraries(xvc_updater_tests - PRIVATE - libxvc - gtest::gtest - openssl::openssl -) -target_include_directories(xvc_updater_tests - INTERFACE - "$" - "$" -) - -add_test( - NAME xvc_updater_tests - COMMAND xvc_updater_tests -) -target_compile_features(xvc_updater_tests PRIVATE cxx_std_20) -target_compile_options(xvc_updater_tests - PRIVATE - $<$:/W4> - $<$>:-Wall> -) - -# add_executable(test_port_pool) - -# target_sources(test_port_pool -# PRIVATE -# test_port_pool.cc -# ) -# target_link_libraries(test_port_pool -# PRIVATE -# Catch2::Catch2 -# libxvc -# ) -# target_compile_features(test_port_pool PRIVATE cxx_std_20) -# target_compile_options(test_port_pool -# PRIVATE -# $<$:/W4> -# $<$>:-Wall> -# ) \ No newline at end of file diff --git a/test/test_port_pool.cc b/test/test_port_pool.cc deleted file mode 100644 index d1ce28c..0000000 --- a/test/test_port_pool.cc +++ /dev/null @@ -1,117 +0,0 @@ -#include - -#include "port_pool.h" - -int main() -{ - try { - PortPool pool1(9000, 9010); - PortPool pool2(9000, 9010); - - pool1.print_available_ports(); - pool2.print_available_ports(); - - auto port1 = pool1.allocate_port(); - auto port2 = pool2.allocate_port(); - - pool1.print_available_ports(); - pool1.release_port(port1.value()); - - pool2.print_available_ports(); - pool2.release_port(port2.value()); - - std::vector allocated1; - try { - for (auto i = 0; i < 20; ++i) { - auto port = pool1.allocate_port(); - if (!port) continue; - allocated1.push_back(port.value()); - } - } catch (const std::exception &ex) { - fmt::println("Expected exception on exhaustion: {}", ex.what()); - } - - std::vector allocated2; - try { - for (auto i = 0; i < 20; ++i) { - auto port = pool2.allocate_port(); - if (!port) continue; - allocated2.push_back(port.value()); - } - } catch (const std::exception &ex) { - fmt::println("Expected exception on exhaustion: {}", ex.what()); - } - - } catch (const std::exception &ex) { - fmt::println("Test failed: {}", ex.what()); - } - - return 0; -} - - -// #include -// #include - -// #include "port_pool.h" - -// TEST_CASE("PortPool allocates a valid port", "[allocate]") -// { -// PortPool pool(40000, 40010); -// auto port = pool.allocate_port(); - -// REQUIRE(port.has_value()); -// REQUIRE(port.value() >= 40000); -// REQUIRE(port.value() < 40010); -// } - -// TEST_CASE("PortPool allocates all ports then fails", "[allocate][exhaust]") -// { -// const int start = 40100; -// const int end = 40105; -// PortPool pool(start, end); - -// std::set allocated_ports; -// for (int i = 0; i < (end - start); ++i) { -// auto port = pool.allocate_port(); -// REQUIRE(port.has_value()); -// allocated_ports.insert(port.value()); -// } - -// REQUIRE(allocated_ports.size() == static_cast(end - start)); - -// SECTION("No ports should be available now") -// { -// auto port = pool.allocate_port(); -// REQUIRE_FALSE(port.has_value()); -// } -// } - -// TEST_CASE("PortPool releases and reallocates a port", "[release][reuse]") -// { -// PortPool pool(40200, 40203); - -// auto port1 = pool.allocate_port(); -// REQUIRE(port1.has_value()); - -// pool.release_port(port1.value()); - -// auto port2 = pool.allocate_port(); -// REQUIRE(port2.has_value()); -// REQUIRE(port2.value() == port1.value()); -// } - -// TEST_CASE("PortPool rejects invalid range", "[ctor]") -// { -// REQUIRE_THROWS_AS(PortPool(5000, 5000), std::invalid_argument); -// REQUIRE_THROWS_AS(PortPool(5001, 5000), std::invalid_argument); -// } - -// TEST_CASE("PortPool handles out-of-range releases safely", "[release]") -// { -// PortPool pool(40300, 40302); - -// REQUIRE_NOTHROW(pool.release_port(40299)); -// REQUIRE_NOTHROW(pool.release_port(40302)); // Equal to end -// REQUIRE_NOTHROW(pool.release_port(50000)); // Far outside -// } diff --git a/test/updater_test.cc b/test/updater_test.cc deleted file mode 100644 index 02812eb..0000000 --- a/test/updater_test.cc +++ /dev/null @@ -1,152 +0,0 @@ -#include "updater_test_base.h" - - -using namespace std::chrono_literals; - - -TEST_F(XVCUpdaterTest, CalculateSHA256) -{ - // Create a test file with known content - auto test_content = "Hello, World!"; - std::ofstream _test_file("test.txt"); - _test_file << test_content; - _test_file.close(); - - auto hash = xvc::calculate_sha256("test.txt"); - ASSERT_TRUE(hash.has_value()); - EXPECT_FALSE(hash->empty()); - - // Known SHA256 hash for "Hello, World!" - auto expected_hash = "dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f"; - EXPECT_EQ(*hash, expected_hash); - - fs::remove("test.txt"); -} - -TEST_F(XVCUpdaterTest, DownloadAndVerify) -{ - // Get version table first to get valid hash - auto version_table = xvc::get_version_table(version_table_url); - ASSERT_TRUE(version_table.has_value()); - ASSERT_FALSE(version_table->versions.empty()); - - const auto &test_version = version_table->versions.back(); - auto download_url = fmt::format("https://xvc001.sgp1.cdn.digitaloceanspaces.com/{}", test_file); - - auto result = xvc::download_and_verify(download_url, test_version.hash, test_file); - ASSERT_TRUE(result.success) << "Download failed: " << result.error_message; - - // Verify file exists and has content - ASSERT_TRUE(fs::exists(test_file)); - ASSERT_GT(fs::file_size(test_file), 0); -} - -TEST_F(XVCUpdaterTest, DownloadAndVerifyInvalidHash) -{ - auto result = xvc::download_and_verify( - "https://xvc001.sgp1.cdn.digitaloceanspaces.com/xvc-server-0.0.1.tar.xz", - "invalid_hash", - test_file - ); - EXPECT_FALSE(result.success); - EXPECT_FALSE(result.error_message.empty()); - EXPECT_FALSE(fs::exists(test_file)); -} - -TEST_F(XVCUpdaterTest, HandshakeTest) -{ - auto response = xvc::perform_handshake(server_address, update_server_port); - ASSERT_TRUE(response.success) << "Handshake failed: " << response.error_message; - - EXPECT_FALSE(response.token.empty()); - EXPECT_GT(response.expires, std::chrono::system_clock::now()); -} - -TEST_F(XVCUpdaterTest, FileTransferWorkflow) -{ - // First download a test file - auto version_table = xvc::get_version_table(version_table_url); - ASSERT_TRUE(version_table.has_value()); - ASSERT_FALSE(version_table->versions.empty()); - - const auto &test_version = version_table->versions.back(); - auto download_url = fmt::format("https://xvc001.sgp1.cdn.digitaloceanspaces.com/{}", test_file); - - auto download_result = xvc::download_and_verify(download_url, test_version.hash, test_file); - ASSERT_TRUE(download_result.success); - - // Perform handshake - auto handshake = xvc::perform_handshake(server_address, update_server_port); - ASSERT_TRUE(handshake.success); - - // Prepare transfer - std::string transfer_id; - auto file_size = fs::file_size(test_file); - bool prepared = xvc::prepare_file_transfer( - server_address, - update_server_port, - handshake.token, - test_file, - test_version.hash, - file_size, - transfer_id - ); - ASSERT_TRUE(prepared); - EXPECT_FALSE(transfer_id.empty()); - - // Perform transfer - bool transfer_success = xvc::transfer_file( - server_address, - update_server_port, - handshake.token, - test_file, - transfer_id, - [](const xvc::FileTransferProgress &progress) { - EXPECT_GE(progress.progress_percentage, 0.0f); - EXPECT_LE(progress.progress_percentage, 100.0f); - EXPECT_GT(progress.total_bytes, 0ULL); - } - ); - ASSERT_TRUE(transfer_success); -} - -TEST_F(XVCUpdaterTest, GetServerVersion) -{ - auto version = xvc::get_server_version(server_address, server_port); - ASSERT_TRUE(version.has_value()); - EXPECT_GT(*version, xvc::Version({0, 0, 0})); -} - -TEST_F(XVCUpdaterTest, GetVersionTable) -{ - auto table = xvc::get_version_table(version_table_url); - ASSERT_TRUE(table.has_value()); - EXPECT_FALSE(table->versions.empty()); - EXPECT_EQ(table->latest_version, table->versions.front().version); -} - -TEST_F(XVCUpdaterTest, CompleteUpdateWorkflow) -{ - auto result = xvc::update_server( - server_address, - server_port, - update_server_port, - version_table_url, - update_dir, - client_version - ); - - ASSERT_TRUE(result.success) << "Update failed: " << result.error_message; - - if (result.update_needed) { - EXPECT_GT(result.available_version, result.current_version); - - // Verify update file was downloaded - auto expected_filename = - fmt::format("xvc-server-{}.tar.xz", result.available_version.to_string()); - auto update_path = fs::path(update_dir) / expected_filename; - - EXPECT_TRUE(fs::exists(update_path)); - EXPECT_GT(fs::file_size(update_path), 0); - } -} \ No newline at end of file diff --git a/test/updater_test_base.h b/test/updater_test_base.h deleted file mode 100644 index 71b5829..0000000 --- a/test/updater_test_base.h +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include -#include - -#include -#include - -#include "updater.h" - - -namespace fs = std::filesystem; - - -class XVCUpdaterTest : public testing::Test -{ -protected: - void SetUp() override - { - server_address = "192.168.177.100"; - server_port = 8000; - update_server_port = 8001; - version_table_url = "https://xvc001.sgp1.digitaloceanspaces.com/versions.json"; - update_dir = "test_updates"; - test_file = "xvc-server-0.0.1.tar.xz"; - client_version = xvc::Version{0, 0, 1}; - } - - void TearDown() override - { - fs::remove_all(update_dir); - if (fs::exists(test_file)) { - fs::remove(test_file); - } - } - - std::string read_file_content(const std::string &filepath) - { - std::ifstream file(filepath); - if (!file.is_open()) { - ADD_FAILURE() << "Could not open file: " << filepath; - return std::string(""); - } - return std::string( - (std::istreambuf_iterator(file)), std::istreambuf_iterator() - ); - } - -public: - std::string server_address; - int server_port; - int update_server_port; - std::string version_table_url; - std::string update_dir; - std::string test_file; - xvc::Version client_version; -}; \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..099c49a --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,39 @@ +add_executable(test_port_pool test_port_pool.cc) +target_link_libraries(test_port_pool + PRIVATE + xvc + Catch2::Catch2WithMain +) +target_compile_features(test_port_pool PRIVATE cxx_std_23) +target_compile_options(test_port_pool + PRIVATE + $<$:/W4> + $<$>:-Wall> +) + +add_executable(test_ws_client test_ws_client.cc) +target_link_libraries(test_ws_client + PRIVATE + xvc + nlohmann_json::nlohmann_json + Catch2::Catch2WithMain +) +target_compile_features(test_ws_client PRIVATE cxx_std_23) +target_compile_options(test_ws_client + PRIVATE + $<$:/W4> + $<$>:-Wall> +) + +add_executable(test_camera test_camera.cc) +target_link_libraries(test_camera + PRIVATE + xvc + Catch2::Catch2WithMain +) +target_compile_features(test_camera PRIVATE cxx_std_23) +target_compile_options(test_camera + PRIVATE + $<$:/W4> + $<$>:-Wall> +) \ No newline at end of file diff --git a/tests/test_camera.cc b/tests/test_camera.cc new file mode 100644 index 0000000..0ad7ab9 --- /dev/null +++ b/tests/test_camera.cc @@ -0,0 +1,101 @@ +#include + +#include "camera.h" + +TEST_CASE("Camera::parse", "[camera][parse]") +{ + SECTION("Parses valid camera JSON") + { + constexpr auto json = R"( + { + "id": 1, + "device_id": "0000:XXXX", + "name": "Test Camera", + "caps": [ + { + "media_type": "video/x-h264", + "format": "H264", + "width": 1920, + "height": 1080, + "framerate": "30/1" + } + ] + } + )"; + auto camera = Camera::parse(json); + REQUIRE(camera); + } + + SECTION("Rejects non-object JSON") + { + constexpr auto json = R"( + [ + { + "id": 1, + "device_id": "0000:XXXX", + "name": "Test Camera", + "caps": [ + { + "media_type": "video/x-h264", + "format": "H264", + "width": 1920, + "height": 1080, + "framerate": "30/1" + } + ] + } + ] + )"; + auto camera = Camera::parse(json); + REQUIRE_FALSE(camera); + } + + SECTION("Rejects JSON missing required field") + { + constexpr auto json = R"( + { + "device_id": "0000:XXXX", + "name": "Test Camera", + "caps": [ + { + "media_type": "video/x-h264", + "format": "H264", + "width": 1920, + "height": 1080, + "framerate": "30/1" + } + ] + } + )"; + auto camera = Camera::parse(json); + REQUIRE_FALSE(camera); + } +} + +TEST_CASE("Camera port allocation does not collide", "[camera][portpool]") +{ + using namespace std::chrono_literals; + + SECTION("Exhausting port pool of camera throws runtime_error") + { + std::vector> cameras; + const auto size = 64; + cameras.reserve(size); + for (auto i = 0; i < size; ++i) { + auto cam = std::make_unique(0, "0000:XXXX", "cam"); + cameras.push_back(std::move(cam)); + } + REQUIRE_THROWS_AS(Camera(0, "0000:XXXX", "cam"), std::runtime_error); + } + + SECTION("Port is freed when camera is destroyed") + { + auto cam1 = std::make_unique(0, "0000:XXXX", "cam"); + auto port1 = cam1->port(); + cam1.reset(); + auto cam2 = std::make_unique(0, "0000:XXXX", "cam"); + auto port2 = cam2->port(); + + REQUIRE(port2 == port1); + } +} \ No newline at end of file diff --git a/tests/test_port_pool.cc b/tests/test_port_pool.cc new file mode 100644 index 0000000..f0c9035 --- /dev/null +++ b/tests/test_port_pool.cc @@ -0,0 +1,53 @@ +#include +#include +#include + +#include "port_pool.h" + +TEST_CASE("PortPool constructs with valid range", "[port_pool]") +{ + SECTION("Constructs with valid range") { REQUIRE_NOTHROW(PortPool{9000, 9005}); } + + SECTION("Allocates unique ports within range") + { + PortPool pool(9000, 9005); + std::set allocated; + for (auto i = 0; i < 5; ++i) { + auto port = pool.allocate(); + REQUIRE(port.has_value()); + REQUIRE(port.value() >= 9000); + REQUIRE(port.value() < 9005); + REQUIRE(allocated.insert(port.value()).second); + } + } + + SECTION("Failed to allocate when pool is exhausted") + { + PortPool pool(9000, 9002); + REQUIRE(pool.allocate()); + REQUIRE(pool.allocate()); + REQUIRE_FALSE(pool.allocate()); + } + + SECTION("Released ports can be reallocated") + { + PortPool pool(9000, 9002); + + auto p1 = pool.allocate(); + auto p2 = pool.allocate(); + REQUIRE(p1); + REQUIRE(p2); + + pool.release(p1.value()); + auto p3 = pool.allocate(); + REQUIRE(p3); + + REQUIRE(p3.value() == p1.value()); + } + + SECTION("Releasing port which is out of range is safe") + { + PortPool pool(9000, 9002); + REQUIRE_FALSE(pool.release(9002)); + } +} diff --git a/tests/test_ws_client.cc b/tests/test_ws_client.cc new file mode 100644 index 0000000..5085e11 --- /dev/null +++ b/tests/test_ws_client.cc @@ -0,0 +1,52 @@ +#include +#include + +#include "camera.h" +#include "ws_client.h" + +using namespace std::chrono_literals; + +TEST_CASE("WebSocket add/remove cameras", "[ws][camera]") +{ + std::atomic received_event{false}; + std::vector> cameras; + + auto client = xvc::ws_client("192.168.177.100", "8000", [&](std::string event) { + REQUIRE_NOTHROW(nlohmann::json::parse(event)); + + auto const device_event = nlohmann::json::parse(event); + REQUIRE(device_event.contains("event_type")); + REQUIRE(device_event.contains("camera")); + + auto const &event_type = device_event.at("event_type").get(); + auto const &camera_json = device_event.at("camera"); + + if (event_type == "Added") { + auto camera = Camera::parse(camera_json.dump()); + REQUIRE(camera); + + cameras.emplace_back(std::move(camera)); + received_event = true; + + } else if (event_type == "Removed") { + REQUIRE(camera_json.contains("id")); + + auto const id = camera_json.at("id").get(); + cameras.erase( + std::remove_if( + cameras.begin(), + cameras.end(), + [id](const std::unique_ptr &cam) { return cam->id() == id; } + ), + cameras.end() + ); + received_event = true; + + } else { + FAIL("Unknown event_type: " + event_type); + } + }); + + std::this_thread::sleep_for(10s); + REQUIRE(received_event); +} diff --git a/tool/CMakeLists.txt b/tool/CMakeLists.txt deleted file mode 100644 index fe8e5f0..0000000 --- a/tool/CMakeLists.txt +++ /dev/null @@ -1,29 +0,0 @@ -add_executable(tvcli tvcli.cc) -target_link_libraries(tvcli - PRIVATE - libxvc - CLI11::CLI11 - PkgConfig::gstreamer-app - PkgConfig::gstreamer-video -) -target_compile_features(tvcli PRIVATE cxx_std_20) -target_compile_options(tvcli - PRIVATE - $<$:/W4> - # $<$:/W4 /WX> - $<$>:-Wall> - # $<$>:-Wall -Wextra -Wpedantic -Werror> -) - -add_executable(xvc_update_tool xvc_update_tool.cc) -target_link_libraries(xvc_update_tool - PRIVATE - libxvc - CLI11::CLI11 -) -target_compile_features(xvc_update_tool PRIVATE cxx_std_20) -target_compile_options(xvc_update_tool - PRIVATE - $<$:/W4> - $<$>:-Wall> -) \ No newline at end of file diff --git a/tool/tvcli.cc b/tool/tvcli.cc deleted file mode 100644 index d39dd33..0000000 --- a/tool/tvcli.cc +++ /dev/null @@ -1,373 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include "camera.h" -#include "server.h" -#include "xdaqmetadata/metadata_handler.h" -#include "xvc.h" - -using json = nlohmann::json; - -namespace -{ - -GMainLoop *loop = nullptr; -GstElement *pipeline = nullptr; -MetadataHandler *handler = nullptr; -bool record = false; - -Camera *stream_cam = nullptr; -std::vector cams; - -GstFlowReturn draw_image(GstAppSink *sink, [[maybe_unused]] void *user_data) -{ - std::unique_ptr sample( - gst_app_sink_pull_sample(sink), gst_sample_unref - ); - if (!sample) return GST_FLOW_OK; - - auto buffer = gst_sample_get_buffer(sample.get()); - GstMapInfo info; - if (gst_buffer_map(buffer, &info, GST_MAP_READ)) { - std::unique_ptr video_info( - gst_video_info_new(), gst_video_info_free - ); - if (!gst_video_info_from_caps(video_info.get(), gst_sample_get_caps(sample.get()))) { - spdlog::critical("Failed to parse video info"); - gst_buffer_unmap(buffer, &info); - return GST_FLOW_ERROR; - } - auto caps = gst_sample_get_caps(sample.get()); - auto structure = gst_caps_get_structure(caps, 0); - auto width = static_cast(g_value_get_int(gst_structure_get_value(structure, "width"))); - auto height = - static_cast(g_value_get_int(gst_structure_get_value(structure, "height"))); - auto buffer_pts = GST_BUFFER_PTS(buffer); - - auto xdaqmetadata = handler->safe_deque.check_pts_pop_timestamp(buffer_pts); - auto metadata = xdaqmetadata.value_or(XDAQFrameData{0, 0, 0, 0, 0, 0}); - - spdlog::info( - "Received buffer: size={}, pts={}, width={}, height={}, " - "fpga_timestamp={}, rhythm_timestamp={}, ttl_in={}, ttl_out={}, spi_perf_counter={}, " - "reserved={}", - gst_buffer_get_size(buffer), - buffer_pts, - width, - height, - metadata.fpga_timestamp, - metadata.rhythm_timestamp, - metadata.ttl_in, - metadata.ttl_out, - metadata.spi_perf_counter, - metadata.reserved - ); - gst_buffer_unmap(buffer, &info); - } - return GST_FLOW_OK; -} - -void handle_sigint(int) -{ - spdlog::info("SIGINT received, stopping camera..."); - if (stream_cam) { - stream_cam->stop(); - } - if (record) { - xvc::stop_jpeg_recording(GST_PIPELINE(pipeline)); - } - if (pipeline) { - gst_element_set_state(pipeline, GST_STATE_NULL); - } - if (loop) { - g_main_loop_quit(loop); - } - std::exit(EXIT_SUCCESS); -} - -std::string cap_to_string(const Camera::Cap &cap) -{ - // Skip format for image/jpeg media type - if (cap.format.empty()) { - return fmt::format( - "{},width={},height={},framerate={}/{}", - cap.media_type, - cap.width, - cap.height, - cap.fps_n, - cap.fps_d - ); - } else { - return fmt::format( - "{},format={},width={},height={},framerate={}/{}", - cap.media_type, - cap.format, - cap.width, - cap.height, - cap.fps_n, - cap.fps_d - ); - } -} - -std::vector cameras() -{ - auto const cameras_str = Camera::cameras(); - std::vector cams; - - if (cameras_str.empty()) { - fmt::println("No camera found"); - return cams; - } - - auto const cameras_json = json::parse(cameras_str); - - for (const auto &camera_json : cameras_json) { - auto id = camera_json["id"].get(); - auto name = camera_json["name"].get(); - auto cam = new Camera(id, name); - - for (const auto &cap_json : camera_json["caps"]) { - Camera::Cap cap; - cap.media_type = cap_json["media_type"].get(); - cap.format = cap_json["format"].get(); - cap.width = cap_json["width"].get(); - cap.height = cap_json["height"].get(); - - auto framerate_str = cap_json["framerate"].get(); - auto delimiter_pos = framerate_str.find('/'); - if (delimiter_pos != std::string::npos) { - cap.fps_n = std::stoi(framerate_str.substr(0, delimiter_pos)); - cap.fps_d = std::stoi(framerate_str.substr(delimiter_pos + 1)); - } - cam->add_cap(cap); - } - cams.emplace_back(cam); - } - return cams; -} - -} // namespace - -int func(int argc, char *argv[]) -{ - CLI::App app("Thor Vision CLI", "tvcli"); - argv = app.ensure_utf8(argv); - - std::string host = "192.168.177.100"; - int id; - std::string cap, codec; - - std::string location = "."; - auto split = false; - auto max_size_time = 5; - auto max_files = 10; - auto test = false; - std::string log_file; - std::string time_unit; - - auto stream = app.add_subcommand("stream", "Stream camera"); - stream->add_option("--host", host, "Host computer that connected cameras") - ->default_val(host) - ->group("Stream"); - stream->add_option("-i,--id", id, "Camera device ID")->required()->group("Stream"); - stream->add_option("--cap", cap, "Camera capability")->required()->group("Stream"); - stream->add_option("--codec", codec, "Camera codec")->required()->group("Stream"); - stream->add_flag("-t,--test", test, "Enable test mode")->default_val(test)->group("Stream"); - - auto opt_record = - stream->add_flag("-r,--record", record, "Whether to record stream")->group("Record"); - auto opt_location = stream->add_option("--location", location, "Location to save record") - ->default_val(location) - ->group("Record"); - auto opt_split = stream->add_flag("-s,--split", split, "Enable split recording") - ->default_val(split) - ->group("Record"); - auto opt_max_size_time = - stream - ->add_option("--max-size-time", max_size_time, "Max recording time per file (minutes)") - ->default_val(5) - ->group("Split"); - auto opt_time_unit = - stream->add_option("--time-unit", time_unit, "Time unit for recording split size") - ->check(CLI::IsMember({"seconds", "minutes", "hours", "days"})) - ->default_val("minutes") - ->group("Split"); - auto opt_max_files = - stream->add_option("--max-files", max_files, "Maximum number of files to keep") - ->default_val(10) - ->group("Split"); - - opt_location->needs(opt_record); - opt_split->needs(opt_record); - opt_max_size_time->needs(opt_split); - opt_time_unit->needs(opt_split); - opt_max_files->needs(opt_split); - - auto list = app.add_subcommand("list", "List cameras"); - list->add_option("--host", host, "Host computer that connected cameras")->default_val(host); - - auto logs = app.add_subcommand("logs", "Show server logs"); - logs->add_option("--host", host, "Host computer that connected cameras")->default_val(host); - logs->add_option("-f,--file", log_file, "Log file to view"); - - CLI11_PARSE(app, argc, argv); - - signal(SIGINT, handle_sigint); - gst_init(&argc, &argv); - - xvc::TimeUnit unit; - - if (time_unit == "seconds") - unit = xvc::TimeUnit::Seconds; - else if (time_unit == "minutes") - unit = xvc::TimeUnit::Minutes; - else if (time_unit == "hours") - unit = xvc::TimeUnit::Hours; - else if (time_unit == "days") - unit = xvc::TimeUnit::Days; - else { - fmt::println("Invalid time unit specified."); - return EXIT_FAILURE; - } - - if (*stream) { - // TODO: support h264, h265 - auto valid_codecs = {"jpeg"}; - if (std::find(valid_codecs.begin(), valid_codecs.end(), codec) == valid_codecs.end()) { - fmt::println("Invalid codec. Valid options is: jpeg."); - return EXIT_FAILURE; - } - - if (!test) { - cams = cameras(); - for (auto cam : cams) { - if (id == cam->id()) { - stream_cam = cam; - break; - } - } - if (!stream_cam) { - fmt::println("Error: no camera with id = {}", id); - return EXIT_FAILURE; - } - - auto caps = stream_cam->caps(); - auto it = std::find_if(caps.begin(), caps.end(), [cap](const Camera::Cap &_cap) { - return cap_to_string(_cap) == cap; - }); - if (it == caps.end()) { - fmt::println("Error: Camera {} does not support cap '{}'", id, cap); - return EXIT_FAILURE; - } - } else { - stream_cam = new Camera(id, "test"); - } - - stream_cam->set_current_cap(cap); - stream_cam->set_test(test); - stream_cam->start(); - - auto uri = fmt::format("{}:{}", host, stream_cam->port()); - auto record_path = std::filesystem::current_path(); - auto filepath = record_path / fmt::format("{}-{}", stream_cam->name(), stream_cam->id()); - - if (location != ".") { - record_path = fs::path(location); - if (!fs::exists(record_path)) { - fmt::println("Error: specified location path '{}' does not exist.", location); - return EXIT_FAILURE; - } - } - - handler = new MetadataHandler(); - pipeline = gst_pipeline_new(codec.c_str()); - loop = g_main_loop_new(nullptr, false); - - if (codec == "jpeg") { - xvc::setup_jpeg_srt_stream(GST_PIPELINE(pipeline), uri); - if (record) { - xvc::start_jpeg_recording( - GST_PIPELINE(pipeline), filepath, !split, max_size_time, unit, max_files - ); - } - } - - auto parser = gst_bin_get_by_name(GST_BIN(pipeline), "parser"); - std::unique_ptr src_pad( - gst_element_get_static_pad(parser, "src"), gst_object_unref - ); - gst_pad_add_probe( - src_pad.get(), GST_PAD_PROBE_TYPE_BUFFER, parse_jpeg_metadata, handler, nullptr - ); - - GstAppSinkCallbacks callbacks = {nullptr, nullptr, draw_image, nullptr, nullptr, {nullptr}}; - auto appsink = gst_bin_get_by_name(GST_BIN(pipeline), "appsink"); - gst_app_sink_set_callbacks(GST_APP_SINK(appsink), &callbacks, nullptr, nullptr); - - auto ret = gst_element_set_state(pipeline, GST_STATE_PLAYING); - if (ret == GST_STATE_CHANGE_FAILURE) { - spdlog::error("Unable to set the pipeline to the playing state"); - return EXIT_FAILURE; - } - - auto _thread = std::jthread([]() { - spdlog::debug("Run GStreamer stream thread"); - g_main_loop_run(loop); - spdlog::debug("Quit GStreamer stream thread"); - delete stream_cam; - delete handler; - stream_cam = nullptr; - handler = nullptr; - }); - } - - if (*list) { - cams = cameras(); - fmt::println("Discovered Cameras:"); - - for (auto cam : cams) { - fmt::println(""); - fmt::println("Camera ID : {}", cam->id()); - fmt::println("Name : {}", cam->name()); - fmt::println("Capabilities :"); - - for (auto cap : cam->caps()) { - fmt::println(" - {}", cap_to_string(cap)); - } - } - } - - if (*logs) { - auto server = xvc::Server(host); - auto logs = log_file.empty() ? server.logs() : server.logs(log_file); - - fmt::println("{}", logs); - } - - return EXIT_SUCCESS; -} - -int main(int argc, char *argv[]) -{ -#if defined(__APPLE__) && TARGET_OS_MAC && !TARGET_OS_IPHONE - return gst_macos_main((GstMainFunc) func, argc, argv, nullptr); -#else - return func(argc, argv); -#endif -} \ No newline at end of file diff --git a/tool/xvc_update_tool.cc b/tool/xvc_update_tool.cc deleted file mode 100644 index 50d23fd..0000000 --- a/tool/xvc_update_tool.cc +++ /dev/null @@ -1,112 +0,0 @@ -#include - -#include -#include - -#include "updater.h" - -int main(int argc, char *argv[]) -{ - // TODO: Make this updater CLI part of tvcli - std::string server_address = "192.168.177.100"; - auto server_port = 8000; - auto update_server_port = 8001; - std::string version_table_url = "https://xvc001.sgp1.digitaloceanspaces.com/versions.json"; - std::string update_dir = "updates"; - auto skip_version_check = false; - std::string target_version; - std::string calculate_hash_file; - bool get_server_version = false; - - CLI::App app{"Thor Vision Server Updater"}; - app.add_option("-s,--server", server_address, "Server address (IP or hostname)") - ->default_val(server_address); - app.add_option("-p,--port", server_port, "Server port to be updated")->default_val(server_port); - app.add_option("-u,--update-port", update_server_port, "Update server port") - ->default_val(update_server_port); - app.add_option("-t,--version-table", version_table_url, "Version table URL") - ->default_val(version_table_url); - app.add_option("-d,--update-dir", update_dir, "Directory for downloaded updates") - ->default_val(update_dir); - app.add_flag("-f,--force", skip_version_check, "Skip version check"); - app.add_option("-v,--version", target_version, "Target version to update to (optional)"); - app.add_option("-c,--calculate-hash", calculate_hash_file, "Calculate SHA256 hash for a file"); - app.add_flag( - "-g,--get-server-version", get_server_version, "Get and display the server version" - ); - - CLI11_PARSE(app, argc, argv); - - try { - // Handle calculate-hash option - if (!calculate_hash_file.empty()) { - auto hash = xvc::calculate_sha256(calculate_hash_file); - if (hash) { - return EXIT_SUCCESS; - } else { - spdlog::error("Failed to calculate hash for file: {}", calculate_hash_file); - return EXIT_FAILURE; - } - } - - // Handle get-server-version option - if (get_server_version) { - auto version = xvc::get_server_version(server_address, server_port); - if (version) { - spdlog::info("Server version: {}", version->to_string()); - return EXIT_SUCCESS; - } else { - spdlog::error("Failed to get server version"); - return EXIT_FAILURE; - } - } - - // Set client version (using a dummy version that should work with most updates) - xvc::Version client_version{999, 999, 999}; - - // Parse target version if specified - std::optional force_version; - if (!target_version.empty()) { - auto parsed_version = xvc::Version::from_string(target_version); - if (!parsed_version) { - spdlog::error("Invalid target version format: {}", target_version); - return EXIT_FAILURE; - } - force_version = *parsed_version; - } - - // Perform update - auto result = xvc::update_server( - server_address, - server_port, - update_server_port, - version_table_url, - update_dir, - client_version, - skip_version_check, - force_version - ); - - if (!result.success) { - spdlog::error("Update failed: {}", result.error_message); - return EXIT_FAILURE; - } - - if (!result.update_needed) { - spdlog::info( - "Server is already up to date (version {})", result.current_version.to_string() - ); - return EXIT_SUCCESS; - } - - spdlog::info( - "Successfully updated server from {} to {}", - result.current_version.to_string(), - result.available_version.to_string() - ); - return EXIT_SUCCESS; - } catch (const std::exception &e) { - spdlog::error("Error: {}", e.what()); - return EXIT_FAILURE; - } -} \ No newline at end of file diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..9e84aae --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,15 @@ +add_executable(tvcli tvcli.cc) +target_link_libraries(tvcli + PRIVATE + xvc + xdaqmetadata::xdaqmetadata + CLI11::CLI11 + PkgConfig::gstreamer-app + PkgConfig::gstreamer-video +) +target_compile_features(tvcli PRIVATE cxx_std_23) +target_compile_options(tvcli + PRIVATE + $<$:/W4> + $<$>:-Wall> +) \ No newline at end of file diff --git a/tools/tvcli.cc b/tools/tvcli.cc new file mode 100644 index 0000000..78e85f1 --- /dev/null +++ b/tools/tvcli.cc @@ -0,0 +1,289 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "camera.h" +#include "server.h" +#include "xdaqmetadata/safe_queue.h" +#include "xvc.h" + +namespace fs = std::filesystem; + +namespace +{ + +GMainLoop *loop = nullptr; +GstElement *pipeline = nullptr; +bool record = false; + +std::unique_ptr stream_cam = nullptr; +std::unique_ptr queue = nullptr; +std::chrono::steady_clock::time_point stream_duration; + +enum class Codec : int { MJPEG }; +enum class TimeUnit : int { Seconds, Minutes, Hours, Days }; + +GstFlowReturn draw_image(GstAppSink *sink, void *) +{ + std::unique_ptr sample( + gst_app_sink_pull_sample(sink), gst_sample_unref + ); + if (!sample) return GST_FLOW_OK; + + auto buffer = gst_sample_get_buffer(sample.get()); + std::unique_ptr video_info( + gst_video_info_new(), gst_video_info_free + ); + if (!gst_video_info_from_caps(video_info.get(), gst_sample_get_caps(sample.get()))) { + std::println("Failed to parse video info"); + return GST_FLOW_ERROR; + } + + const auto caps = gst_sample_get_caps(sample.get()); + const auto structure = gst_caps_get_structure(caps, 0); + const auto width = + static_cast(g_value_get_int(gst_structure_get_value(structure, "width"))); + const auto height = + static_cast(g_value_get_int(gst_structure_get_value(structure, "height"))); + const auto buffer_pts = GST_BUFFER_PTS(buffer); + + auto xdaqmetadata = queue->dequeue(buffer_pts); + if (!xdaqmetadata) { + std::println("Failed to dequeue XDAQ metadata from buffer with PTS {}", buffer_pts); + return GST_FLOW_OK; + } + + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast(now - stream_duration).count(); + + auto hours = elapsed / 3600; + auto minutes = (elapsed % 3600) / 60; + auto seconds = elapsed % 60; + + std::println( + "Received buffer: size={}, width={}, height={}, PTS={}, " + "fpga_timestamp={}, time={:02}:{:02}:{:02}", + gst_buffer_get_size(buffer), + width, + height, + buffer_pts, + xdaqmetadata->fpga_timestamp, + hours, + minutes, + seconds + ); + + return GST_FLOW_OK; +} + +void handle_sigint(int) +{ + std::println("SIGINT received, stopping stream..."); + if (record) { + xvc::stop_jpeg_recording(GST_PIPELINE(pipeline)); + } + if (stream_cam) { + stream_cam->stop(); + } + if (pipeline) { + gst_element_set_state(pipeline, GST_STATE_NULL); + } + if (loop) { + g_main_loop_quit(loop); + } +} + +} // namespace + +int func(int argc, char *argv[]) +{ + CLI::App app("Thor Vision CLI", "tvcli"); + argv = app.ensure_utf8(argv); + + std::string host = "192.168.177.100"; + int id; + std::string gst_cap; + Codec codec{Codec::MJPEG}; + std::unordered_map codec_map{{"mjpeg", Codec::MJPEG}}; + + std::string location = "records"; + auto split = false; + auto max_size_time = 10; + std::string log_file; + TimeUnit time_unit{TimeUnit::Seconds}; + std::unordered_map time_unit_map = { + {"seconds", TimeUnit::Seconds}, + {"minutes", TimeUnit::Minutes}, + {"hours", TimeUnit::Hours}, + {"days", TimeUnit::Days} + }; + + auto stream = app.add_subcommand("stream", "Stream camera"); + stream->add_option("--host", host, "Host computer that connected cameras") + ->default_val(host) + ->group("Stream"); + stream->add_option("-i,--id", id, "Camera device ID")->required()->group("Stream"); + stream->add_option("--cap", gst_cap, "Camera capability")->required()->group("Stream"); + stream->add_option("--codec", codec, "Camera codec") + ->required() + ->transform(CLI::CheckedTransformer(codec_map, CLI::ignore_case)) + ->group("Stream"); + + auto opt_record = + stream->add_flag("-r,--record", record, "Whether to record stream")->group("Record"); + auto opt_location = stream->add_option("--location", location, "Location to save record") + ->default_val(location) + ->group("Record"); + auto opt_split = stream->add_flag("-s,--split", split, "Enable split recording") + ->default_val(split) + ->group("Record"); + auto opt_max_size_time = + stream + ->add_option("--max-size-time", max_size_time, "Max recording time per file (seconds)") + ->default_val(10) + ->group("Split"); + auto opt_time_unit = + stream->add_option("--time-unit", time_unit, "Time unit for recording split size") + ->transform(CLI::CheckedTransformer(time_unit_map, CLI::ignore_case)) + ->group("Split"); + + opt_location->needs(opt_record); + opt_split->needs(opt_record); + opt_max_size_time->needs(opt_split); + opt_time_unit->needs(opt_split); + + auto list = app.add_subcommand("list", "List cameras"); + list->add_option("--host", host, "Host computer that connected cameras")->default_val(host); + + auto logs = app.add_subcommand("logs", "Show server logs"); + logs->add_option("--host", host, "Host computer that connected cameras")->default_val(host); + logs->add_option("-f,--file", log_file, "Log file to view"); + + CLI11_PARSE(app, argc, argv); + + signal(SIGINT, handle_sigint); + gst_init(&argc, &argv); + + if (*stream) { + queue = std::make_unique(); + pipeline = gst_pipeline_new(nullptr); + loop = g_main_loop_new(nullptr, false); + stream_duration = std::chrono::steady_clock::now(); + + for (auto &camera : Camera::cameras()) { + if (id == camera->id()) { + stream_cam = std::move(camera); + break; + } + } + if (!stream_cam) { + std::println("Error: no camera with id = {}", id); + return -1; + } + + auto caps = stream_cam->caps(); + auto it = std::find_if(caps.begin(), caps.end(), [gst_cap](const Camera::Cap &_cap) { + return _cap.to_string() == gst_cap; + }); + if (it == caps.end()) { + std::println("Error: Camera {} does not support cap '{}'", id, gst_cap); + return -1; + } + stream_cam->start(*it); + + std::chrono::seconds duration; + switch (time_unit) { + case TimeUnit::Seconds: duration = std::chrono::seconds(max_size_time); break; + case TimeUnit::Minutes: duration = std::chrono::minutes(max_size_time); break; + case TimeUnit::Hours: duration = std::chrono::hours(max_size_time); break; + case TimeUnit::Days: duration = std::chrono::days(max_size_time); break; + default: std::println("Invalid time unit specified."); return -1; + } + + auto uri = std::format("{}:{}", host, stream_cam->port()); + + if (codec == Codec::MJPEG) { + xvc::setup_jpeg_srt_stream(GST_PIPELINE(pipeline), uri); + } + + if (record) { + const auto &base = (location == "records") ? fs::path("records") : fs::path(location); + std::error_code ec; + if (!fs::exists(base) && !fs::create_directories(base, ec)) { + std::println( + "Error: cannot create directory '{}': {}", base.string(), ec.message() + ); + return -1; + } + auto filepath = base / stream_cam->name(); + xvc::RecordConfig config(filepath, split, duration); + xvc::start_jpeg_recording(GST_PIPELINE(pipeline), config); + } + + auto parser = gst_bin_get_by_name(GST_BIN(pipeline), "parser"); + std::unique_ptr src_pad( + gst_element_get_static_pad(parser, "src"), gst_object_unref + ); + gst_pad_add_probe( + src_pad.get(), GST_PAD_PROBE_TYPE_BUFFER, parse_jpeg_metadata, queue.get(), nullptr + ); + + GstAppSinkCallbacks callbacks = {nullptr, nullptr, draw_image, nullptr, nullptr, {nullptr}}; + auto appsink = gst_bin_get_by_name(GST_BIN(pipeline), "appsink"); + gst_app_sink_set_callbacks(GST_APP_SINK(appsink), &callbacks, nullptr, nullptr); + + if (gst_element_set_state(pipeline, GST_STATE_PLAYING) == GST_STATE_CHANGE_FAILURE) { + std::println("Unable to set the pipeline to the playing state"); + return -1; + } + + g_main_loop_run(loop); + + if (pipeline) { + gst_element_set_state(pipeline, GST_STATE_NULL); + } + stream_cam.reset(); + queue.reset(); + } + + if (*list) { + std::println("Discovered Cameras:"); + for (const auto &camera : Camera::cameras()) { + std::println(""); + std::println("Camera ID : {}", camera->id()); + std::println("Device ID : {}", camera->device_id()); + std::println("Name : {}", camera->name()); + + std::println("Capabilities:"); + for (const auto &cap : camera->caps()) { + std::println(" - {}", cap.to_string()); + } + } + } + + if (*logs) { + auto server = xvc::Server(); + if (auto logs = log_file.empty() ? server.logs() : server.logs(log_file)) { + std::println("{}", logs.value()); + } + } + + return 0; +} + +int main(int argc, char *argv[]) +{ +#if defined(__APPLE__) && TARGET_OS_MAC && !TARGET_OS_IPHONE + return gst_macos_main((GstMainFunc) func, argc, argv, nullptr); +#else + return func(argc, argv); +#endif +} \ No newline at end of file diff --git a/xdaqvc/CMakeLists.txt b/xdaqvc/CMakeLists.txt index 3e96eef..617b363 100644 --- a/xdaqvc/CMakeLists.txt +++ b/xdaqvc/CMakeLists.txt @@ -1,77 +1,53 @@ -add_library(libxvc STATIC) - -set(XVC_SOURCES - xvc.cc - camera.cc - port_pool.cc - ws_client.cc - server.cc - updater.cc -) -set(XVC_HEADERS - xvc.h - camera.h - port_pool.h - ws_client.h - server.h - updater.h -) - -target_sources(libxvc +target_sources(xvc PRIVATE - "${XVC_SOURCES}" + ${CMAKE_CURRENT_SOURCE_DIR}/xvc.cc + ${CMAKE_CURRENT_SOURCE_DIR}/camera.cc + ${CMAKE_CURRENT_SOURCE_DIR}/port_pool.cc + # ${CMAKE_CURRENT_SOURCE_DIR}/stream.cc + ${CMAKE_CURRENT_SOURCE_DIR}/ws_client.cc + ${CMAKE_CURRENT_SOURCE_DIR}/server.cc + ${CMAKE_CURRENT_SOURCE_DIR}/validator.cc + ${CMAKE_CURRENT_SOURCE_DIR}/port_pool.h + ${CMAKE_CURRENT_SOURCE_DIR}/validator.h PUBLIC FILE_SET "public_headers" TYPE "HEADERS" - FILES "${XVC_HEADERS}" -) -target_include_directories(libxvc - INTERFACE - "$" - "$" -) -set_target_properties(libxvc PROPERTIES - VERSION "${libxvc_VERSION}" - SOVERSION "${PROJECT_VERSION_MAJOR}" - POSITION_INDEPENDENT_CODE ON + FILES + ${CMAKE_CURRENT_SOURCE_DIR}/xvc.h + ${CMAKE_CURRENT_SOURCE_DIR}/camera.h + ${CMAKE_CURRENT_SOURCE_DIR}/server.h + # ${CMAKE_CURRENT_SOURCE_DIR}/stream.h + ${CMAKE_CURRENT_SOURCE_DIR}/common.h + ${CMAKE_CURRENT_SOURCE_DIR}/ws_client.h +) +set_target_properties(xvc + PROPERTIES + # TODO + INSTALL_RPATH "@executable_path/../Frameworks;@loader_path/../Frameworks" + # VERSION "${libxvc_VERSION}" + # SOVERSION "${PROJECT_VERSION_MAJOR}" + POSITION_INDEPENDENT_CODE ON + CXX_VISIBILITY_PRESET hidden ) -target_compile_features(libxvc PUBLIC cxx_std_20) -target_compile_options(libxvc +target_compile_features(xvc PUBLIC cxx_std_23) +target_compile_options(xvc PRIVATE $<$:/W4> - # $<$:/W4 /WX> $<$>:-Wall> - # $<$>:-Wall -Wextra -Wpedantic -Werror> ) -target_link_libraries(libxvc + +target_link_libraries(xvc PUBLIC - fmt::fmt + Boost::boost + PkgConfig::gstreamer + nlohmann_json::nlohmann_json + PRIVATE cpr::cpr spdlog::spdlog - nlohmann_json::nlohmann_json - PkgConfig::gstreamer - xdaqmetadata::xdaqmetadata - Boost::boost - OpenSSL::SSL + nlohmann_json_schema_validator ) -install( - TARGETS libxvc - EXPORT libxvc-targets - LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" - ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" - RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" - FILE_SET "public_headers" DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}/xdaqvc" -) -install( - EXPORT libxvc-targets - FILE libxvc-targets.cmake - NAMESPACE libxvc:: - DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libxvc" -) -export( - EXPORT libxvc-targets - FILE libxvc-config.cmake - NAMESPACE libxvc:: -) \ No newline at end of file +# if(BUILD_SHARED_LIBS) +# target_compile_definitions(xvc PRIVATE "xvc_EXPORTS") +# endif() \ No newline at end of file diff --git a/xdaqvc/camera.cc b/xdaqvc/camera.cc index b660aa2..0f35e91 100644 --- a/xdaqvc/camera.cc +++ b/xdaqvc/camera.cc @@ -4,106 +4,207 @@ #include #include +#include #include "port_pool.h" +#include "validator.h" using nlohmann::json; namespace { -auto constexpr Cameras = "192.168.177.100:8000/cameras"; -auto constexpr jpeg = "192.168.177.100:8000/jpeg"; -auto constexpr test = "192.168.177.100:8000/test"; -[[maybe_unused]] auto constexpr loopback = "127.0.0.1:8000/test"; -auto constexpr H265 = "192.168.177.100:8000/h265"; -auto constexpr Stop = "192.168.177.100:8000/stop"; -auto constexpr OK = 200; - -auto constexpr VIDEO_MJPEG = "image/jpeg"; -auto constexpr VIDEO_RAW = "video/x-raw"; +constexpr std::string_view URL = "http://192.168.177.100:8000"; +constexpr std::string_view CAMERAS = "/cameras"; +constexpr std::string_view MJPEG = "/jpeg"; +constexpr std::string_view H265 = "/h265"; +constexpr std::string_view STOP = "/stop"; +constexpr auto OK = 200; PortPool pool(9000, 9064); +std::optional get_json(std::string_view url, std::chrono::milliseconds timeout) +{ + if (url.empty()) { + spdlog::error("GET attempted with empty URL"); + return std::nullopt; + } + + auto res = cpr::Get(cpr::Url{url}, cpr::Timeout{timeout}); + if (res.status_code != OK) { + spdlog::warn( + "Failed to GET {} (status={}, error='{}')", url, res.status_code, res.error.message + ); + return std::nullopt; + } + + try { + return nlohmann::json::parse(res.text); + } catch (const std::exception &e) { + spdlog::error("JSON parse error from {}: {}", url, e.what()); + return std::nullopt; + } +}; + +cpr::Response post_json( + std::string_view url, const nlohmann::json &payload, std::chrono::milliseconds timeout +) +{ + if (url.empty()) { + spdlog::error("Attempted HTTP POST with empty URL"); + return {}; + } + + return cpr::Post( + cpr::Url{url}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{payload.dump(2)}, + cpr::Timeout{timeout} + ); +} + +void log(const cpr::Response &r, std::string_view action) +{ + if (r.status_code == OK) { + spdlog::info("{} succeeded", action); + } else { + spdlog::warn("{} failed. status={}, error='{}'", action, r.status_code, r.error.message); + } +} + +constexpr std::string url(std::string_view endpoint) { return std::format("{}{}", URL, endpoint); } + } // namespace -Camera::Camera(const int id, const std::string &name) - : _id(id), _name(name), _current_cap(""), _test(false) +Camera::Camera(int id, std::string device_id, std::string name) + : _id(id), _device_id(std::move(device_id)), _name(std::move(name)) { - auto port = pool.allocate_port(); - if (port) { - _port = port.value(); + auto port = pool.allocate(); + if (!port) { + spdlog::error("Failed to allocate port for camera id: {}", _id); + throw std::runtime_error("Failed to allocate port for camera"); } - spdlog::info("Creating camera id: {}, name: {}, port: {}", id, name, _port); + + _port = port.value(); + spdlog::debug( + "Creating camera id: {}, device_id: {}, name: {}, port: {}", _id, _device_id, _name, _port + ); } Camera::~Camera() { - pool.release_port(_port); - spdlog::info("Deleting camera id: {}, name: {}, port: {}", _id, _name, _port); + if (pool.release(_port)) { + spdlog::debug( + "Deleting camera id: {}, device_id: {}, name: {}, port: {}", + _id, + _device_id, + _name, + _port + ); + } +} + +std::unique_ptr Camera::parse(std::string_view camera_json) +{ + try { + auto json = json::parse(camera_json); + if (json.empty() || !json.is_object()) { + spdlog::error("Invalid camera JSON format"); + return nullptr; + } + if (auto validated = validate_camera(json); !validated) { + spdlog::error("Camera JSON validation failed: {}", validated.error()); + return nullptr; + } + + auto camera = std::make_unique( + json.at("id").get(), + json.at("device_id").get(), + json.at("name").get() + ); + + for (const auto &cap_json : json.at("caps")) { + Camera::Cap cap{ + .media_type = cap_json.at("media_type").get(), + .format = cap_json.value("format", ""), + .width = cap_json.at("width").get(), + .height = cap_json.at("height").get() + }; + + const auto &framerate = cap_json.at("framerate").get(); + auto slash = framerate.find('/'); + if (slash != std::string::npos) { + cap.fps_n = std::stoi(framerate.substr(0, slash)); + cap.fps_d = std::stoi(framerate.substr(slash + 1)); + } + + if (cap.media_type == "image/jpeg" || cap.media_type == "video/x-h265") { + camera->add_cap(cap); + } + } + return camera; + } catch (const std::exception &e) { + spdlog::error("Exception parsing camera JSON: {}", e.what()); + return nullptr; + } } -std::string Camera::cameras(const std::chrono::milliseconds duration) +std::vector> Camera::cameras(std::chrono::milliseconds duration) { - auto cameras = std::string(""); + std::vector> cameras; - auto response = cpr::Get(cpr::Url(Cameras), cpr::Timeout(duration)); - if (response.status_code == OK) { - cameras = json::parse(response.text).dump(2); + auto camera_json = get_json(url(CAMERAS), duration); + if (!camera_json || !camera_json->is_array()) { + spdlog::error("Invalid cameras format"); + return cameras; + } + cameras.reserve(camera_json->size()); + + for (const auto &json : *camera_json) { + if (auto validated = validate_camera(json); !validated) { + spdlog::error("Camera JSON validation failed: {}", validated.error()); + continue; + } + if (auto cam = parse(json.dump())) { + cameras.emplace_back(std::move(cam)); + } } return cameras; } -void Camera::start(const std::chrono::milliseconds duration) +bool Camera::start(const Cap &cap, std::chrono::milliseconds duration) { - json payload; - payload["id"] = _id; - payload["capability"] = _current_cap; - payload["port"] = _port; - cpr::Url url; - - if (_test) { - url = cpr::Url(test); - } else if (_current_cap.find(VIDEO_MJPEG) != std::string::npos || - _current_cap.find(VIDEO_RAW) != std::string::npos) { - url = cpr::Url(jpeg); + const nlohmann::json payload{{"id", _id}, {"capability", cap.to_string()}, {"port", _port}}; + + std::string _url; + if (cap.media_type == "image/jpeg") { + _url = url(MJPEG); + } else if (cap.media_type == "video/x-h265") { + _url = url(H265); } else { - // TODO: disable h265 for now - url = cpr::Url(H265); + spdlog::error("Unsupported codec for camera id: {}", _id); + return false; } - auto response = cpr::Post( - url, - cpr::Header{{"Content-Type", "application/json"}}, - cpr::Body(payload.dump(2)), - cpr::Timeout(duration) - ); - if (response.status_code == OK) { - spdlog::info( - "Successfully send http request to start camera: {} with id: {}, port: {}, cap: {}", - jpeg, - _id, - _port, - _current_cap - ); - } else { - spdlog::info("Failed to start camera"); + auto res = post_json(_url, payload, duration); + if (res.status_code != OK) { + log(res, std::format("Start camera id: {}", _id)); + return false; } + + log(res, std::format("Start camera id: {}", _id)); + return true; } -void Camera::stop(const std::chrono::milliseconds duration) +bool Camera::stop(std::chrono::milliseconds duration) { - json payload; - payload["id"] = _id; + const nlohmann::json payload{{"id", _id}}; - auto response = cpr::Post( - cpr::Url(Stop), - cpr::Header{{"Content-Type", "application/json"}}, - cpr::Body(payload.dump(2)), - cpr::Timeout(duration) - ); - if (response.status_code == OK) { - spdlog::info("Successfully send http request to stop camera: {} with id: {}", Stop, _id); - } else { - spdlog::info("Failed to stop camera"); + auto res = post_json(url(STOP), payload, duration); + if (res.status_code != OK) { + log(res, std::format("Stop camera id: {}", _id)); + return false; } -} + + log(res, std::format("Stop camera id: {}", _id)); + return true; +} \ No newline at end of file diff --git a/xdaqvc/camera.h b/xdaqvc/camera.h index 3692485..b7979d6 100644 --- a/xdaqvc/camera.h +++ b/xdaqvc/camera.h @@ -1,49 +1,69 @@ #pragma once #include +#include +#include #include #include - -using namespace std::chrono_literals; - - class Camera { public: struct Cap { std::string media_type; - std::string format; + std::optional format = std::nullopt; int width; int height; int fps_n; int fps_d; + + constexpr std::string to_string() const noexcept + { + if (format.has_value() && !format.value().empty()) { + return std::format( + "{},format={},width={},height={},framerate={}/{}", + media_type, + format.value(), + width, + height, + fps_n, + fps_d + ); + } else { + return std::format( + "{},width={},height={},framerate={}/{}", media_type, width, height, fps_n, fps_d + ); + } + } }; - Camera(const int id, const std::string &name); + explicit Camera(int id, std::string device_id, std::string name); ~Camera(); - [[nodiscard]] static std::string cameras(const std::chrono::milliseconds duration = 500ms); - [[nodiscard]] std::string name() const { return _name; }; - [[nodiscard]] std::vector caps() const { return _caps; }; - [[nodiscard]] unsigned short port() const { return _port; }; - [[nodiscard]] int id() const { return _id; } - [[nodiscard]] std::string current_cap() const { return _current_cap; }; + // TODO: Camera::parse is used by xvc::ws_client function pointer and Camera::cameras + // but it should be made private. + [[nodiscard]] static std::unique_ptr parse(std::string_view camera_json); + [[nodiscard]] static std::vector> cameras( + std::chrono::milliseconds timeout = std::chrono::milliseconds(1000) + ); + [[nodiscard]] int id() const noexcept { return _id; } + [[nodiscard]] const std::string &device_id() const noexcept { return _device_id; } + [[nodiscard]] const std::string &name() const noexcept { return _name; } + [[nodiscard]] unsigned short port() const noexcept { return _port; } + [[nodiscard]] const std::vector &caps() const noexcept { return _caps; } - void set_current_cap(const std::string &cap) { _current_cap = cap; } + void set_name(std::string_view name) { _name = name; } void add_cap(const Cap &cap) { _caps.emplace_back(cap); } - void start(const std::chrono::milliseconds duration = 500ms); - void stop(const std::chrono::milliseconds duration = 500ms); - - void set_test(const bool test) { _test = test; }; - [[nodiscard]] bool test_mode() const { return _test; }; + bool start( + const Cap &cap, std::chrono::milliseconds duration = std::chrono::milliseconds(1000) + ); + bool stop(std::chrono::milliseconds duration = std::chrono::milliseconds(1000)); private: int _id; + std::string _device_id; unsigned short _port; std::string _name; std::vector _caps; - std::string _current_cap; - bool _test; }; \ No newline at end of file diff --git a/xdaqvc/common.h b/xdaqvc/common.h new file mode 100644 index 0000000..fd300ce --- /dev/null +++ b/xdaqvc/common.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include + +namespace xvc +{ + +class Version +{ +public: + constexpr Version() noexcept : _major(0), _minor(0), _patch(0) {} + constexpr Version(int major, int minor, int patch) noexcept + : _major(major), _minor(minor), _patch(patch) + { + } + + constexpr bool operator==(const Version &other) const noexcept + { + return _major == other._major && _minor == other._minor && _patch == other._patch; + } + constexpr bool operator>(const Version &other) const noexcept + { + return !(*this < other || *this == other); + } + constexpr bool operator<(const Version &other) const noexcept + { + if (_major != other._major) return _major < other._major; + if (_minor != other._minor) return _minor < other._minor; + return _patch < other._patch; + } + constexpr bool operator>=(const Version &other) const noexcept { return !(*this < other); } + constexpr bool operator<=(const Version &other) const noexcept + { + return (*this < other) || (*this == other); + }; + + // TODO + [[nodiscard]] static std::optional from_string(std::string_view version) + { + try { + std::regex regex(R"((\d+)\.(\d+)\.(\d+))"); + std::smatch matches; + std::string version_str(version); + + if (std::regex_match(version_str, matches, regex)) { + return Version{ + std::stoi(matches[1].str()), + std::stoi(matches[2].str()), + std::stoi(matches[3].str()) + }; + } + return std::nullopt; + } catch (...) { + return std::nullopt; + } + }; + + [[nodiscard]] constexpr std::string to_string() const noexcept + { + return std::format("{}.{}.{}", _major, _minor, _patch); + }; + +private: + int _major; + int _minor; + int _patch; +}; + +} // namespace xvc \ No newline at end of file diff --git a/xdaqvc/port_pool.cc b/xdaqvc/port_pool.cc index c30ce43..7034611 100644 --- a/xdaqvc/port_pool.cc +++ b/xdaqvc/port_pool.cc @@ -2,7 +2,6 @@ #include -#include #include PortPool::PortPool(Port start, Port end) : _start(start), _end(end) @@ -11,101 +10,100 @@ PortPool::PortPool(Port start, Port end) : _start(start), _end(end) throw std::invalid_argument("Invalid port range"); } - for (auto port = start; port < end; ++port) { + _available_ports.reserve(_end - _start); + for (auto port = _start; port < _end; ++port) { _available_ports.insert(port); + _shuffled_ports.push_back(port); } + + std::random_device rd; + std::mt19937 gen(rd()); + std::shuffle(_shuffled_ports.begin(), _shuffled_ports.end(), gen); } PortPool::~PortPool() { for (auto &[port, acceptor] : _bound_ports) { boost::system::error_code ec; - acceptor->close(ec); + auto _ = acceptor->close(ec); if (ec) { - spdlog::warn("Failed to close acceptor on port {}: {}", port, ec.message()); + spdlog::error("Failed to close acceptor on port {}: {}", port, ec.message()); } } + _bound_ports.clear(); } -std::optional PortPool::allocate_port() +std::optional PortPool::allocate() { if (_available_ports.empty()) { spdlog::warn("No available ports"); return std::nullopt; } - std::vector ports(_available_ports.begin(), _available_ports.end()); - - std::random_device rd; - std::mt19937 gen(rd()); - std::shuffle(ports.begin(), ports.end(), gen); - - for (auto port : ports) { - boost::system::error_code ec; - - auto acceptor = std::make_shared(_io_context); - acceptor->open(boost::asio::ip::tcp::v4(), ec); - if (ec) { - spdlog::debug("Failed to open acceptor on port {}: {}", port, ec.message()); - continue; - } - - acceptor->set_option(boost::asio::ip::tcp::acceptor::reuse_address(true), ec); - if (ec) { - spdlog::debug("Failed to set reuse_address on port {}: {}", port, ec.message()); + for (auto port : _shuffled_ports) { + if (!_available_ports.contains(port)) { continue; } - - auto _ = acceptor->bind({boost::asio::ip::tcp::v4(), port}, ec); - if (ec == boost::asio::error::address_in_use) { - spdlog::debug("Failed to bind port {}: {}", port, ec.message()); - continue; + if (try_bind(port)) { + _available_ports.erase(port); + spdlog::debug("Allocated port {}", port); + return port; } - - _bound_ports[port] = acceptor; - _available_ports.erase(port); - - spdlog::info("Allocated port {}", port); - return port; } - - spdlog::warn("No ports could be bound successfully"); + spdlog::error("No ports could be bound"); return std::nullopt; } -void PortPool::release_port(Port port) +bool PortPool::release(Port port) { - // if (_start <= port && port < _end) { - // _available_ports.insert(port); - // _bound_ports.erase(port); - // fmt::println("Released and unbound port {}", port); - // } if (port < _start || port >= _end) { - spdlog::warn("Attempted to release port {} outside of range", port); - return; + spdlog::warn("Failed to release port {}: outside of range", port); + return false; } auto it = _bound_ports.find(port); - if (it != _bound_ports.end()) { - boost::system::error_code ec; - it->second->close(ec); - if (ec) { - spdlog::warn("Failed to close acceptor on port {}: {}", port, ec.message()); - } - - _bound_ports.erase(it); + if (it == _bound_ports.end()) { + spdlog::warn("Failed to release port {}: not allocated", port); + return false; } + boost::system::error_code ec; + auto _ = it->second->close(ec); + if (ec) { + spdlog::error("Failed to close acceptor on port {}: {}", port, ec.message()); + } + _bound_ports.erase(it); _available_ports.insert(port); - spdlog::info("Released port {}", port); - // fmt::println("Port {} is not in the valid range", port); + + spdlog::debug( + "Released port {} ({}/{} ports in use)", port, _bound_ports.size(), _end - _start + ); + return true; } -void PortPool::print_available_ports() const +bool PortPool::try_bind(Port port) { - std::string ports; - for (const auto port : _available_ports) { - ports += std::to_string(port) + " "; + boost::system::error_code ec; + auto acceptor = std::make_unique(_io_context); + + auto _ = acceptor->open(tcp::v4(), ec); + if (ec) { + spdlog::error("Failed to open acceptor on port {}: {}", port, ec.message()); + return false; + } + + _ = acceptor->set_option(Acceptor::reuse_address(true), ec); + if (ec) { + spdlog::error("Failed to set reuse_address option on port {}: {}", port, ec.message()); + return false; } - spdlog::info("Available Ports: {}", ports); + + _ = acceptor->bind({tcp::v4(), port}, ec); + if (ec == boost::asio::error::address_in_use) { + spdlog::error("Failed to bind port {}: {}", port, ec.message()); + return false; + } + + _bound_ports.emplace(port, std::move(acceptor)); + return true; } \ No newline at end of file diff --git a/xdaqvc/port_pool.h b/xdaqvc/port_pool.h index bd6275a..387f20d 100644 --- a/xdaqvc/port_pool.h +++ b/xdaqvc/port_pool.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -10,17 +11,34 @@ class PortPool public: using Port = unsigned short; + // Range: [start, end) explicit PortPool(Port start, Port end); ~PortPool(); - [[nodiscard]] std::optional allocate_port(); - void release_port(Port port); - void print_available_ports() const; + PortPool(const PortPool &) = delete; + PortPool(PortPool &&) = delete; + PortPool &operator=(const PortPool &) = delete; + PortPool &operator=(PortPool &&) = delete; + + [[nodiscard]] std::optional allocate(); + bool release(Port port); + + [[nodiscard]] const std::unordered_set &available_ports() const noexcept + { + return _available_ports; + }; private: - std::unordered_map> _bound_ports; + using tcp = boost::asio::ip::tcp; + using Acceptor = tcp::acceptor; + std::unordered_map> _bound_ports; std::unordered_set _available_ports; - Port _start, _end; + std::vector _shuffled_ports; + + bool try_bind(Port port); + + Port _start; + Port _end; boost::asio::io_context _io_context; }; diff --git a/xdaqvc/server.cc b/xdaqvc/server.cc index a5185a3..26445d8 100644 --- a/xdaqvc/server.cc +++ b/xdaqvc/server.cc @@ -1,147 +1,74 @@ #include "server.h" #include -#include #include +#include #include -using json = nlohmann::json; - namespace { -auto constexpr OK = 200; +constexpr int OK = 200; } // namespace namespace xvc { -Version::Version(const std::string &version_str) : major(0), minor(0), patch(0) -{ - std::istringstream ss(version_str); - std::string token; - std::vector parts; - - while (std::getline(ss, token, '.')) { - try { - parts.push_back(std::stoi(token)); - } catch (const std::invalid_argument &) { - throw std::invalid_argument("Invalid version format: " + version_str); - } - } - - if (parts.size() != 3) { - throw std::invalid_argument( - "Version must have exactly three components (major.minor.patch): " + version_str - ); - } - - major = parts[0]; - minor = parts[1]; - patch = parts[2]; -} - -bool Version::operator==(const Version &other) const -{ - return major == other.major && minor == other.minor && patch == other.patch; -} - -bool Version::operator<(const Version &other) const +Server::Server(std::string_view host, int port) noexcept + : _base_url(std::format("http://{}:{}", host, port)) { - if (major != other.major) return major < other.major; - if (minor != other.minor) return minor < other.minor; - return patch < other.patch; } -bool Version::operator>(const Version &other) const { return other < *this; } - -std::string Version::to_string() const { return fmt::format("{}.{}.{}", major, minor, patch); } - -Server::Server(const std::string &host, int port) - : _base_url(fmt::format("http://{}:{}", host, port)) -{ -} - -Status Server::status(const std::chrono::milliseconds timeout) const +bool Server::root(std::chrono::milliseconds timeout) const { auto response = cpr::Get(cpr::Url{_base_url}, cpr::Timeout{timeout}); - - // cpr::Session session; - // session.SetUrl(url); - // session.SetTimeout(_timeout); - // auto response = session.Get(); - - return (response.status_code == OK) ? Status::ON : Status::OFF; + if (response.status_code != OK) { + spdlog::debug("Failed to fetch {} Status: {}", _base_url, response.status_code); + return false; + } + return true; } -std::string Server::logs(const std::string &filename, const std::chrono::milliseconds timeout) const +std::optional Server::logs( + std::string_view filename, std::chrono::milliseconds timeout +) const { - auto url = fmt::format("{}/{}", _base_url, "logs"); - if (!filename.empty()) { - url += "/" + filename; - } - spdlog::debug("Fetching logs from = {}", url); + const auto logs = filename.empty(); + const auto &url = logs ? std::format("{}/{}", _base_url, "logs") + : std::format("{}/{}/{}", _base_url, "logs", filename); auto response = cpr::Get(cpr::Url{url}, cpr::Timeout{timeout}); if (response.status_code != OK) { - spdlog::error("Failed to fetch logs. Status: {}", response.status_code); - return ""; + spdlog::debug("Failed to fetch {} Status: {}", url, response.status_code); + return std::nullopt; } - - return response.text; + return logs ? nlohmann::json::parse(response.text).dump(2) : response.text; } -std::optional Server::get_api_version(const std::chrono::milliseconds timeout) const +std::optional Server::api_version(std::chrono::milliseconds timeout) const { - auto url = fmt::format("{}/{}", _base_url, "version"); - auto response = cpr::Get(cpr::Url{url}, cpr::Timeout{timeout}); + const auto &url = std::format("{}/{}", _base_url, "api_version"); + auto response = cpr::Get(cpr::Url{url}, cpr::Timeout{timeout}); if (response.status_code != OK) { - spdlog::warn("Failed to fetch API version. Status: {}", response.status_code); + spdlog::debug("Failed to fetch {} Status: {}", url, response.status_code); return std::nullopt; } - return response.text; -} -bool Server::update(const Version &update_version, const std::chrono::milliseconds timeout) const -{ - auto api_version = get_api_version(); - if (!api_version) { - spdlog::error("Failed to get server API version."); - return false; - } - - Version current_version{json::parse(api_version.value())["version"].get()}; - - if (current_version == update_version) { - spdlog::info( - "Skip update: current version {} = update version {}", - current_version.to_string(), - update_version.to_string() - ); - return true; + auto text = response.text; + try { + auto json = nlohmann::json::parse(text); + auto version_str = json.at("version").get(); + auto version = Version::from_string(version_str); + if (!version) { + spdlog::error("Invalid API version format: {}", text); + return std::nullopt; + } + return version.value(); + } catch (const nlohmann::json::parse_error &e) { + spdlog::error("Failed to parse {} response as JSON: {}", url, text); + return std::nullopt; } - - // TODO - // json payload = {{"version", update_version.to_string()}}; - - // auto url = fmt::format("{}/{}", _base_url, "update"); - - // auto response = cpr::Post( - // cpr::Url{url}, - // cpr::Header{{"Content-Type", "application/json"}}, - // cpr::Body{payload.dump()}, - // cpr::Timeout{timeout} - // ); - - // if (response.status_code != OK) { - // spdlog::error("Update request failed: {} - {}", response.status_code, response.text); - // return false; - // } - // - - spdlog::info("API version updated successfully."); - return true; } } // namespace xvc \ No newline at end of file diff --git a/xdaqvc/server.h b/xdaqvc/server.h index 52005bd..a372adc 100644 --- a/xdaqvc/server.h +++ b/xdaqvc/server.h @@ -3,44 +3,28 @@ #include #include #include +#include -using namespace std::chrono_literals; +#include "common.h" namespace xvc { -enum class Status { OFF, ON }; - -class Version -{ -public: - Version(const std::string &version_str); - - bool operator==(const Version &other) const; - bool operator<(const Version &other) const; - bool operator>(const Version &other) const; - - std::string to_string() const; - -private: - int major; - int minor; - int patch; -}; - class Server { public: - Server(const std::string &host = "192.168.177.100", int port = 8000); + explicit Server(std::string_view host = "192.168.177.100", int port = 8000) noexcept; - Status status(const std::chrono::milliseconds timeout = 500ms) const; - std::string logs( - const std::string &filename = "", const std::chrono::milliseconds timeout = 500ms + bool root(std::chrono::milliseconds timeout = std::chrono::milliseconds(1000)) const; + + std::optional logs( + std::string_view filename = "", + std::chrono::milliseconds timeout = std::chrono::milliseconds(1000) ) const; - std::optional get_api_version(const std::chrono::milliseconds timeout = 500ms) - const; - bool update(const Version &version, const std::chrono::milliseconds timeout = 500ms) const; + std::optional api_version( + std::chrono::milliseconds timeout = std::chrono::milliseconds(1000) + ) const; private: std::string _base_url; diff --git a/xdaqvc/updater.cc b/xdaqvc/updater.cc deleted file mode 100644 index fac99f3..0000000 --- a/xdaqvc/updater.cc +++ /dev/null @@ -1,689 +0,0 @@ -#include "updater.h" - -#include -#include -#include - -#include -#include -#include -#include - - -using namespace std::chrono_literals; - - -namespace -{ -auto constexpr OK = 200; - -// size_t write_data(void *ptr, size_t size, size_t nmemb, FILE *stream) -// { -// return fwrite(ptr, size, nmemb, stream); -// } - -// std::string bytes_to_hex(const unsigned char *bytes, size_t len) -// { -// std::stringstream ss; -// ss << std::hex << std::setfill('0'); -// for (size_t i = 0; i < len; i++) { -// ss << std::setw(2) << static_cast(bytes[i]); -// } -// return ss.str(); -// } - -// bool handle_response(const cpr::Response &response) -// { -// if (response.status_code == OK) { -// auto json_response = nlohmann::json::parse(response.text); -// return json_response["status"] == "success"; -// } - -// spdlog::error( -// "File transfer failed with status code: {} ({})", response.status_code, response.text -// ); -// return false; -// } -} // namespace - - -namespace xvc -{ -std::optional calculate_sha256(const fs::path &filepath) -{ - std::ifstream file(filepath, std::ios::binary); - if (!file) { - spdlog::error("Failed to open file for hashing: {}", filepath.string()); - return std::nullopt; - } - - SHA256_CTX sha256; - SHA256_Init(&sha256); - - char buffer[4096]; - while (file.read(buffer, sizeof(buffer))) { - SHA256_Update(&sha256, buffer, file.gcount()); - } - if (file.gcount() > 0) { - SHA256_Update(&sha256, buffer, file.gcount()); - } - - unsigned char hash[SHA256_DIGEST_LENGTH]; - SHA256_Final(hash, &sha256); - - std::stringstream ss; - ss << std::hex << std::setfill('0'); - for (size_t i = 0; i < SHA256_DIGEST_LENGTH; i++) { - ss << std::setw(2) << static_cast(hash[i]); - } - - auto calculated = ss.str(); - spdlog::info("Calculated hash: {}", calculated); - return calculated; -} - -DownloadResult download_and_verify( - const std::string &url, const std::string &expected_hash, const fs::path &output_path -) -{ - DownloadResult result{false, ""}; - constexpr auto MAX_RETRIES = 3; - constexpr std::chrono::seconds TIMEOUT{30}; - - try { - // First, make a HEAD request to get the expected file size - auto head_response = cpr::Head(cpr::Url{url}, cpr::VerifySsl{false}, cpr::Timeout{2s}); - if (head_response.status_code != OK) { - result.error_message = - fmt::format("Failed to get file information: {}", head_response.status_code); - return result; - } - - // Get expected file size from header - size_t expected_size = 0; - if (head_response.header.count("Content-Length") > 0) { - expected_size = std::stoull(head_response.header["Content-Length"]); - } - - // Retry loop - for (int attempt = 1; attempt <= MAX_RETRIES; ++attempt) { - std::ofstream file(output_path, std::ios::binary); - if (!file) { - result.error_message = "Failed to open output file for writing"; - return result; - } - - // Track last reported progress - size_t last_progress = 0; - - // Progress callback - auto progress_callback = [&last_progress]( - size_t downloadTotal, - size_t downloadNow, - [[maybe_unused]] size_t uploadTotal, - [[maybe_unused]] size_t uploadNow, - [[maybe_unused]] intptr_t userdata - ) -> bool { - if (downloadTotal > 0) { - float progress_percentage = - static_cast(downloadNow) / downloadTotal * 100.0f; - if (downloadNow != last_progress) { - last_progress = downloadNow; - spdlog::info( - "Download progress: {:.1f}% ({}/{} bytes)", - progress_percentage, - downloadNow, - downloadTotal - ); - } - } - return true; // Continue transfer - }; - - // Perform the download - auto response = cpr::Download( - file, - cpr::Url{url}, - cpr::VerifySsl{false}, - cpr::Timeout{TIMEOUT}, - cpr::ProgressCallback(progress_callback) - ); - - file.close(); - - if (response.status_code != OK) { - spdlog::warn( - "Download attempt {} failed with status code: {}. Retrying...", - attempt, - response.status_code - ); - std::this_thread::sleep_for(std::chrono::seconds(attempt)); - continue; - } - - // Verify file size - if (expected_size > 0) { - auto actual_size = fs::file_size(output_path); - if (actual_size != expected_size) { - spdlog::warn( - "File size mismatch. Expected: {}, Got: {}. Retrying...", - expected_size, - actual_size - ); - fs::remove(output_path); - continue; - } - } - - // Calculate and verify hash - auto calculated_hash = calculate_sha256(output_path); - if (!calculated_hash) { - spdlog::warn("Failed to calculate file hash. Retrying..."); - fs::remove(output_path); - continue; - } - - if (*calculated_hash != expected_hash) { - spdlog::warn( - "Hash verification failed. Expected: {}, Got: {}. Retrying...", - expected_hash, - *calculated_hash - ); - fs::remove(output_path); - continue; - } - - // If we get here, all verifications passed - result.success = true; - return result; - } - - // If retries are exhausted - result.error_message = "Failed to download file after multiple attempts."; - return result; - - } catch (const std::exception &e) { - result.error_message = fmt::format("Download failed: {}", e.what()); - if (fs::exists(output_path)) { - fs::remove(output_path); - } - return result; - } -} - - -HandshakeResponse perform_handshake(const std::string &server_address, int port) -{ - HandshakeResponse response{false, "", "", {}}; - - try { - auto url = fmt::format("http://{}:{}/handshake", server_address, port); - - spdlog::info("Attempting handshake with server at {}", url); - - auto http_response = - cpr::Get(cpr::Url{url}, cpr::Timeout{2s}, cpr::Header{{"User-Agent", "XVC-Client"}}); - - if (http_response.status_code == OK) { - try { - auto json_response = nlohmann::json::parse(http_response.text); - spdlog::debug("Raw server response: {}", http_response.text); - - if (json_response["status"] == "ready") { - response.success = true; - response.token = json_response["token"].get(); - - // Get current UTC time - auto now = std::chrono::system_clock::now(); - auto now_ts = std::chrono::system_clock::to_time_t(now); - - // Get expiration time from server (as UTC timestamp) - auto expire_timestamp = json_response["expires"].get(); - response.expires = std::chrono::system_clock::from_time_t(expire_timestamp); - - auto expire_time_t = static_cast(expire_timestamp); - - // Format times in UTC - std::tm now_tm_utc{}, expire_tm_utc{}; -#ifdef _WIN32 - gmtime_s(&now_tm_utc, &now_ts); - gmtime_s(&expire_tm_utc, &expire_timestamp); -#else - gmtime_r(&now_ts, &now_tm_utc); - gmtime_r(&expire_time_t, &expire_tm_utc); -#endif - char now_str[32], expire_str[32]; - std::strftime(now_str, sizeof(now_str), "%Y-%m-%d %H:%M:%S UTC", &now_tm_utc); - std::strftime( - expire_str, sizeof(expire_str), "%Y-%m-%d %H:%M:%S UTC", &expire_tm_utc - ); - - spdlog::info("Handshake successful!"); - spdlog::info("Current UTC time: {}", now_str); - spdlog::info("Current UTC timestamp: {}", now_ts); - spdlog::info("Session token: {}", response.token); - spdlog::info("Token expires (UTC): {}", expire_str); - spdlog::info("Expire UTC timestamp: {}", expire_timestamp); - spdlog::info("Time until expiration: {} seconds", expire_timestamp - now_ts); - } - } catch (const nlohmann::json::exception &e) { - response.error_message = - fmt::format("Invalid handshake response format: {}", e.what()); - spdlog::error("JSON parse error: {}", e.what()); - } - } else { - response.error_message = - fmt::format("Handshake failed with status code: {}", http_response.status_code); - spdlog::error("HTTP error: {}", response.error_message); - } - - } catch (const std::exception &e) { - response.error_message = fmt::format("Handshake failed with exception: {}", e.what()); - spdlog::error("Exception: {}", e.what()); - } - - return response; -} - -bool prepare_file_transfer( - const std::string &server_address, int port, const std::string &token, - const std::string &filename, const std::string &file_hash, size_t file_size, - std::string &out_transfer_id -) -{ - try { - auto url = fmt::format("http://{}:{}/prepare-transfer", server_address, port); - - nlohmann::json request_body = { - {"filename", filename}, {"file_hash", file_hash}, {"file_size", file_size} - }; - - auto response = cpr::Post( - cpr::Url{url}, - cpr::Header{ - {"Authorization", fmt::format("Bearer {}", token)}, - {"Content-Type", "application/json"} - }, - cpr::Body{request_body.dump()}, - cpr::Timeout{5s} - ); - - if (response.status_code == OK) { - auto json_response = nlohmann::json::parse(response.text); - if (json_response["status"] == "ready") { - out_transfer_id = json_response["transfer_id"]; - return true; - } - } - - spdlog::error("Failed to prepare transfer: {} ({})", response.text, response.status_code); - return false; - - } catch (const std::exception &e) { - spdlog::error("Error preparing transfer: {}", e.what()); - return false; - } -} - -bool transfer_file( - const std::string &server_address, int port, const std::string &token, - const fs::path &file_path, const std::string &transfer_id, - std::function progress_callback -) -{ - auto timeout = std::chrono::seconds{30}; - try { - if (!fs::exists(file_path)) { - spdlog::error("File does not exist: {}", file_path.string()); - return false; - } - if (!fs::is_regular_file(file_path)) { - spdlog::error("Invalid file type: {}", file_path.string()); - return false; - } - - auto file_size = fs::file_size(file_path); - if (file_size == 0) { - spdlog::error("File is empty: {}", file_path.string()); - return false; - } - - auto url = fmt::format("http://{}:{}/transfer/{}", server_address, port, transfer_id); - - cpr::Multipart multipart{}; - multipart.parts.emplace_back("file", cpr::File{file_path.string()}); - - cpr::Header headers = {{"Authorization", fmt::format("Bearer {}", token)}}; - - // Deduplication logic in progress callback - auto progress_callback_wrapper = [&progress_callback, file_size]( - size_t, size_t, size_t, size_t ul_now, intptr_t - ) -> bool { - static size_t last_progress = 0; - auto actual_progress = std::clamp(ul_now, size_t{0}, file_size); - - if (actual_progress != last_progress) { // Only log/report if progress changes - last_progress = actual_progress; - if (progress_callback) { - progress_callback( - {actual_progress, - file_size, - file_size > 0 ? static_cast(actual_progress) / file_size * 100.0f - : 0.0f} - ); - } - } - return true; // Continue transfer - }; - - auto response = cpr::Post( - cpr::Url{url}, - headers, - multipart, - progress_callback ? cpr::ProgressCallback(progress_callback_wrapper) - : cpr::ProgressCallback{}, - cpr::Timeout{timeout} - ); - - if (response.status_code == OK) { - auto json_response = nlohmann::json::parse(response.text); - return json_response["status"] == "success"; - } else { - spdlog::error( - "File transfer failed with status code: {} ({})", - response.status_code, - response.text - ); - return false; - } - - } catch (const std::exception &e) { - spdlog::error( - "File transfer failed for file '{}' (transfer_id: {}): {}", - file_path.string(), - transfer_id, - e.what() - ); - return false; - } -} - - -std::optional get_server_version(const std::string &server_address, int port) -{ - try { - auto response = cpr::Get( - cpr::Url{fmt::format("http://{}:{}/server_version", server_address, port)}, - cpr::Timeout{5s} - ); - - if (response.status_code == OK) { - // Parse JSON response - auto json_response = nlohmann::json::parse(response.text); - - // Extract version string from JSON - if (json_response.contains("version")) { - return Version::from_string(json_response["version"].get()); - } - - spdlog::error("Server response missing version field: {}", response.text); - return std::nullopt; - } - - spdlog::error("Failed to get server version: {} ({})", response.text, response.status_code); - return std::nullopt; - - } catch (const std::exception &e) { - spdlog::error("Error getting server version: {}", e.what()); - return std::nullopt; - } -} - -std::optional get_version_table(const std::string &table_url) -{ - try { - auto response = cpr::Get( - cpr::Url{table_url}, cpr::Timeout{5s}, cpr::VerifySsl{false} - // Add this if needed for self-signed certs - ); - - if (response.status_code == OK) { - auto json = nlohmann::json::parse(response.text); - VersionTable table; - - // Parse latest version - auto latest_ver = Version::from_string(json["latest_version"].get()); - if (!latest_ver) { - spdlog::error("Invalid latest version format"); - return std::nullopt; - } - table.latest_version = *latest_ver; - - // Parse version entries - for (const auto &version_data : json["versions"]) { - UpdateInfo info; - - // Parse version - auto ver = Version::from_string(version_data["version"].get()); - if (!ver) { - spdlog::error("Invalid version format in version entry"); - continue; - } - info.version = *ver; - - // Parse other fields - info.release_date = version_data["release_date"].get(); - info.update_url = version_data["update_url"].get(); - info.hash = version_data["hash"].get(); - - auto min_ver = - Version::from_string(version_data["min_client_version"].get()); - if (!min_ver) { - spdlog::error("Invalid min_client_version format"); - continue; - } - info.min_client_version = *min_ver; - - info.description = version_data["description"].get(); - - table.versions.push_back(info); - } - - return table; - } - - spdlog::error("Failed to get version table: {} ({})", response.text, response.status_code); - return std::nullopt; - - } catch (const std::exception &e) { - spdlog::error("Error getting version table: {}", e.what()); - return std::nullopt; - } -} - -UpdateResult update_server( - const std::string &server_address, - int server_port, // Port of the server to be updated - int update_server_port, // Port of the update server - const std::string &table_url, const fs::path &update_dir, - [[maybe_unused]] const Version &client_version, bool skip_version_check, - const std::optional &force_version -) -{ - UpdateResult result; - result.success = false; - - try { - // Step 1: Version check (unless skipped) - - auto current_version = get_server_version(server_address, server_port); - if (!current_version) { - result.error_message = "Failed to get current server version"; - return result; - } - result.current_version = *current_version; - - - // Step 2: Get version table - auto version_table = get_version_table(table_url); - if (!version_table) { - result.error_message = "Failed to get version table"; - return result; - } - - // If force_version is specified, override the target version - if (force_version) { - auto it = std::find_if( - version_table->versions.begin(), - version_table->versions.end(), - [&](const UpdateInfo &info) { return info.version == *force_version; } - ); - - if (it == version_table->versions.end()) { - result.error_message = fmt::format( - "Forced version {} not found in version table", force_version->to_string() - ); - return result; - } - version_table->latest_version = *force_version; - } - - result.available_version = version_table->latest_version; - - // Check if update is needed (unless forced) - if (!skip_version_check && result.current_version >= version_table->latest_version) { - result.update_needed = false; - result.success = true; - return result; - } - - result.update_needed = true; - - // Step 3: Download and verify update file - auto target_version = std::find_if( - version_table->versions.begin(), - version_table->versions.end(), - [&](const UpdateInfo &info) { return info.version == version_table->latest_version; } - ); - - if (target_version == version_table->versions.end()) { - result.error_message = "Target version not found in version table"; - return result; - } - - // Create update directory if it doesn't exist - fs::create_directories(update_dir); - - auto update_file = - update_dir / fmt::format("xvc-server-{}.tar.xz", target_version->version.to_string()); - - spdlog::info("Downloading update file from {}", target_version->update_url); - auto download_result = - download_and_verify(target_version->update_url, target_version->hash, update_file); - - if (!download_result.success) { - result.error_message = - fmt::format("Failed to download update: {}", download_result.error_message); - return result; - } - - // Step 4: Perform handshake with update server - spdlog::info("Performing handshake with update server"); - auto handshake_response = perform_handshake(server_address, update_server_port); - if (!handshake_response.success) { - result.error_message = - fmt::format("Handshake failed: {}", handshake_response.error_message); - return result; - } - - // Step 5: Prepare file transfer - std::string transfer_id; - auto file_size = fs::file_size(update_file); - - spdlog::info("Preparing file transfer"); - bool prepared = prepare_file_transfer( - server_address, - update_server_port, - handshake_response.token, - update_file.filename().string(), - target_version->hash, - file_size, - transfer_id - ); - - if (!prepared) { - result.error_message = "Failed to prepare file transfer"; - return result; - } - - // Step 6: Perform file transfer - spdlog::info("Transferring update file"); - bool transfer_success = transfer_file( - server_address, - update_server_port, - handshake_response.token, - update_file, - transfer_id, - [](const FileTransferProgress &progress) { - spdlog::info( - "Transfer progress: {:.1f}% ({}/{} bytes)", - progress.progress_percentage, - progress.bytes_transferred, - progress.total_bytes - ); - } - ); - - if (!transfer_success) { - result.error_message = "File transfer failed"; - return result; - } - - result.success = true; - return result; - - } catch (const std::exception &e) { - result.error_message = fmt::format("Update failed: {}", e.what()); - return result; - } -} - -bool Version::operator==(const Version &other) const -{ - return major == other.major && minor == other.minor && patch == other.patch; -} - -bool Version::operator>(const Version &other) const { return !(*this < other || *this == other); } - -bool Version::operator<(const Version &other) const -{ - if (major != other.major) return major < other.major; - if (minor != other.minor) return minor < other.minor; - return patch < other.patch; -} - -bool Version::operator>=(const Version &other) const { return !(*this < other); } - -bool Version::operator<=(const Version &other) const { return (*this < other) || (*this == other); } - -std::optional Version::from_string(const std::string &version_str) -{ - try { - std::regex version_regex(R"((\d+)\.(\d+)\.(\d+))"); - std::smatch matches; - - if (std::regex_match(version_str, matches, version_regex)) { - return Version{ - std::stoi(matches[1].str()), - std::stoi(matches[2].str()), - std::stoi(matches[3].str()) - }; - } - return std::nullopt; - } catch (...) { - return std::nullopt; - } -} - -std::string Version::to_string() const { return fmt::format("{}.{}.{}", major, minor, patch); } -} // namespace xvc \ No newline at end of file diff --git a/xdaqvc/updater.h b/xdaqvc/updater.h deleted file mode 100644 index dd3f65f..0000000 --- a/xdaqvc/updater.h +++ /dev/null @@ -1,110 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - - -namespace fs = std::filesystem; - - -namespace xvc -{ - -struct DownloadResult { - bool success; - std::string error_message; -}; - -class Version -{ -public: - int major; - int minor; - int patch; - - bool operator==(const Version &other) const; - bool operator>(const Version &other) const; - bool operator<(const Version &other) const; - bool operator>=(const Version &other) const; - bool operator<=(const Version &other) const; - - static std::optional from_string(const std::string &version_str); - std::string to_string() const; -}; - -struct HandshakeResponse { - bool success; - std::string token; - std::string error_message; - std::chrono::system_clock::time_point expires; -}; - -struct FileTransferProgress { - size_t bytes_transferred; - size_t total_bytes; - float progress_percentage; -}; - -struct UpdateInfo { - Version version; - std::string release_date; - std::string update_url; - std::string hash; - Version min_client_version; - std::string description; -}; - -struct VersionTable { - Version latest_version; - std::vector versions; -}; - -struct UpdateResult { - bool success; - std::string error_message; - Version current_version; - Version available_version; - bool update_needed; -}; - -DownloadResult download_and_verify( - const std::string &url, const std::string &expected_hash, - const std::filesystem::path &output_path -); - -std::optional calculate_sha256(const fs::path &filepath); - -HandshakeResponse perform_handshake(const std::string &server_address, int port); - -bool prepare_file_transfer( - const std::string &server_address, int port, const std::string &token, - const std::string &filename, const std::string &file_hash, size_t file_size, - std::string &out_transfer_id -); - -bool transfer_file( - const std::string &server_address, int port, const std::string &token, - const fs::path &file_path, const std::string &transfer_id, - std::function progress_callback = nullptr -); - -// Get server version -std::optional get_server_version(const std::string &server_address, int port); - -// Get version table from CDN -std::optional get_version_table(const std::string &table_url); - -// Main update function -UpdateResult update_server( - const std::string &server_address, - int server_port, // Port of the server to be updated - int update_server_port, // Port of the update server - const std::string &table_url, const fs::path &update_dir, const Version &client_version, - bool skip_version_check = false, const std::optional &force_version = std::nullopt -); - -} // namespace xvc \ No newline at end of file diff --git a/xdaqvc/validator.cc b/xdaqvc/validator.cc new file mode 100644 index 0000000..1ea633d --- /dev/null +++ b/xdaqvc/validator.cc @@ -0,0 +1,57 @@ +#include +#include + +#include + +using nlohmann::json; +using nlohmann::json_schema::json_validator; + +static auto camera_schema = R"( +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["id", "device_id", "name", "caps"], + "properties": { + "id": { "type": "integer" }, + "device_id": { "type": "string" }, + "name": { "type": "string" }, + "caps": { + "type": "array", + "items": { + "type": "object", + "required": ["media_type", "width", "height", "framerate"], + "properties": { + "media_type": { "type": "string" }, + "format": { "type": "string" }, + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 }, + "framerate": { + "type": "string", + "pattern": "^[0-9]+/[0-9]+$" + } + } + } + } + } +} +)"_json; + +std::expected validate_camera(const nlohmann::json &json) +{ + static auto validator = [] -> json_validator { + json_validator v; + try { + v.set_root_schema(camera_schema); + } catch (const std::exception &e) { + spdlog::error("Validation of schema failed: {}", e.what()); + } + return v; + }(); + + try { + validator.validate(json); + } catch (const std::exception &e) { + return std::unexpected(e.what()); + } + return {}; +} diff --git a/xdaqvc/validator.h b/xdaqvc/validator.h new file mode 100644 index 0000000..44a8e99 --- /dev/null +++ b/xdaqvc/validator.h @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +std::expected validate_camera(const nlohmann::json &json); diff --git a/xdaqvc/ws_client.cc b/xdaqvc/ws_client.cc index a37ca0f..44f8c71 100644 --- a/xdaqvc/ws_client.cc +++ b/xdaqvc/ws_client.cc @@ -2,50 +2,39 @@ #include -#include -#include - +#include namespace http = beast::http; // from - -namespace -{ -auto constexpr RESOLVE = "resolve"; -auto constexpr CONNECT = "connect"; -auto constexpr HANDSHAKE = "handshake"; -auto constexpr READ = "read"; -auto constexpr CLOSE = "close"; -auto constexpr ROUTE = "/ws"; - // Report a failure -void fail(beast::error_code ec, char const *what) { spdlog::debug("{} : {}", what, ec.message()); } - -} // namespace - -namespace xvc +void fail(beast::error_code ec, std::string_view what) { + spdlog::debug("{} : {}", what, ec.message()); +} -session::session(net::io_context &ioc, std::function handler) +session::session( + std::string host, std::string port, net::io_context &ioc, + std::function handler +) : _resolver(net::make_strand(ioc)), _ws(net::make_strand(ioc)), - _event_handler(std::move(handler)) + _host(std::move(host)), + _port(std::move(port)), + _handler(std::move(handler)) { } -void session::run(char const *host, char const *port) +void session::run() { - _host = host; - // Look up the domain name _resolver.async_resolve( - host, port, beast::bind_front_handler(&session::on_resolve, shared_from_this()) + _host, _port, beast::bind_front_handler(&session::on_resolve, shared_from_this()) ); } void session::on_resolve(beast::error_code ec, tcp::resolver::results_type results) { - if (ec) return fail(ec, RESOLVE); + if (ec) return fail(ec, "resolve"); // Set the timeout for the operation beast::get_lowest_layer(_ws).expires_after(std::chrono::seconds(1)); @@ -59,7 +48,7 @@ void session::on_resolve(beast::error_code ec, tcp::resolver::results_type resul void session::on_connect(beast::error_code ec, tcp::resolver::results_type::endpoint_type ep) { if (ec) { - fail(ec, CONNECT); + fail(ec, "connect"); reconnect(); return; }; @@ -79,20 +68,20 @@ void session::on_connect(beast::error_code ec, tcp::resolver::results_type::endp ); })); - // Update the host_ string. This will provide the value of the + // Update the _host string. This will provide the value of the // Host HTTP header during the WebSocket handshake. // See https://tools.ietf.org/html/rfc7230#section-5.4 - _host += ':' + std::to_string(ep.port()); + _host = std::format("{}:{}", _host, ep.port()); // Perform the websocket handshake _ws.async_handshake( - _host, ROUTE, beast::bind_front_handler(&session::on_handshake, shared_from_this()) + _host, "/ws", beast::bind_front_handler(&session::on_handshake, shared_from_this()) ); } void session::on_handshake(beast::error_code ec) { - if (ec) return fail(ec, HANDSHAKE); + if (ec) return fail(ec, "handshake"); read(); } @@ -107,18 +96,16 @@ void session::on_read(beast::error_code ec, std::size_t bytes_transferred) boost::ignore_unused(bytes_transferred); if (ec) { - fail(ec, READ); + fail(ec, "read"); reconnect(); return; }; // Process the received message - auto const event = beast::buffers_to_string(_buffer.data()); - _event_handler(event); + _handler(beast::buffers_to_string(_buffer.data())); // Clear the buffer - _buffer.clear(); - // _buffer.consume(_buffer.size()); + _buffer.consume(_buffer.size()); read(); } @@ -134,14 +121,13 @@ void session::close() void session::on_close(beast::error_code ec) { - if (ec) return fail(ec, CLOSE); + if (ec) return fail(ec, "close"); // If we get here then the connection is closed gracefully - spdlog::debug("WebSocket closed gracefully"); } -void session::reconnect(const std::chrono::milliseconds timeout) +void session::reconnect(std::chrono::milliseconds timeout) { spdlog::debug("session has been disconnected, trying to reconnect..."); @@ -152,40 +138,30 @@ void session::reconnect(const std::chrono::milliseconds timeout) spdlog::debug("next trial will start after {}ms", timeout.count()); std::this_thread::sleep_for(timeout); - auto const host = "192.168.177.100"; - auto const port = "8000"; - - run(host, port); + run(); } -ws_client::ws_client(std::function handler) : _event_handler(std::move(handler)) +namespace xvc { - _ioc = std::make_unique(); - _thread = std::jthread([this, host = "192.168.177.100", port = "8000"]() { - try { - // Launch the asynchronous operation - _session = std::make_shared(*_ioc, [this](const std::string &event) { - _event_handler(event); - }); - - _session->run(host, port); +ws_client::ws_client(std::string host, std::string port, std::function handler) +{ + // Launch the asynchronous operation + auto _session = + std::make_shared(std::move(host), std::move(port), _ioc, std::move(handler)); + _session->run(); + _thread = std::jthread([&]() { + try { // Run the I/O service. The call will return when // the socket is closed. - _ioc->run(); - - spdlog::info("WebSocket closed"); + _ioc.run(); } catch (const std::exception &e) { spdlog::error("WebSocket thread error: {}", e.what()); } }); } -ws_client::~ws_client() -{ - // _ioc->stop(); - // _session->close(); -} +ws_client::~ws_client() { _ioc.stop(); } } // namespace xvc \ No newline at end of file diff --git a/xdaqvc/ws_client.h b/xdaqvc/ws_client.h index 2fd5f9e..c551116 100644 --- a/xdaqvc/ws_client.h +++ b/xdaqvc/ws_client.h @@ -1,29 +1,18 @@ #pragma once -#ifndef _WIN32_WINNT -#define _WIN32_WINNT 0x0601 -#endif - -#ifndef _WIN32_WINDOWS -#define _WIN32_WINDOWS 0x0601 -#endif - #include #include #include +#include +#include #include +#include #include - namespace beast = boost::beast; // from namespace websocket = beast::websocket; // from namespace net = boost::asio; // from using tcp = boost::asio::ip::tcp; // from -using namespace std::chrono_literals; - - -namespace xvc -{ // Sends a WebSocket message and prints the response class session : public std::enable_shared_from_this @@ -32,15 +21,18 @@ class session : public std::enable_shared_from_this websocket::stream _ws; beast::flat_buffer _buffer; std::string _host; - std::function _event_handler; + std::string _port; + std::function _handler; public: // Resolver and socket require an io_context - explicit session(net::io_context &ioc, std::function handler); - ~session() = default; + explicit session( + std::string host, std::string port, net::io_context &ioc, + std::function handler + ); // Start the asynchronous operation - void run(char const *host, char const *port); + void run(); void on_resolve(beast::error_code ec, tcp::resolver::results_type results); void on_connect(beast::error_code ec, tcp::resolver::results_type::endpoint_type ep); @@ -51,20 +43,24 @@ class session : public std::enable_shared_from_this void close(); void on_close(beast::error_code ec); - void reconnect(const std::chrono::milliseconds timeout = 500ms); + void reconnect(std::chrono::milliseconds timeout = std::chrono::milliseconds(500)); }; +namespace xvc +{ + class ws_client { public: - ws_client(std::function handler); + ws_client( + std::string host = "192.168.177.100", std::string port = "8000", + std::function handler = nullptr + ); ~ws_client(); private: - std::shared_ptr _session; - std::unique_ptr _ioc; + net::io_context _ioc; std::jthread _thread; - std::function _event_handler; }; } // namespace xvc \ No newline at end of file diff --git a/xdaqvc/xvc.cc b/xdaqvc/xvc.cc index 256a97e..de3234c 100644 --- a/xdaqvc/xvc.cc +++ b/xdaqvc/xvc.cc @@ -1,75 +1,35 @@ #include "xvc.h" -#include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include +#include #include -#include #include -#include "xdaqmetadata/key_value_store.h" -#include "xdaqmetadata/xdaqmetadata.h" - - using namespace std::chrono_literals; - +namespace fs = std::filesystem; namespace { -GstElement *create_element(const gchar *factoryname, const gchar *name) -{ - auto element = gst_element_factory_make(factoryname, name); - if (!element) { - spdlog::error("Element {} could not be created.", factoryname); - } - return element; -} - struct FileTracker { std::string base_filepath; std::vector file_paths; int max_files; }; -gchararray generate_filename( - [[maybe_unused]] GstElement *splitmux, [[maybe_unused]] guint fragment_id, gpointer udata -) +gchararray generate_filename(GstElement *, guint, gpointer udata) { auto tracker = static_cast(udata); - auto now = std::chrono::system_clock::now(); - auto time_t_now = std::chrono::system_clock::to_time_t(now); - std::tm tm_now; - -#ifdef _WIN32 - localtime_s(&tm_now, &time_t_now); -#else - localtime_r(&time_t_now, &tm_now); -#endif + if (!tracker) { + spdlog::error("FileTracker is null"); + return nullptr; + } - auto timestamp = fmt::format("{:%Y-%m-%d_%H-%M-%S}", tm_now); - auto file_path = fmt::format("{}-{}.mkv", tracker->base_filepath, timestamp); + const auto &now = std::chrono::floor(std::chrono::system_clock::now()); + const auto ×tamp = std::format("{:%Y-%m-%d_%H-%M-%S}", now); + const auto &file_path = std::format("{}-{}.mkv", tracker->base_filepath, timestamp); tracker->file_paths.emplace_back(file_path); @@ -90,109 +50,30 @@ gchararray generate_filename( } // namespace - namespace xvc { -void setup_h265_srt_stream(GstPipeline *pipeline, const std::string &uri) -{ - spdlog::info("Setup GStreamer H.265 SRT stream pipeline"); - - auto src = create_element("srtsrc", "src"); - auto parser = create_element("h265parse", "parser"); - auto cf_parser = create_element("capsfilter", "cf_parser"); - auto tee = create_element("tee", "t"); - auto queue_display = create_element("queue", "queue_display"); -#ifdef _WIN32 - auto dec = create_element("d3d11h265dec", "dec"); -#elif __APPLE__ - auto dec = create_element("vtdec", "dec"); -#else - auto dec = create_element("avdec_h265", "dec"); -#endif - auto cf_dec = create_element("capsfilter", "cf_dec"); - auto conv = create_element("videoconvert", "conv"); - auto cf_conv = create_element("capsfilter", "cf_conv"); - auto appsink = create_element("appsink", "appsink"); - - // clang-format off - std::unique_ptr cf_src_caps( - gst_caps_new_simple( - "application/x-rtp", - "encoding-name", G_TYPE_STRING, "H265", - nullptr), - gst_caps_unref - ); - std::unique_ptr cf_parser_caps( - gst_caps_new_simple( - "video/x-h265", - "stream-format", G_TYPE_STRING, "byte-stream", - "alignment", G_TYPE_STRING, "au", - nullptr), - gst_caps_unref - ); - std::unique_ptr cf_dec_caps( - gst_caps_new_simple( - "video/x-raw", - "format", G_TYPE_STRING, "NV12", - nullptr), - gst_caps_unref - ); - std::unique_ptr cf_conv_caps( - gst_caps_new_simple( - "video/x-raw", - "format", G_TYPE_STRING, "RGB", - nullptr), - gst_caps_unref - ); - // clang-format on - - g_object_set(G_OBJECT(src), "uri", fmt::format("srt://{}", uri).c_str(), nullptr); - g_object_set(G_OBJECT(cf_parser), "caps", cf_parser_caps.get(), nullptr); - g_object_set(G_OBJECT(cf_dec), "caps", cf_dec_caps.get(), nullptr); - g_object_set(G_OBJECT(cf_conv), "caps", cf_conv_caps.get(), nullptr); - - gst_bin_add_many( - GST_BIN(pipeline), - src, - parser, - cf_parser, - tee, - queue_display, - dec, - cf_dec, - conv, - cf_conv, - appsink, - nullptr - ); - - if (!gst_element_link_many(src, parser, cf_parser, tee, nullptr) || - !gst_element_link_many(tee, queue_display, dec, cf_dec, conv, cf_conv, appsink, nullptr)) { - spdlog::error("Elements could not be linked."); - gst_object_unref(pipeline); - } -} void setup_jpeg_srt_stream(GstPipeline *pipeline, const std::string &uri) { + if (!pipeline) return; spdlog::info("Setup GStreamer M-JPEG SRT stream pipeline with uri: {}", uri); - auto src = create_element("srtclientsrc", "src"); - auto parser = create_element("jpegparse", "parser"); - auto tee = create_element("tee", "t"); - auto queue_display = create_element("queue", "queue_display"); + auto src = gst_element_factory_make("srtclientsrc", "src"); + auto parser = gst_element_factory_make("jpegparse", "parser"); + auto tee = gst_element_factory_make("tee", "t"); + auto queue_display = gst_element_factory_make("queue", "queue_display"); #ifdef _WIN32 - auto dec = create_element("jpegdec", "dec"); + auto dec = gst_element_factory_make("jpegdec", "dec"); #elif __APPLE__ - auto dec = create_element("vtdec", "dec"); + auto dec = gst_element_factory_make("vtdec", "dec"); #else - auto dec = create_element("jpegdec", "dec"); + auto dec = gst_element_factory_make("jpegdec", "dec"); #endif - auto conv = create_element("videoconvert", "conv"); - auto cf_conv = create_element("capsfilter", "cf_conv"); - auto fpsdisplaysink = create_element("fpsdisplaysink", "fpsdisplaysink"); - auto appsink = create_element("appsink", "appsink"); + auto conv = gst_element_factory_make("videoconvert", "conv"); + auto cf_conv = gst_element_factory_make("capsfilter", "cf_conv"); + auto fpsdisplaysink = gst_element_factory_make("fpsdisplaysink", "fpsdisplaysink"); + auto appsink = gst_element_factory_make("appsink", "appsink"); // clang-format off std::unique_ptr cf_conv_caps( @@ -204,12 +85,18 @@ void setup_jpeg_srt_stream(GstPipeline *pipeline, const std::string &uri) ); // clang-format on - g_object_set(G_OBJECT(src), "uri", fmt::format("srt://{}", uri).c_str(), nullptr); + g_object_set(G_OBJECT(src), "uri", std::format("srt://{}", uri).c_str(), nullptr); g_object_set(G_OBJECT(cf_conv), "caps", cf_conv_caps.get(), nullptr); g_object_set(G_OBJECT(appsink), "sync", false, nullptr); - g_object_set(G_OBJECT(fpsdisplaysink), "video-sink", appsink, nullptr); - g_object_set(G_OBJECT(fpsdisplaysink), "text-overlay", false, nullptr); - g_object_set(G_OBJECT(fpsdisplaysink), "sync", false, nullptr); + // clang-format off + g_object_set( + G_OBJECT(fpsdisplaysink), + "video-sink", appsink, + "text-overlay", false, + "sync", false, + nullptr + ); + // clang-format on gst_bin_add_many( GST_BIN(pipeline), @@ -231,577 +118,146 @@ void setup_jpeg_srt_stream(GstPipeline *pipeline, const std::string &uri) } } -void decode_toggle(GstPipeline *pipeline, bool decode) +bool start_jpeg_recording(GstPipeline *pipeline, const RecordConfig &config) { - std::unique_ptr queue_display( - gst_bin_get_by_name(GST_BIN(pipeline), "queue_display"), gst_object_unref - ); - if (!queue_display) { - spdlog::error("Failed to find element: 'queue_display'"); - return; - } - - std::unique_ptr src_pad( - gst_element_get_static_pad(queue_display.get(), "src"), gst_object_unref - ); - if (!src_pad) { - spdlog::error("Failed to get src pad from 'queue_display'"); - return; + if (!pipeline) { + spdlog::debug("Pipeline is null"); + return false; } - - static unsigned long probe_id = 0; - - if (decode) { - spdlog::debug( - "Remove probe from src pad on 'queue_display' to allow buffers to pass through" - ); - // TODO: 'decode' defaults to true, this line generate a warning - gst_pad_remove_probe(src_pad.get(), probe_id); - - } else { - spdlog::debug("Add probe to src pad on 'queue_display' to drop buffers"); - probe_id = gst_pad_add_probe( - src_pad.get(), - GST_PAD_PROBE_TYPE_BUFFER, - []([[maybe_unused]] GstPad *pad, - [[maybe_unused]] GstPadProbeInfo *info, - [[maybe_unused]] gpointer user_data) -> GstPadProbeReturn { - spdlog::trace("Drop buffer before decode"); - return GST_PAD_PROBE_DROP; - }, - nullptr, - nullptr - ); + if (config._path.empty()) { + spdlog::debug("Config path is empty"); + return false; } -} + spdlog::info("Starting M-JPEG recording ..."); -void start_h265_recording( - GstPipeline *pipeline, fs::path &filepath, bool continuous, int max_size_time, int max_files -) -{ - spdlog::info("Start GStreamer H.265 recording"); + const auto &location = config._path.generic_string(); + const auto split = config._split; + const auto max_size_time = config._max_size_time; auto tee = gst_bin_get_by_name(GST_BIN(pipeline), "t"); - auto src_pad = gst_element_request_pad_simple(tee, "src_1"); - - auto queue_record = create_element("queue", "queue_record"); - auto parser = create_element("h265parse", "record_parser"); - auto cf_parser = create_element("capsfilter", "cf_record_parser"); - auto filesink = create_element("splitmuxsink", "filesink"); - - filepath += continuous ? ".mkv" : "-%02d.mkv"; - auto _max_size_time = continuous ? 0 : max_size_time * GST_SECOND * 60; - - // clang-format off - std::unique_ptr cf_parser_caps( - gst_caps_new_simple( - "video/x-h265", - "stream-format", G_TYPE_STRING, "hvc1", - "alignment", G_TYPE_STRING, "au", - nullptr), - gst_caps_unref - ); - // clang-format on - - g_object_set(G_OBJECT(cf_parser), "caps", cf_parser_caps.get(), nullptr); - g_object_set(G_OBJECT(filesink), "location", filepath.generic_string().c_str(), nullptr); - g_object_set( - G_OBJECT(filesink), "max-size-time", _max_size_time, nullptr - ); // max-size-time=0 -> continuous - g_object_set(G_OBJECT(filesink), "max-files", max_files, nullptr); - g_object_set( - G_OBJECT(filesink), "max-size-bytes", 0, nullptr - ); // Set max-size-bytes to 0 in order to make send-keyframe-requests work. - g_object_set(G_OBJECT(filesink), "send-keyframe-requests", true, nullptr); - g_object_set(G_OBJECT(filesink), "async-finalize", true, nullptr); - g_object_set( - G_OBJECT(filesink), "muxer-factory", "matroskamux", nullptr - ); // Valid only for async-finalize = TRUE - - - gst_bin_add_many(GST_BIN(pipeline), queue_record, parser, cf_parser, filesink, nullptr); - - if (!gst_element_link_many(queue_record, parser, cf_parser, filesink, nullptr)) { - spdlog::error("Elements could not be linked."); - gst_object_unref(pipeline); - return; + if (auto exist_tee_srcpad = gst_element_get_static_pad(tee, "src_1")) { + spdlog::warn("tee 'src_1' pad already exists, releasing it..."); + gst_element_release_request_pad(tee, exist_tee_srcpad); + gst_object_unref(exist_tee_srcpad); } - - gst_element_sync_state_with_parent(queue_record); - gst_element_sync_state_with_parent(parser); - gst_element_sync_state_with_parent(cf_parser); - gst_element_sync_state_with_parent(filesink); - - std::unique_ptr sink_pad( - gst_element_get_static_pad(queue_record, "sink"), gst_object_unref - ); - - auto ret = gst_pad_link(src_pad, sink_pad.get()); - if (GST_PAD_LINK_FAILED(ret)) { - spdlog::error("Failed to link 'tee' src pad to 'queue' sink pad"); - gst_object_unref(pipeline); - return; + auto tee_srcpad = gst_element_request_pad_simple(tee, "src_1"); + gst_object_unref(tee); + + auto queue = gst_element_factory_make("queue", "queue_record"); + auto parser = gst_element_factory_make("jpegparse", "record_parser"); + auto muxer = gst_element_factory_make("matroskamux", "muxer"); + auto filesink = gst_element_factory_make("splitmuxsink", "filesink"); + + if (!queue || !parser || !muxer || !filesink) { + spdlog::error("Failed to create elements for recording"); + if (queue) gst_object_unref(queue); + if (parser) gst_object_unref(parser); + if (muxer) gst_object_unref(muxer); + if (filesink) gst_object_unref(filesink); + return false; } - GST_DEBUG_BIN_TO_DOT_FILE( - GST_BIN(pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "video-capture-after-link" - ); -} -void stop_h265_recording(GstPipeline *pipeline) -{ - spdlog::info("Stop GStreamer H.265 recording"); - - auto tee = gst_bin_get_by_name(GST_BIN(pipeline), "t"); - auto src_pad = gst_element_get_static_pad(tee, "src_1"); - - // Increase the reference count so that 'pipeline' remains valid. - gst_object_ref(pipeline); - - gst_pad_add_probe( - src_pad, - GST_PAD_PROBE_TYPE_IDLE, - [](GstPad *src_pad, GstPadProbeInfo *, gpointer user_data) -> GstPadProbeReturn { - spdlog::info("Unlinking"); - - auto pipeline = GST_PIPELINE(user_data); - auto tee = gst_bin_get_by_name(GST_BIN(pipeline), "t"); - std::unique_ptr queue_record( - gst_bin_get_by_name(GST_BIN(pipeline), "queue_record"), gst_object_unref - ); - std::unique_ptr parser( - gst_bin_get_by_name(GST_BIN(pipeline), "record_parser"), gst_object_unref - ); - std::unique_ptr cf_parser( - gst_bin_get_by_name(GST_BIN(pipeline), "cf_record_parser"), gst_object_unref - ); - std::unique_ptr filesink( - gst_bin_get_by_name(GST_BIN(pipeline), "filesink"), gst_object_unref - ); - std::unique_ptr sink_pad( - gst_element_get_static_pad(queue_record.get(), "sink"), gst_object_unref - ); - gst_pad_unlink(src_pad, sink_pad.get()); - gst_pad_send_event(sink_pad.get(), gst_event_new_eos()); - - // Launch a detached thread to remove the elements after a delay. - std::thread([pipeline, // captured pipeline (ref'ed) - queue_record = std::move(queue_record), - parser = std::move(parser), - cf_parser = std::move(cf_parser), - filesink = std::move(filesink)]() { - std::this_thread::sleep_for(std::chrono::milliseconds(3500)); - gst_bin_remove(GST_BIN(pipeline), queue_record.get()); - gst_bin_remove(GST_BIN(pipeline), parser.get()); - gst_bin_remove(GST_BIN(pipeline), cf_parser.get()); - gst_bin_remove(GST_BIN(pipeline), filesink.get()); - - gst_element_set_state(queue_record.get(), GST_STATE_NULL); - gst_element_set_state(parser.get(), GST_STATE_NULL); - gst_element_set_state(cf_parser.get(), GST_STATE_NULL); - gst_element_set_state(filesink.get(), GST_STATE_NULL); - - // Release the extra reference on the pipeline. - gst_object_unref(pipeline); - }).detach(); - - gst_element_release_request_pad(tee, src_pad); - gst_object_unref(src_pad); - - return GST_PAD_PROBE_REMOVE; + auto tracker = new FileTracker(location, {}, INT_MAX); + g_signal_connect_data( + filesink, + "format-location", + G_CALLBACK(generate_filename), + tracker, + [](gpointer data, GClosure *) { + delete static_cast(data); + spdlog::debug("FileTracker deleted"); }, - pipeline, - nullptr + G_CONNECT_DEFAULT ); -} - -void start_jpeg_recording( - GstPipeline *pipeline, fs::path &filepath, bool continuous, int max_size_time, TimeUnit unit, - int max_files -) -{ - spdlog::info("Start GStreamer M-JPEG recording"); - - auto tee = gst_bin_get_by_name(GST_BIN(pipeline), "t"); - auto src_pad = gst_element_request_pad_simple(tee, "src_1"); - - auto queue_record = create_element("queue", "queue_record"); - auto parser = create_element("jpegparse", "record_parser"); - auto filesink = create_element("splitmuxsink", "filesink"); - auto muxer = create_element("matroskamux", "muxer"); - - switch (unit) { - case TimeUnit::Minutes: max_size_time = max_size_time * 60; break; - case TimeUnit::Hours: max_size_time = max_size_time * 60 * 60; break; - case TimeUnit::Days: max_size_time = max_size_time * 60 * 60 * 24; break; - default: break; - } - - auto tracker = - std::make_unique(FileTracker{filepath.generic_string(), {}, max_files}); - - g_signal_connect(filesink, "format-location", G_CALLBACK(generate_filename), tracker.release()); // clang-format off - g_object_set(G_OBJECT(muxer), "timecodescale", 1, nullptr); - g_object_set(G_OBJECT(muxer), "offset-to-zero", true, nullptr); + g_object_set( + G_OBJECT(muxer), + "timecodescale", 1, + "offset-to-zero", true, + nullptr + ); g_object_set( G_OBJECT(filesink), - "max-size-time", continuous ? 0 : max_size_time * GST_SECOND, // max-size-time=0 -> continuous - "max-files", max_files, + "max-size-time", split ? max_size_time.count() * GST_SECOND : 0, // max-size-time=0 -> continuous "async-finalize", false, "muxer", muxer, nullptr ); // clang-format on + gst_bin_add_many(GST_BIN(pipeline), queue, parser, filesink, nullptr); - gst_bin_add_many(GST_BIN(pipeline), queue_record, parser, filesink, nullptr); - - if (!gst_element_link_many(queue_record, parser, filesink, nullptr)) { - spdlog::error("Elements could not be linked."); - gst_object_unref(pipeline); - return; + if (!gst_element_link_many(queue, parser, filesink, nullptr)) { + spdlog::error("Failed to link MJPEG recording elements"); + return false; } - gst_element_sync_state_with_parent(queue_record); + gst_element_sync_state_with_parent(queue); gst_element_sync_state_with_parent(parser); gst_element_sync_state_with_parent(filesink); - std::unique_ptr sink_pad( - gst_element_get_static_pad(queue_record, "sink"), gst_object_unref - ); - - auto ret = gst_pad_link(src_pad, sink_pad.get()); - if (GST_PAD_LINK_FAILED(ret)) { - spdlog::error("Failed to link 'tee' src pad to 'queue' sink pad"); - gst_object_unref(pipeline); - return; + auto queue_sinkpad = gst_element_get_static_pad(queue, "sink"); + if (gst_pad_link(tee_srcpad, queue_sinkpad) != GST_PAD_LINK_OK) { + spdlog::error("Failed to link 'tee' srcpad to 'queue' sinkpad"); + return false; } + gst_object_unref(queue_sinkpad); + GST_DEBUG_BIN_TO_DOT_FILE(GST_BIN(pipeline), GST_DEBUG_GRAPH_SHOW_ALL, "after-link"); + return true; } -void stop_jpeg_recording(GstPipeline *pipeline) +bool stop_jpeg_recording(GstPipeline *pipeline) { - spdlog::info("Stop GStreamer M-JPEG recording"); + if (!pipeline) { + spdlog::debug("Pipeline is null"); + return false; + } + spdlog::info("Stopping M-JPEG recording ..."); auto tee = gst_bin_get_by_name(GST_BIN(pipeline), "t"); - auto src_pad = gst_element_get_static_pad(tee, "src_1"); + if (!tee) return false; - // Increase the reference count so that 'pipeline' remains valid. - gst_object_ref(pipeline); + auto tee_srcpad = gst_element_get_static_pad(tee, "src_1"); + if (!tee_srcpad) { + gst_object_unref(tee); + return false; + } gst_pad_add_probe( - src_pad, + tee_srcpad, GST_PAD_PROBE_TYPE_IDLE, - [](GstPad *src_pad, GstPadProbeInfo *, gpointer user_data) -> GstPadProbeReturn { - spdlog::info("Unlinking"); - - auto pipeline = GST_PIPELINE(user_data); - auto tee = gst_bin_get_by_name(GST_BIN(pipeline), "t"); - std::unique_ptr queue_record( - gst_bin_get_by_name(GST_BIN(pipeline), "queue_record"), gst_object_unref - ); - std::unique_ptr parser( - gst_bin_get_by_name(GST_BIN(pipeline), "record_parser"), gst_object_unref - ); - std::unique_ptr filesink( - gst_bin_get_by_name(GST_BIN(pipeline), "filesink"), gst_object_unref - ); - std::unique_ptr sink_pad( - gst_element_get_static_pad(queue_record.get(), "sink"), gst_object_unref - ); - gst_pad_send_event(sink_pad.get(), gst_event_new_eos()); - - // Launch a detached thread to remove the elements after a delay. - std::thread([pipeline, // captured pipeline (ref'ed) - queue_record = std::move(queue_record), - parser = std::move(parser), - filesink = std::move(filesink)]() { - std::this_thread::sleep_for(std::chrono::milliseconds(3500)); - gst_bin_remove(GST_BIN(pipeline), queue_record.get()); - gst_bin_remove(GST_BIN(pipeline), parser.get()); - gst_bin_remove(GST_BIN(pipeline), filesink.get()); - - gst_element_set_state(queue_record.get(), GST_STATE_NULL); - gst_element_set_state(parser.get(), GST_STATE_NULL); - gst_element_set_state(filesink.get(), GST_STATE_NULL); - - // Release the extra reference on the pipeline. - gst_object_unref(pipeline); - }).detach(); - - gst_element_release_request_pad(tee, src_pad); - gst_object_unref(src_pad); + [](GstPad *tee_srcpad, GstPadProbeInfo *, gpointer user_data) -> GstPadProbeReturn { + spdlog::debug("MJPEG recording unlinking"); - return GST_PAD_PROBE_REMOVE; - }, - pipeline, - nullptr - ); -} + // auto pipeline = GST_PIPELINE(user_data); + auto pipeline = static_cast(user_data); -void mock_camera( - GstPipeline *pipeline, [[maybe_unused]] const std::string &uri, const std::string ¤t_cap -) -{ - spdlog::info("Setup GStreamer mock camera SRT Stream"); - - auto parser = [](const std::string &camera_cap) { - std::unordered_map caps_map; - std::stringstream ss(camera_cap); - std::string token; - - // "image/jpeg,width=640,height=480,framerate=601/1"; - while (std::getline(ss, token, ',')) { - auto pos = token.find('='); - if (pos != std::string::npos) { - auto k = token.substr(0, pos); - auto v = token.substr(pos + 1); - caps_map[k] = v; - } else { - caps_map["media_type"] = token; - } - } - return caps_map; - }; - std::unordered_map caps_map = parser(current_cap); - - // auto media_type = caps_map["media_type"]; - auto width = std::stoi(caps_map["width"]); - auto height = std::stoi(caps_map["height"]); - - std::stringstream ss(caps_map["framerate"]); - auto fps_n = 0, fps_d = 1; - char slash; - ss >> fps_n >> slash >> fps_d; - - auto src = create_element("videotestsrc", "src"); - auto cf_src = create_element("capsfilter", "cf_src"); - auto tee = create_element("tee", "t"); - auto queue_display = create_element("queue", "queue_display"); - - auto enc = create_element("jpegenc", "enc"); - // auto dec = create_element("jpegdec", "dec"); - auto fpsdisplaysink = create_element("fpsdisplaysink", "fpsdisplaysink"); - auto appsink = create_element("appsink", "appsink"); - - // clang-format off - std::unique_ptr cf_src_caps( - gst_caps_new_simple( - "video/x-raw", - "format", G_TYPE_STRING, "RGB", - "width", G_TYPE_INT, width, - "height", G_TYPE_INT, height, - "framerate", GST_TYPE_FRACTION, fps_n, fps_d, - nullptr - ), - gst_caps_unref - ); - // clang-format on - - g_object_set(G_OBJECT(src), "pattern", 18, nullptr); - g_object_set(G_OBJECT(src), "is-live", true, nullptr); - g_object_set(G_OBJECT(cf_src), "caps", cf_src_caps.get(), nullptr); - g_object_set(G_OBJECT(fpsdisplaysink), "video-sink", appsink, nullptr); - g_object_set(G_OBJECT(fpsdisplaysink), "sync", false, nullptr); - g_object_set(G_OBJECT(fpsdisplaysink), "text-overlay", false, nullptr); - g_object_set(G_OBJECT(appsink), "sync", false, nullptr); + auto queue = gst_bin_get_by_name(pipeline, "queue_record"); + if (!queue) return GST_PAD_PROBE_REMOVE; - gst_bin_add_many( - GST_BIN(pipeline), src, cf_src, enc, tee, queue_display, fpsdisplaysink, nullptr - ); - - if (!gst_element_link_many(src, cf_src, enc, tee, queue_display, fpsdisplaysink, nullptr)) { - spdlog::error("Elements could not be linked."); - gst_object_unref(pipeline); - } - - // FPS watcher thread - // auto pipeline_name = gst_element_get_name(GST_ELEMENT(pipeline)); - - // std::thread([pipeline_name, fpsdisplaysink]() { - // while (true) { - // gchar *msg = nullptr; - // g_object_get(G_OBJECT(fpsdisplaysink), "last-message", &msg, nullptr); - // if (msg) { - // spdlog::info("fps{}: {}", pipeline_name, msg); - // g_free(msg); - // } - // std::this_thread::sleep_for(std::chrono::seconds(1)); - // } - // }).detach(); -} - -void parse_video_save_binary_h265(const std::string &video_filepath) -{ - auto bin_file_name = video_filepath; - bin_file_name.replace(bin_file_name.end() - 3, bin_file_name.end(), "bin"); - - spdlog::info("parse_video_save_binary: {}", bin_file_name); - - KeyValueStore bin_store(bin_file_name); - - bin_store.openFile(); - - auto pipeline_str = fmt::format( - "filesrc location=\"{}\" ! matroskademux ! h265parse name=h265parse ! video/x-h265, " - "stream-format=byte-stream, alignment=au ! fakesink", - video_filepath - ); - - spdlog::info("pipeline_str = {}", pipeline_str); - - GError *error = nullptr; - std::unique_ptr pipeline( - gst_parse_launch(pipeline_str.c_str(), &error), gst_object_unref - ); - if (!pipeline) { - spdlog::error("Failed to create pipeline: {}", error->message); - g_clear_error(&error); - return; - } - - std::unique_ptr h265parse( - gst_bin_get_by_name(GST_BIN(pipeline.get()), "h265parse"), gst_object_unref - ); - std::unique_ptr src_pad{ - gst_element_get_static_pad(h265parse.get(), "src"), gst_object_unref - }; - - gst_pad_add_probe( - src_pad.get(), GST_PAD_PROBE_TYPE_BUFFER, h265_parse_saving_metadata, &bin_store, nullptr - ); - - gst_element_set_state(pipeline.get(), GST_STATE_PLAYING); - - // Event loop to keep the pipeline running - std::unique_ptr bus = { - gst_element_get_bus(pipeline.get()), gst_object_unref - }; - bool terminate = false; - - while (!terminate) { - // Wait for a message for up to 100 milliseconds - std::unique_ptr msg( - gst_bus_timed_pop_filtered( - bus.get(), - 100 * GST_MSECOND, - static_cast(GST_MESSAGE_ERROR | GST_MESSAGE_EOS) - ), - gst_message_unref - ); - - // Handle errors and EOS messages - if (msg) { - GError *err; - gchar *debug_info; - - switch (GST_MESSAGE_TYPE(msg.get())) { - case GST_MESSAGE_ERROR: - gst_message_parse_error(msg.get(), &err, &debug_info); - spdlog::error("Error from element {}:", GST_OBJECT_NAME(msg->src), err->message); - g_clear_error(&err); - g_free(debug_info); - terminate = true; - break; - case GST_MESSAGE_EOS: - spdlog::info("End-Of-Stream reached."); - terminate = true; - break; - default: break; + auto queue_sinkpad = gst_element_get_static_pad(queue, "sink"); + if (!queue_sinkpad) { + gst_object_unref(queue); + return GST_PAD_PROBE_REMOVE; } - } - } - gst_element_set_state(pipeline.get(), GST_STATE_NULL); + gst_pad_unlink(tee_srcpad, queue_sinkpad); + gst_pad_send_event(queue_sinkpad, gst_event_new_eos()); - bin_store.closeFile(); -} - -void parse_video_save_binary_jpeg(const std::string &video_filepath) -{ - auto bin_file_name = video_filepath; - bin_file_name.replace(bin_file_name.end() - 3, bin_file_name.end(), "bin"); - - spdlog::info("parse_video_save_binary: {}", bin_file_name); - - KeyValueStore bin_store(bin_file_name); - - bin_store.openFile(); - - auto pipeline_str = fmt::format( - "filesrc location=\"{}\" ! matroskademux ! jpegparse name=jpegparse ! fakesink", - video_filepath - ); - - spdlog::info("pipeline_str = {}", pipeline_str); - - GError *error = nullptr; - std::unique_ptr pipeline( - gst_parse_launch(pipeline_str.c_str(), &error), gst_object_unref - ); - - if (!pipeline) { - spdlog::error("Failed to create pipeline: {}", error->message); - g_clear_error(&error); - return; - } - - std::unique_ptr jpegparse{ - gst_bin_get_by_name(GST_BIN(pipeline.get()), "jpegparse"), gst_object_unref - }; - std::unique_ptr src_pad{ - gst_element_get_static_pad(jpegparse.get(), "src"), gst_object_unref - }; + gst_object_unref(queue_sinkpad); + gst_object_unref(queue); - gst_pad_add_probe( - src_pad.get(), GST_PAD_PROBE_TYPE_BUFFER, jpeg_parse_saving_metadata, &bin_store, nullptr + return GST_PAD_PROBE_REMOVE; + }, + pipeline, + nullptr ); - gst_element_set_state(pipeline.get(), GST_STATE_PLAYING); - - // Event loop to keep the pipeline running - std::unique_ptr bus = { - gst_element_get_bus(pipeline.get()), gst_object_unref - }; - bool terminate = false; - - while (!terminate) { - // Wait for a message for up to 100 milliseconds - std::unique_ptr msg( - gst_bus_timed_pop_filtered( - bus.get(), - 100 * GST_MSECOND, - static_cast(GST_MESSAGE_ERROR | GST_MESSAGE_EOS) - ), - gst_message_unref - ); - - // Handle errors and EOS messages - if (msg) { - GError *err; - gchar *debug_info; - - switch (GST_MESSAGE_TYPE(msg.get())) { - case GST_MESSAGE_ERROR: - gst_message_parse_error(msg.get(), &err, &debug_info); - spdlog::error("Error from element {}:", GST_OBJECT_NAME(msg->src), err->message); - g_clear_error(&err); - g_free(debug_info); - terminate = true; - break; - case GST_MESSAGE_EOS: - spdlog::info("End-Of-Stream reached."); - terminate = true; - break; - default: break; - } - } - } - - gst_element_set_state(pipeline.get(), GST_STATE_NULL); - - bin_store.closeFile(); + gst_object_unref(tee_srcpad); + gst_object_unref(tee); + return true; } } // namespace xvc \ No newline at end of file diff --git a/xdaqvc/xvc.h b/xdaqvc/xvc.h index 8cdfd1b..79f6eac 100644 --- a/xdaqvc/xvc.h +++ b/xdaqvc/xvc.h @@ -1,40 +1,32 @@ #pragma once -#define LIBXVC_API_VER "0.1.0" - #include +#include #include #include -namespace fs = std::filesystem; - namespace xvc { -enum class TimeUnit { Seconds = 0, Minutes, Hours, Days }; - -void setup_h265_srt_stream(GstPipeline *pipeline, const std::string &uri); +struct RecordConfig { + std::filesystem::path _path; + bool _split; + std::chrono::seconds _max_size_time; + + RecordConfig( + std::filesystem::path path = std::filesystem::current_path(), bool split = false, + std::chrono::seconds max_size_time = std::chrono::seconds(10) + ) + : _path(std::move(path)), _split(split), _max_size_time(max_size_time) + { + } +}; + +// TODO void setup_jpeg_srt_stream(GstPipeline *pipeline, const std::string &uri); -void decode_toggle(GstPipeline *pipeline, bool decode = true); - -void mock_camera( - GstPipeline *pipeline, [[maybe_unused]] const std::string &uri, const std::string ¤t_cap -); - -void start_h265_recording( - GstPipeline *pipeline, fs::path &filepath, bool continuous, int max_size_time, int max_files -); -void stop_h265_recording(GstPipeline *pipeline); - -void start_jpeg_recording( - GstPipeline *pipeline, fs::path &filepath, bool continuous = true, int max_size_time = 10, - TimeUnit unit = TimeUnit::Minutes, int max_files = 10 -); -void stop_jpeg_recording(GstPipeline *pipeline); - -void parse_video_save_binary_h265(const std::string &filepath); -void parse_video_save_binary_jpeg(const std::string &filepath); +bool start_jpeg_recording(GstPipeline *, const RecordConfig &); +bool stop_jpeg_recording(GstPipeline *); } // namespace xvc \ No newline at end of file