#include "fgc/Application.h" #include "fgc/Config.h" #include "fgc/FileLogSink.h" #include "fgc/Logger.h" #include "fgc/Paths.h" #include "fgc/SingleInstance.h" #include #include #include #include namespace po = boost::program_options; using namespace fgc; namespace { std::string want_or(const std::string& s, const char* fallback) { return s.empty() ? std::string(fallback) : s; } // Written at the top of every log file, and again after each rotation, so an // archive found on its own still says which build and tower produced it. std::string fileLogHeader(const fgc::AppConfig& cfg, const std::string& cfg_path, const std::string& hw_path, int argc, char* argv[]) { std::ostringstream h; h << "=== fire_gimbal_control ===\n"; h << "build " << FGC_GIT_REV << "\n"; h << "tower " << cfg.general.tower_name << "\n"; h << "pid " << ::getpid() << "\n"; h << "config " << cfg_path << "\n"; h << "hardware " << (hw_path.empty() ? "(none)" : hw_path) << "\n"; h << "argv "; for (int i = 0; i < argc; ++i) h << ' ' << argv[i]; h << "\n==="; return h.str(); } } // namespace int main(int argc, char* argv[]) { po::options_description desc("Fire Gimbal Control options"); desc.add_options() ("help,h", "show this help") ("config,c", po::value(), "path to config.ini") ("hardware", po::value(), "path to hardware.ini (default: $FGC_HARDWARE, /etc/fire_gimbal_control/hardware.ini, " "or one beside config.ini)") ("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)") ("mock-camera", po::bool_switch(), "use a simulated camera (no hardware)") ("mock-serial", po::bool_switch(), "use a simulated motor controller (no hardware)") ("mock-env", po::bool_switch(), "use a simulated ambient temp/humidity sensor (implies enable_env)") ("tui", po::bool_switch(), "show the full-screen terminal dashboard") ("no-tui", po::bool_switch(), "force the headless line console (overrides config)") ("degraded", po::bool_switch(), "start even though hardware.ini declares a subsystem this rig does not have " "(field triage; says so loudly in the log)") ("no-lock", po::bool_switch(), "skip the single-instance lock (dev only: for a second mock-only instance)") ("lock-file", po::value(), "path to the single-instance lockfile") ("log-level", po::value(), "console verbosity: trace|debug|info|warn|error|off. Does NOT silence the log " "file, which has its own level") ("no-log-file", po::bool_switch(), "do not write a persistent log file this run") ("log-file-level", po::value(), "log-file verbosity, independent of --log-level (default debug)") ("test", po::value(), "run one self-test once the motor link is up, then exit: 0 PASS, 4 FAIL, 5 could " "not start. Takes what follows `test` at the console, e.g. --test hardware or " "--test \"hardware motor\". Headless; ignores [Startup], so it neither homes nor " "leaves manual") ("trace", po::value(), "verbatim wire trace, comma list: serial,mqtt,camera,control,all,none"); // The removed startup flags, accepted only so that using one says where the // setting went instead of boost's bare "unrecognised option". A boot service // still carrying them must not look like a typo. po::options_description removed; removed.add_options() ("init,i", po::bool_switch(), "") ("start,s", po::bool_switch(), ""); po::options_description all; all.add(desc).add(removed); po::variables_map vm; try { po::store(po::parse_command_line(argc, argv, all), vm); po::notify(vm); } catch (const std::exception& e) { std::cerr << "Argument error: " << e.what() << "\n"; return 1; } if (vm.count("help")) { std::cout << desc << "\n"; return 0; } if (vm["init"].as() || vm["start"].as()) { std::cerr << "Argument error: --init and --start were removed. Whether the tower homes " "and which mode it starts in are set in config.ini:\n" " [Startup]\n" " home = true ; was --init\n" " mode = auto ; was --start (manual otherwise)\n" "A boot service still passing them: re-run scripts/install-tower, which " "moves them into config.ini.\n"; return 1; } if (vm.count("test")) { const std::string t = vm["test"].as(); if (t.find_first_not_of(" \t") == std::string::npos) { std::cerr << "Argument error: --test needs a selection, e.g. --test hardware\n"; return 1; } } const std::string cli_cfg = vm.count("config") ? vm["config"].as() : ""; auto cfg_path = paths::resolveConfigPath(cli_cfg); if (!cfg_path) { std::cerr << "No config.ini found. Searched:\n"; for (const auto& p : paths::configSearchPaths(cli_cfg)) std::cerr << " - " << p << "\n"; std::cerr << "Run scripts/tower-config seed to create one in " << paths::towerConfigDir() << ", pass --config, or set $FGC_CONFIG.\n"; return 1; } // hardware.ini describes the machine itself and is optional: a tower that has // not been migrated yet simply has none, and everything comes from config.ini // exactly as before. An explicit --hardware that does not exist IS an error, // though - it means someone expected an assembly description to be in force. const std::string cli_hw = vm.count("hardware") ? vm["hardware"].as() : ""; auto hw_path = paths::resolveHardwarePath(cli_hw, *cfg_path); if (!cli_hw.empty() && !hw_path) { std::cerr << "No hardware.ini at --hardware " << cli_hw << "\n"; return 1; } // Resolved before the config is even loaded, because the install block below // needs them and it runs before Application::run() touches its own options. const std::string opts_log_file_level = vm.count("log-file-level") ? vm["log-file-level"].as() : std::string(); const bool opts_no_log_file = vm["no-log-file"].as(); AppConfig cfg; try { cfg = ConfigLoader::loadFromFile(*cfg_path, hw_path.value_or("")); } catch (const std::exception& e) { std::cerr << "Config error (" << *cfg_path << "): " << e.what() << "\n"; return 1; } // Start the persistent log HERE - before the first LOG_INFO below, and well // before Application::run(). // // Two reasons it cannot wait: run() can refuse to start at // reconcileHardware() and return before the UI exists, so a sink installed // there would miss the very message explaining why the tower did not come // up; and in TUI mode every line otherwise lands in a 500-line on-screen // ring buffer that dies with the tmux session. // // Anything logged earlier than this (bad arguments, no config.ini found) // happens before the log's own path is knowable, and stays console-only. FileLogSink file_log; if (cfg.logging.file && !opts_no_log_file) { FileLogPolicy pol; pol.dir = cfg.logging.file_dir; pol.max_bytes = static_cast(cfg.logging.file_max_mb) * 1024 * 1024; pol.keep_files = cfg.logging.file_keep; LogLevel flvl = LogLevel::Debug; // the file is normally more verbose const std::string want = !opts_log_file_level.empty() ? opts_log_file_level : cfg.logging.file_level; bool bad_level = false; if (!want.empty()) { LogLevel parsed{}; if (Logger::setFileLevelFromString(want)) parsed = Logger::fileLevel(); else bad_level = true; if (!bad_level) flvl = parsed; } Logger::setFileLevel(flvl); if (file_log.open(pol, fileLogHeader(cfg, *cfg_path, hw_path.value_or(""), argc, argv))) { Logger::setFileSink([&file_log](LogLevel l, LogCat c, const std::string& line) { file_log.write(l, c, line); }); } // Reported from OUT HERE, never from inside the sink: the sink runs // under Logger's non-recursive mutex and logging from it deadlocks. if (file_log.failed()) { Logger::setFileLevel(LogLevel::Off); LOG_WARN << "no log file: " << file_log.lastError(); } else { LOG_INFO << "logging to " << file_log.path() << " (level " << want_or(want, "debug") << ", " << cfg.logging.file_keep << " x " << cfg.logging.file_max_mb << " MB)"; } if (bad_level) LOG_WARN << "unknown Logging.file_level '" << want << "', using debug"; } LOG_INFO << "Loaded config: " << *cfg_path; if (paths::configInOldPlace(cli_cfg, *cfg_path)) LOG_WARN << "this tower's config.ini is still in the project folder, where a deploy " "can reach it: run scripts/tower-config move to put it in " << paths::towerConfigDir(); // Only one instance may run: a second would fight the first over the motor's // serial port, the camera, and the exposure store. The lock is held for the // whole run and released when this scope ends (or the process dies). SingleInstance lock; if (!vm["no-lock"].as()) { const std::string lock_path = vm.count("lock-file") ? paths::expandUser(vm["lock-file"].as()) : SingleInstance::defaultLockPath(); lock = SingleInstance::acquire(lock_path); if (!lock) { std::cerr << "Cannot start: " << lock.error() << "\n"; return 2; // distinct from 1 so scripts/fgc can tell this apart } LOG_INFO << "Single-instance lock held: " << lock.path(); } RuntimeOptions opts; if (vm["no-mqtt"].as()) opts.use_mqtt = false; if (vm["mock-camera"].as()) opts.mock_camera = true; if (vm["mock-serial"].as()) opts.mock_serial = true; if (vm["mock-env"].as()) opts.mock_env = true; opts.degraded = vm["degraded"].as(); if (vm["tui"].as()) opts.use_tui = true; if (vm["no-tui"].as()) opts.use_tui = false; // --no-tui wins if (vm.count("log-level")) opts.log_level = vm["log-level"].as(); if (vm.count("trace")) opts.trace_categories = vm["trace"].as(); opts.config_path = *cfg_path; // so the setup commands can save back to it if (file_log.isOpen()) opts.log_file = [&file_log] { return file_log.path(); }; if (vm.count("test")) { opts.run_test = vm["test"].as(); opts.use_tui = false; // a script wants lines on stdout, not a dashboard } Application app(std::move(cfg), std::move(opts)); try { return app.run(); } catch (const std::exception& e) { // Startup can still reject the configuration once it touches the // filesystem (a missing camera-mode directory, say). Report that the way // a bad config.ini is reported, not as an unhandled terminate. std::cerr << "Startup failed: " << e.what() << "\n"; return 1; } }