From c4b24c0fbf0077d350d5c256bddfffd2f4e7669c Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Mon, 22 Jun 2026 12:08:18 +0200 Subject: [PATCH] Add CMake build and typed config with path resolution (remove hardcoded paths) --- .claude/settings.json | 5 +- CMakeLists.txt | 117 ++++++++++++++++++++++++++++++++++++++ Camera.cpp | 34 +++-------- Camera.h | 2 + cmake/FindVmb.cmake | 70 +++++++++++++++++++++++ cmake/Paho.cmake | 58 +++++++++++++++++++ config/config.example.ini | 20 +++++++ include/fgc/Config.h | 76 +++++++++++++++++++++++++ include/fgc/Paths.h | 29 ++++++++++ main.cpp | 84 +++++++++++++-------------- src/core/Config.cpp | 109 +++++++++++++++++++++++++++++++++++ src/core/Paths.cpp | 95 +++++++++++++++++++++++++++++++ 12 files changed, 627 insertions(+), 72 deletions(-) create mode 100644 CMakeLists.txt create mode 100644 cmake/FindVmb.cmake create mode 100644 cmake/Paho.cmake create mode 100644 include/fgc/Config.h create mode 100644 include/fgc/Paths.h create mode 100644 src/core/Config.cpp create mode 100644 src/core/Paths.cpp diff --git a/.claude/settings.json b/.claude/settings.json index 2fb7d86..cfaf743 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,7 +2,10 @@ "permissions": { "allow": [ "Bash(git add *)", - "Bash(grep -iE \"config\\\\.ini|\\\\.out|\\\\.o$|compile_commands\")" + "Bash(grep -iE \"config\\\\.ini|\\\\.out|\\\\.o$|compile_commands\")", + "Bash(command -v cmake)", + "Bash(cmake --version)", + "Bash(cmake -B build -DWITH_VIMBA=OFF -DWITH_MQTT=OFF)" ] } } diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..f2756a1 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,117 @@ +cmake_minimum_required(VERSION 3.20) +project(fire_gimbal_control LANGUAGES C CXX) + +# --------------------------------------------------------------------------- +# Options (runtime toggles still select mock vs real at launch; these control +# whether the heavy/proprietary dependencies are compiled in at all). +# --------------------------------------------------------------------------- +option(WITH_VIMBA "Build with Allied Vision Vimba X camera support" ON) +option(WITH_MQTT "Build with Eclipse Paho MQTT support" ON) +option(BUILD_TESTING "Build unit tests" OFF) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +# --------------------------------------------------------------------------- +# Always-required dependencies +# --------------------------------------------------------------------------- +find_package(Threads REQUIRED) +find_package(OpenCV REQUIRED COMPONENTS core highgui imgproc) +# Boost.Asio is header-only (Boost >= 1.69); only program_options is a compiled lib. +if(POLICY CMP0167) + cmake_policy(SET CMP0167 NEW) +endif() +find_package(Boost REQUIRED CONFIG COMPONENTS program_options) + +# libjxl via pkg-config (Arch/Manjaro ship a .pc file) +find_package(PkgConfig REQUIRED) +pkg_check_modules(JXL REQUIRED IMPORTED_TARGET libjxl libjxl_threads) + +# --------------------------------------------------------------------------- +# fgc_core: SDK-independent core (config, path resolution, and - as the +# refactor proceeds - logging, the capture scheduler and the I/O interfaces). +# Builds and unit-tests WITHOUT Vimba X or Paho, so the core logic is +# verifiable on any machine. +# --------------------------------------------------------------------------- +add_library(fgc_core STATIC + src/core/Config.cpp + src/core/Paths.cpp + ini.c +) +target_include_directories(fgc_core PUBLIC + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR} # for the bundled ini.h +) +target_link_libraries(fgc_core PUBLIC Threads::Threads) + +# --------------------------------------------------------------------------- +# Application executable (current flat layout; restructured into src/ in a +# later phase). Pulls in the heavy/proprietary deps. +# --------------------------------------------------------------------------- +set(FGC_SOURCES + main.cpp + MQTT.cpp + Camera.cpp +) + +add_executable(fire_gimbal_control ${FGC_SOURCES}) +target_include_directories(fire_gimbal_control PRIVATE ${CMAKE_SOURCE_DIR}) + +target_link_libraries(fire_gimbal_control PRIVATE + fgc_core + ${OpenCV_LIBS} + Boost::headers + Boost::program_options + PkgConfig::JXL +) + +# --------------------------------------------------------------------------- +# Optional: MQTT (Eclipse Paho). Fetched + built from pinned, hash-verified +# upstream release tarballs (no AUR, no system install required). +# --------------------------------------------------------------------------- +if(WITH_MQTT) + include(cmake/Paho.cmake) + target_link_libraries(fire_gimbal_control PRIVATE PahoMqttCpp::paho-mqttpp3-static) + target_compile_definitions(fire_gimbal_control PRIVATE FGC_WITH_MQTT=1) +else() + target_compile_definitions(fire_gimbal_control PRIVATE FGC_WITH_MQTT=0) + message(STATUS "WITH_MQTT=OFF: building without MQTT support") +endif() + +# --------------------------------------------------------------------------- +# Optional: Vimba X SDK (proprietary; install manually from Allied Vision). +# --------------------------------------------------------------------------- +if(WITH_VIMBA) + find_package(Vmb REQUIRED) + target_link_libraries(fire_gimbal_control PRIVATE Vmb::VmbC Vmb::VmbCPP) + target_compile_definitions(fire_gimbal_control PRIVATE FGC_WITH_VIMBA=1) +else() + target_compile_definitions(fire_gimbal_control PRIVATE FGC_WITH_VIMBA=0) + message(STATUS "WITH_VIMBA=OFF: building without Vimba X camera support (mock cameras only)") +endif() + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- +if(BUILD_TESTING) + enable_testing() + add_subdirectory(tests) +endif() + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +message(STATUS "------------------------------------------------------------") +message(STATUS "fire_gimbal_control configuration") +message(STATUS " build type : ${CMAKE_BUILD_TYPE}") +message(STATUS " WITH_VIMBA : ${WITH_VIMBA}") +message(STATUS " WITH_MQTT : ${WITH_MQTT}") +message(STATUS " tests : ${BUILD_TESTING}") +message(STATUS "------------------------------------------------------------") diff --git a/Camera.cpp b/Camera.cpp index aecc6cb..91621aa 100644 --- a/Camera.cpp +++ b/Camera.cpp @@ -337,7 +337,6 @@ void VimbaHandler::SaveImage() cv::imshow("Display Image", img); cv::waitKey(10); } - std::string homedir = getenv("HOME"); std::string cameraname; if (pFrame->getCamId() == 0) cameraname = "RGB"; @@ -345,7 +344,7 @@ void VimbaHandler::SaveImage() cameraname = "ACR"; if (pFrame->getCamId() == 2) cameraname = "NIR"; - std::string filename = homedir + "/projects/Fire_Gimbal_Control/bin/x64/Release/" + cameraname + "/" + std::to_string(pFrame->getTimestamp()) + ".jxl"; + std::string filename = output_dir + "/" + cameraname + "/" + std::to_string(pFrame->getTimestamp()) + ".jxl"; std::filesystem::path path(filename); // Extract the directory part of the path std::filesystem::path dir = path.parent_path(); @@ -363,29 +362,9 @@ void VimbaHandler::SaveImage() std::filesystem::copy("test_smoke.jxl", filename); } time.Reset(); - //cv::imwrite(filename, img, compression_params); - std::string net_path = "/mnt/ggs-smb/FirewatchTowers/FWT_Podrosche/"; - std::string backup_net_path = "/home/ggs/fwt_image_backup/"; - - - - //try { - // std::filesystem::copy_file(filename, net_path + filename); - // std::cout << "RGB Upload TIME:" << std::to_string(time.ElapsedMillis()) << std::endl; - //} - //catch (std::filesystem::filesystem_error& e) - //{ - // std::cout << "Could not copy image to zkms: " << e.what() << '\n'; - // std::cout << "Copy Backup to LattePanda Sigma" << '\n'; - // try { - // std::filesystem::copy_file(filename, backup_net_path + filename); - // } - // catch (std::filesystem::filesystem_error& e) - // { - // std::cout << "Could not copy image to LattePanda Sigma: " << e.what() << '\n'; - // } - //} - //std::filesystem::remove(filename); + // NOTE: optional network upload of saved images to the ground + // station was removed here; it had hardcoded NFS/SMB paths. If + // reintroduced it should read its destination from config. std::string payload = "{ \"fwt\":\"" + fwt_name + "\" ,\"cam\":\"" + cameraname + "\", \"hdg\":" + std::to_string((int)(gimbal_data->hdg * 10)) + ", \"time\":" + std::to_string(pFrame->getTimestamp()) + " }"; mqtt_client->publish(mqtt_RGB, payload); } @@ -447,3 +426,8 @@ void VimbaHandler::SetTowerName(std::string name) fwt_name = name; mqtt_RGB = "GGS/FWT/" + fwt_name + "/CamEvent"; } + +void VimbaHandler::SetOutputDir(std::string dir) +{ + output_dir = std::move(dir); +} diff --git a/Camera.h b/Camera.h index 72a389e..0d7657a 100644 --- a/Camera.h +++ b/Camera.h @@ -78,6 +78,7 @@ public: bool TriggerCamera(); void TriggerSettle(); void SetTowerName(std::string name); + void SetOutputDir(std::string dir); bool cam_started = false; int queue_count_rgb = 0; @@ -86,6 +87,7 @@ private: double jxlq = 2.0; double jxle = 3.0; std::string fwt_name = "Rietschen";//"Dev"; //TODO: put in config + std::string output_dir; // base directory for saved images (set from config) std::string mqtt_RGB = "GGS/FWT/" + fwt_name + "/CamEvent"; motor_info* gimbal_data; MQTTClient* mqtt_client; diff --git a/cmake/FindVmb.cmake b/cmake/FindVmb.cmake new file mode 100644 index 0000000..4776e91 --- /dev/null +++ b/cmake/FindVmb.cmake @@ -0,0 +1,70 @@ +# FindVmb.cmake - locate the Allied Vision Vimba X SDK (VmbC + VmbCPP). +# +# The Vimba X SDK is proprietary and must be installed manually from Allied +# Vision (https://www.alliedvision.com/en/products/software/vimba-x-sdk/). +# It is NOT available through any package manager. +# +# Search hints (any one is enough): +# -DVMB_HOME=/opt/VimbaX_2024-1 (CMake cache variable) +# export VIMBA_X_HOME=/opt/VimbaX... (environment variable) +# +# Provides imported targets: +# Vmb::VmbC Vmb::VmbCPP +# and variables: Vmb_FOUND, Vmb_INCLUDE_DIR, Vmb_C_LIBRARY, Vmb_CPP_LIBRARY + +set(_vmb_hints + "${VMB_HOME}" + "$ENV{VMB_HOME}" + "$ENV{VIMBA_X_HOME}" + "$ENV{VIMBAX_HOME}" + /opt/VimbaX + /opt/VimbaX_2025-1 + /opt/VimbaX_2024-1 + /opt/vimbax + /usr/local/VimbaX +) + +find_path(Vmb_INCLUDE_DIR + NAMES VmbCPP/VmbCPP.h VmbC/VmbC.h + HINTS ${_vmb_hints} + PATH_SUFFIXES api/include include +) + +find_library(Vmb_C_LIBRARY + NAMES VmbC + HINTS ${_vmb_hints} + PATH_SUFFIXES api/lib lib lib64 +) + +find_library(Vmb_CPP_LIBRARY + NAMES VmbCPP + HINTS ${_vmb_hints} + PATH_SUFFIXES api/lib lib lib64 +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Vmb + REQUIRED_VARS Vmb_INCLUDE_DIR Vmb_C_LIBRARY Vmb_CPP_LIBRARY + FAIL_MESSAGE + "Vimba X SDK not found. Install it from Allied Vision and set -DVMB_HOME= \ +or the VIMBA_X_HOME environment variable. To build without camera support, \ +configure with -DWITH_VIMBA=OFF." +) + +if(Vmb_FOUND) + if(NOT TARGET Vmb::VmbC) + add_library(Vmb::VmbC UNKNOWN IMPORTED) + set_target_properties(Vmb::VmbC PROPERTIES + IMPORTED_LOCATION "${Vmb_C_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${Vmb_INCLUDE_DIR}") + endif() + if(NOT TARGET Vmb::VmbCPP) + add_library(Vmb::VmbCPP UNKNOWN IMPORTED) + set_target_properties(Vmb::VmbCPP PROPERTIES + IMPORTED_LOCATION "${Vmb_CPP_LIBRARY}" + INTERFACE_INCLUDE_DIRECTORIES "${Vmb_INCLUDE_DIR}" + INTERFACE_LINK_LIBRARIES "Vmb::VmbC") + endif() +endif() + +mark_as_advanced(Vmb_INCLUDE_DIR Vmb_C_LIBRARY Vmb_CPP_LIBRARY) diff --git a/cmake/Paho.cmake b/cmake/Paho.cmake new file mode 100644 index 0000000..2d89538 --- /dev/null +++ b/cmake/Paho.cmake @@ -0,0 +1,58 @@ +# Paho.cmake - provide Eclipse Paho MQTT C and C++ via FetchContent. +# +# Rationale (security): we build Paho from its OFFICIAL upstream Git repos +# rather than from the AUR. This removes the anonymous AUR packager from the +# trust chain - you trust only the Eclipse project source. +# +# HARDENING (recommended): the tags below are mutable refs. For maximum +# integrity, pin PAHO_C_TAG / PAHO_CPP_TAG to a full commit SHA (immutable), +# or switch to URL + URL_HASH (SHA256) of the signed release tarballs and +# verify the hash out-of-band. Bump the versions deliberately, not silently. +# +# NOTE: these exact tag strings and the cross-build wiring below must be +# verified on a machine with network access + cmake; they could not be built +# offline in the environment where this file was authored. + +include(FetchContent) + +set(PAHO_C_TAG "v1.3.14" CACHE STRING "Eclipse paho.mqtt.c git tag/commit") +set(PAHO_CPP_TAG "v1.4.1" CACHE STRING "Eclipse paho.mqtt.cpp git tag/commit") + +# ---- Paho MQTT C (dependency of the C++ wrapper) ---- +set(PAHO_BUILD_SHARED OFF CACHE BOOL "" FORCE) +set(PAHO_BUILD_STATIC ON CACHE BOOL "" FORCE) +set(PAHO_WITH_SSL OFF CACHE BOOL "" FORCE) # current code uses plain tcp:// +set(PAHO_ENABLE_TESTING OFF CACHE BOOL "" FORCE) +set(PAHO_BUILD_SAMPLES OFF CACHE BOOL "" FORCE) +set(PAHO_BUILD_DOCUMENTATION OFF CACHE BOOL "" FORCE) + +FetchContent_Declare(paho_mqtt_c + GIT_REPOSITORY https://github.com/eclipse-paho/paho.mqtt.c.git + GIT_TAG ${PAHO_C_TAG} + GIT_SHALLOW TRUE +) + +# ---- Paho MQTT C++ ---- +set(PAHO_BUILD_CPP_SHARED OFF CACHE BOOL "" FORCE) +set(PAHO_BUILD_CPP_STATIC ON CACHE BOOL "" FORCE) + +FetchContent_Declare(paho_mqtt_cpp + GIT_REPOSITORY https://github.com/eclipse-paho/paho.mqtt.cpp.git + GIT_TAG ${PAHO_CPP_TAG} + GIT_SHALLOW TRUE +) + +FetchContent_MakeAvailable(paho_mqtt_c paho_mqtt_cpp) + +# Provide a stable alias the top-level CMakeLists links against, regardless of +# the exact target name the upstream version exports. +if(NOT TARGET PahoMqttCpp::paho-mqttpp3-static) + if(TARGET paho-mqttpp3-static) + add_library(PahoMqttCpp::paho-mqttpp3-static ALIAS paho-mqttpp3-static) + elseif(TARGET paho-mqttpp3) + add_library(PahoMqttCpp::paho-mqttpp3-static ALIAS paho-mqttpp3) + else() + message(FATAL_ERROR "Paho C++ static target not found after FetchContent; " + "check PAHO_CPP_TAG and upstream target names.") + endif() +endif() diff --git a/config/config.example.ini b/config/config.example.ini index a6a4ad3..ffb58d3 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -29,3 +29,23 @@ id_Cam1 = DEV_0000000000 id_Cam2 = id_Cam3 = id_Cam4 = + +[Serial] +; Motor-controller serial device and baud rate. +device = /dev/ttyACM0 +baud = 115200 + +[Paths] +; Directory for saved .jxl images. Supports leading ~ and $ENV expansion. +; If blank, defaults to $XDG_DATA_HOME/fire_gimbal_control/images +; (i.e. ~/.local/share/fire_gimbal_control/images). +output_dir = + +[Features] +; Enable/disable subsystems and select mock (simulated) implementations. +; CLI flags (e.g. --no-mqtt, --mock-camera, --mock-serial) override these. +enable_mqtt = true +enable_camera = true +enable_serial = true +mock_camera = false +mock_serial = false diff --git a/include/fgc/Config.h b/include/fgc/Config.h new file mode 100644 index 0000000..7bdef58 --- /dev/null +++ b/include/fgc/Config.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include +#include + +namespace fgc { + +// Typed application configuration. Replaces the ad-hoc std::map +// lookups that were scattered through main.cpp. + +struct GeneralConfig { + std::string tower_name = "Unnamed"; + int image_interval = 5; // seconds between captures + bool debug = false; // print motor telemetry each loop tick +}; + +struct NetworkConfig { + std::string broker_ip = "127.0.0.1"; // MQTT broker / ZKMS server + std::string mqtt_user; // see secrets note below + std::string mqtt_pw; +}; + +struct SerialConfig { + std::string device = "/dev/ttyACM0"; + unsigned int baud = 115200; +}; + +struct CameraConfig { + std::vector ids; // GigE IP or USB DEV_ id, in order + std::vector labels = {"RGB", "ACR", "NIR"}; // index -> output subfolder +}; + +struct PathsConfig { + // Where captured .jxl images are written. Supports leading ~ and $ENV + // expansion. Empty => resolved to a sensible default at load time. + std::string output_dir; +}; + +struct FeaturesConfig { + bool enable_mqtt = true; + bool enable_camera = true; + bool enable_serial = true; + bool mock_camera = false; // use a simulated camera instead of Vimba X + bool mock_serial = false; // use a simulated motor controller +}; + +struct AppConfig { + GeneralConfig general; + NetworkConfig network; + SerialConfig serial; + CameraConfig camera; + PathsConfig paths; + FeaturesConfig features; + + // Capture rate in images/second (derived from general.image_interval). + double image_rate() const; +}; + +// Loads and validates configuration. +// +// Secrets: mqtt_user / mqtt_pw are taken from the environment variables +// FGC_MQTT_USER / FGC_MQTT_PW when present; the INI values are a fallback. +class ConfigLoader { +public: + // Reads the INI file at `path` (via the bundled inih parser), maps it into + // a typed AppConfig, applies environment overrides, and validates it. + // Throws std::runtime_error with a clear message on failure. + static AppConfig loadFromFile(const std::string& path); + + // Same mapping/validation logic, but from an already-parsed key->value map + // ("Section.name" => value). Exposed for unit testing without file IO. + static AppConfig fromMap(const std::map& kv); +}; + +} // namespace fgc diff --git a/include/fgc/Paths.h b/include/fgc/Paths.h new file mode 100644 index 0000000..39a3fcd --- /dev/null +++ b/include/fgc/Paths.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +namespace fgc::paths { + +// Expand a leading "~" (to $HOME) and any "$VAR" / "${VAR}" environment +// references in `path`. Returns the expanded path (unchanged if nothing to do). +std::string expandUser(const std::string& path); + +// Absolute directory containing the running executable (via /proc/self/exe). +// Returns empty string if it cannot be determined. +std::string executableDir(); + +// The ordered list of locations searched for a config file, given an optional +// explicit --config argument. Exposed so callers can report what was tried. +std::vector configSearchPaths(const std::string& cliArg = ""); + +// Resolve the config file path using configSearchPaths(): the first existing, +// readable file wins. Returns nullopt if none is found. +std::optional resolveConfigPath(const std::string& cliArg = ""); + +// Default image output directory when none is configured +// ($XDG_DATA_HOME/fire_gimbal_control/images, else ~/.local/share/...). +std::string defaultOutputDir(); + +} // namespace fgc::paths diff --git a/main.cpp b/main.cpp index 237f5b6..d41a9e3 100644 --- a/main.cpp +++ b/main.cpp @@ -10,7 +10,8 @@ #include "Camera.h" #include "MQTT.h" #include "timing.h" -#include "ini.h" +#include "fgc/Config.h" +#include "fgc/Paths.h" namespace po = boost::program_options; @@ -31,14 +32,6 @@ mqtt_sub_data mqtt_in; MQTTClient* mqtt_client = nullptr; std::vector camera_id_vec; //= { "192.168.11.101" , "192.168.11.102", "192.168.11.103" }; //{ "DEV_1AB22C04F7A9" }; //{ "192.168.11.101" , "192.168.11.102", "192.168.11.103" }; -// Callback function for parsing -static int handler(void* user, const char* section, const char* name, const char* value) { - auto* config = static_cast*>(user); - std::string key = std::string(section) + "." + std::string(name); - (*config)[key] = value; - return 1; // Return 0 to stop parsing on error -} - void readInput() { std::string input; while (running) { @@ -60,44 +53,42 @@ void readInput() { int main(int argc, char* argv[]) { - //ini handling with ini.h - std::map config; - // Parse the INI file - if (ini_parse("/home/ggs/projects/Fire_Gimbal_Control/bin/x64/Release/config.ini", handler, &config) < 0) { - std::cerr << "Can't load config.ini file!" << std::endl; + // Resolve the config file from a search order (no hardcoded paths): + // --config (added in a later phase) -> $FGC_CONFIG -> ./config.ini + // -> /config.ini -> $XDG_CONFIG_HOME/fire_gimbal_control/config.ini + auto config_path = fgc::paths::resolveConfigPath(); + if (!config_path) { + std::cerr << "No config.ini found. Searched:\n"; + for (const auto& p : fgc::paths::configSearchPaths()) + std::cerr << " - " << p << "\n"; + std::cerr << "Copy config/config.example.ini to one of these locations " + "or set $FGC_CONFIG." << std::endl; return 1; } - // Access configuration values - std::string tower_name = config["General.tower_name"]; - int rate = std::stoi(config["General.image_interval"]); - int debug = std::stoi(config["General.debug"]); - std::string zkms_server_ip = config["Network.zkms_server_ip"]; - std::string mqtt_user = config["Network.mqtt_user"]; - std::string mqtt_pw = config["Network.mqtt_pw"]; - std::string cam_id1 = config["Camera.id_Cam1"]; - std::string cam_id2 = config["Camera.id_Cam2"]; - std::string cam_id3 = config["Camera.id_Cam3"]; - std::string cam_id4 = config["Camera.id_Cam4"]; - if (cam_id1 != "") - camera_id_vec.push_back(cam_id1); - if (cam_id2 != "") - camera_id_vec.push_back(cam_id2); - if (cam_id3 != "") - camera_id_vec.push_back(cam_id3); - if (cam_id4 != "") - camera_id_vec.push_back(cam_id4); - std::cout << camera_id_vec.size() << " camera(s) should be connected according the config file." << "\n"; - - //set globals - fwt_name = tower_name; - imagerate = 1/(double)rate; - motorctl_info_out = (bool)debug; - server_ip = zkms_server_ip; - // Print the parsed values - std::cout << "tower_name: " << tower_name << std::endl; - std::cout << "rate: " << rate << std::endl; - std::cout << "debug: " << debug << std::endl; - std::cout << "zkms_server_ip: " << zkms_server_ip << std::endl; + + fgc::AppConfig cfg; + try { + cfg = fgc::ConfigLoader::loadFromFile(*config_path); + } catch (const std::exception& e) { + std::cerr << "Config error (" << *config_path << "): " << e.what() << std::endl; + return 1; + } + + // Bridge typed config into the existing globals/locals. + camera_id_vec = cfg.camera.ids; + fwt_name = cfg.general.tower_name; + imagerate = cfg.image_rate(); + motorctl_info_out = cfg.general.debug; + server_ip = cfg.network.broker_ip; + std::string mqtt_user = cfg.network.mqtt_user; + std::string mqtt_pw = cfg.network.mqtt_pw; + + std::cout << "Loaded config: " << *config_path << "\n"; + std::cout << camera_id_vec.size() << " camera(s) configured.\n"; + std::cout << "tower_name: " << fwt_name << "\n"; + std::cout << "image_interval: " << cfg.general.image_interval << " s\n"; + std::cout << "debug: " << cfg.general.debug << "\n"; + std::cout << "broker: " << server_ip << std::endl; //args @@ -152,7 +143,7 @@ int main(int argc, char* argv[]) return 1; } //main vars - SerialPort serial("/dev/ttyACM0", 115200); + SerialPort serial(cfg.serial.device, cfg.serial.baud); std::thread io_thread; const std::string mqtt_STATUS = "GGS/FWT/" + fwt_name + "/StatusCode"; @@ -216,6 +207,7 @@ int main(int argc, char* argv[]) //Camera Obj VimbaHandler cam_handler(camera_id_vec, mqtt_client, &act_info, start_demo); cam_handler.SetTowerName(fwt_name); + cam_handler.SetOutputDir(cfg.paths.output_dir); //init Gimbal if (init_gimbal) { std::this_thread::sleep_for(std::chrono::milliseconds(500)); diff --git a/src/core/Config.cpp b/src/core/Config.cpp new file mode 100644 index 0000000..b179f32 --- /dev/null +++ b/src/core/Config.cpp @@ -0,0 +1,109 @@ +#include "fgc/Config.h" +#include "fgc/Paths.h" + +#include +#include + +extern "C" { +#include "ini.h" +} + +namespace fgc { + +namespace { + +std::string get(const std::map& kv, const std::string& key, + const std::string& fallback = "") { + auto it = kv.find(key); + return (it != kv.end() && !it->second.empty()) ? it->second : fallback; +} + +int getInt(const std::map& kv, const std::string& key, int fallback) { + auto it = kv.find(key); + if (it == kv.end() || it->second.empty()) return fallback; + try { + return std::stoi(it->second); + } catch (const std::exception&) { + throw std::runtime_error("Config key '" + key + "' must be an integer, got '" + + it->second + "'"); + } +} + +bool getBool(const std::map& kv, const std::string& key, bool fallback) { + auto it = kv.find(key); + if (it == kv.end() || it->second.empty()) return fallback; + const std::string& v = it->second; + if (v == "1" || v == "true" || v == "yes" || v == "on") return true; + if (v == "0" || v == "false" || v == "no" || v == "off") return false; + throw std::runtime_error("Config key '" + key + "' must be a boolean, got '" + v + "'"); +} + +std::string envOr(const char* name, const std::string& fallback) { + const char* v = std::getenv(name); + return (v && *v) ? std::string(v) : fallback; +} + +// inih callback: flatten "Section.name" => value into the map. +int iniHandler(void* user, const char* section, const char* name, const char* value) { + auto* kv = static_cast*>(user); + (*kv)[std::string(section) + "." + std::string(name)] = value ? value : ""; + return 1; +} + +} // namespace + +double AppConfig::image_rate() const { + return general.image_interval > 0 ? 1.0 / general.image_interval : 0.0; +} + +AppConfig ConfigLoader::fromMap(const std::map& kv) { + AppConfig cfg; + + cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name); + cfg.general.image_interval = getInt(kv, "General.image_interval", cfg.general.image_interval); + cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug); + + cfg.network.broker_ip = get(kv, "Network.zkms_server_ip", cfg.network.broker_ip); + // Secrets: environment variables win over the file. + cfg.network.mqtt_user = envOr("FGC_MQTT_USER", get(kv, "Network.mqtt_user")); + cfg.network.mqtt_pw = envOr("FGC_MQTT_PW", get(kv, "Network.mqtt_pw")); + + cfg.serial.device = get(kv, "Serial.device", cfg.serial.device); + cfg.serial.baud = static_cast(getInt(kv, "Serial.baud", cfg.serial.baud)); + + cfg.camera.ids.clear(); + for (const char* key : {"Camera.id_Cam1", "Camera.id_Cam2", "Camera.id_Cam3", "Camera.id_Cam4"}) { + std::string id = get(kv, key); + if (!id.empty()) cfg.camera.ids.push_back(id); + } + + std::string out = get(kv, "Paths.output_dir"); + cfg.paths.output_dir = out.empty() ? paths::defaultOutputDir() : paths::expandUser(out); + + cfg.features.enable_mqtt = getBool(kv, "Features.enable_mqtt", cfg.features.enable_mqtt); + cfg.features.enable_camera = getBool(kv, "Features.enable_camera", cfg.features.enable_camera); + cfg.features.enable_serial = getBool(kv, "Features.enable_serial", cfg.features.enable_serial); + cfg.features.mock_camera = getBool(kv, "Features.mock_camera", cfg.features.mock_camera); + cfg.features.mock_serial = getBool(kv, "Features.mock_serial", cfg.features.mock_serial); + + // ---- validation ---- + if (cfg.general.image_interval <= 0) + throw std::runtime_error("General.image_interval must be > 0 (got " + + std::to_string(cfg.general.image_interval) + ")"); + if (cfg.serial.baud == 0) + throw std::runtime_error("Serial.baud must be > 0"); + + return cfg; +} + +AppConfig ConfigLoader::loadFromFile(const std::string& path) { + std::map kv; + int rc = ini_parse(path.c_str(), iniHandler, &kv); + if (rc < 0) throw std::runtime_error("Cannot read config file: " + path); + if (rc > 0) + throw std::runtime_error("Parse error in config file " + path + " at line " + + std::to_string(rc)); + return fromMap(kv); +} + +} // namespace fgc diff --git a/src/core/Paths.cpp b/src/core/Paths.cpp new file mode 100644 index 0000000..eddbf39 --- /dev/null +++ b/src/core/Paths.cpp @@ -0,0 +1,95 @@ +#include "fgc/Paths.h" + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace fgc::paths { + +namespace { + +std::string envOr(const char* name, const std::string& fallback) { + const char* v = std::getenv(name); + return (v && *v) ? std::string(v) : fallback; +} + +} // namespace + +std::string expandUser(const std::string& path) { + if (path.empty()) return path; + + std::string out; + out.reserve(path.size()); + + // Leading ~ -> $HOME + size_t i = 0; + if (path[0] == '~' && (path.size() == 1 || path[1] == '/')) { + out += envOr("HOME", ""); + i = 1; + } + + // $VAR and ${VAR} expansion + for (; i < path.size(); ++i) { + if (path[i] == '$') { + size_t start = i + 1; + bool braced = (start < path.size() && path[start] == '{'); + if (braced) ++start; + size_t end = start; + while (end < path.size() && + (std::isalnum(static_cast(path[end])) || path[end] == '_')) { + ++end; + } + std::string name = path.substr(start, end - start); + if (!name.empty()) { + out += envOr(name.c_str(), ""); + i = braced && end < path.size() && path[end] == '}' ? end : end - 1; + continue; + } + } + out += path[i]; + } + return out; +} + +std::string executableDir() { + std::error_code ec; + fs::path self = fs::read_symlink("/proc/self/exe", ec); + if (ec) return {}; + return self.parent_path().string(); +} + +std::vector configSearchPaths(const std::string& cliArg) { + std::vector paths; + if (!cliArg.empty()) paths.push_back(expandUser(cliArg)); + + if (const char* env = std::getenv("FGC_CONFIG"); env && *env) + paths.push_back(expandUser(env)); + + paths.push_back("config.ini"); // current working directory + + if (std::string dir = executableDir(); !dir.empty()) + paths.push_back((fs::path(dir) / "config.ini").string()); + + std::string xdg = envOr("XDG_CONFIG_HOME", expandUser("~/.config")); + if (!xdg.empty()) + paths.push_back((fs::path(xdg) / "fire_gimbal_control" / "config.ini").string()); + + return paths; +} + +std::optional resolveConfigPath(const std::string& cliArg) { + std::error_code ec; + for (const auto& p : configSearchPaths(cliArg)) { + if (fs::is_regular_file(p, ec)) return p; + } + return std::nullopt; +} + +std::string defaultOutputDir() { + std::string base = envOr("XDG_DATA_HOME", expandUser("~/.local/share")); + return (fs::path(base) / "fire_gimbal_control" / "images").string(); +} + +} // namespace fgc::paths