fwt_software/main.cpp

234 lines
11 KiB
C++

#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 <iostream>
#include <sstream>
#include <unistd.h>
#include <boost/program_options.hpp>
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<std::string>(), "path to config.ini")
("hardware", po::value<std::string>(),
"path to hardware.ini (default: $FGC_HARDWARE, /etc/fire_gimbal_control/hardware.ini, "
"or one beside config.ini)")
("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)")
("start,s", po::bool_switch(), "start in auto mode (sweep and capture); without it, manual")
("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<std::string>(), "path to the single-instance lockfile")
("log-level", po::value<std::string>(),
"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<std::string>(),
"log-file verbosity, independent of --log-level (default debug)")
("test", po::value<std::string>(),
"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; cannot be combined with --start")
("trace", po::value<std::string>(),
"verbatim wire trace, comma list: serial,mqtt,camera,control,all,none");
po::variables_map vm;
try {
po::store(po::parse_command_line(argc, argv, desc), 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.count("test")) {
const std::string t = vm["test"].as<std::string>();
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;
}
if (vm["start"].as<bool>()) {
// Capture would move the gimbal and hold the serial link the test needs.
std::cerr << "Argument error: --test cannot be combined with --start\n";
return 1;
}
}
const std::string cli_cfg = vm.count("config") ? vm["config"].as<std::string>() : "";
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 << "Copy config/config.example.ini to one of these, 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<std::string>() : "";
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>() : std::string();
const bool opts_no_log_file = vm["no-log-file"].as<bool>();
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<long long>(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;
// 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<bool>()) {
const std::string lock_path = vm.count("lock-file")
? paths::expandUser(vm["lock-file"].as<std::string>())
: 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;
opts.init = vm["init"].as<bool>();
opts.start = vm["start"].as<bool>();
if (vm["no-mqtt"].as<bool>()) opts.use_mqtt = false;
if (vm["mock-camera"].as<bool>()) opts.mock_camera = true;
if (vm["mock-serial"].as<bool>()) opts.mock_serial = true;
if (vm["mock-env"].as<bool>()) opts.mock_env = true;
opts.degraded = vm["degraded"].as<bool>();
if (vm["tui"].as<bool>()) opts.use_tui = true;
if (vm["no-tui"].as<bool>()) opts.use_tui = false; // --no-tui wins
if (vm.count("log-level")) opts.log_level = vm["log-level"].as<std::string>();
if (vm.count("trace")) opts.trace_categories = vm["trace"].as<std::string>();
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<std::string>();
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;
}
}