Compare commits

..

No commits in common. "2c05c08f24b74a0a538a8b742fd58d4b7785052c" and "c972aba4d3365d2aab7a5a10e177069480289bd1" have entirely different histories.

15 changed files with 21 additions and 203 deletions

View File

@ -1,10 +1,11 @@
# Fire Gimbal Control
# Fire Gimbal Control (Staeffelsberg)
Real-time control software for an automated **fire-watch gimbal**. A C++17 program runs on a tower-mounted
PC, rotates a pan gimbal carrying up to four industrial cameras, captures a 360° panorama for wildfire
detection, compresses each frame to JPEG XL, and reports to a ground station over MQTT.
A single codebase serves all fire-watch towers (FWT); the tower identity comes from `config.ini`.
This is the deployment for the **Staeffelsberg** fire-watch tower (FWT). The same codebase serves all towers;
the tower identity comes from `config.ini`.
The code is organized around an **SDK-independent core** (`fgc_core`: config, logging, capture scheduler,
parsers) and **swappable I/O implementations** behind three interfaces — motor controller, control channel,

View File

@ -77,10 +77,6 @@ pitch_counts_per_deg = 8333.33
pitch_zero_count = 0
pitch_min_deg = -10
pitch_max_deg = 30
; Live encoder-tracking WARN: log a warning when an axis's |xactual - xenc|
; exceeds this many counts (positional slip). 0 disables. Default 400 sits above
; the firmware hold deadband (200) and below the DIAG peak limit (500).
enc_error_warn_counts = 400
[Scan]
; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through).

View File

@ -80,7 +80,7 @@ Set at least:
```ini
[General]
tower_name = ExampleTower ; this tower's name (drives all MQTT topics)
tower_name = Staeffelsberg ; this tower's name (drives all MQTT topics)
image_interval = 3
[Network]
@ -173,7 +173,7 @@ Confirm end to end:
- Images accumulating under the configured `[Paths] output_dir` (default
`~ggs/.local/share/fire_gimbal_control/images/RGB/`).
- CamEvents on the broker:
`mosquitto_sub -h <broker> -t 'GGS/FWT/<tower>/#' -v`.
`mosquitto_sub -h <broker> -t 'GGS/FWT/Staeffelsberg/#' -v`.
## 9. Update / redeploy

View File

@ -58,7 +58,7 @@ When a ControlCode message arrives, the program echoes the current code back on
Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
```json
{ "fwt":"ExampleTower", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678 }
{ "fwt":"Staeffelsberg", "cam":"RGB", "hdg":1373, "pit":300, "time":1719312345678 }
```
| Field | Type | Meaning |
@ -87,7 +87,7 @@ publish: GGS/FWT/<tower>/StatusCode (echoed control code)
Point `zkms_server_ip` at a local broker and exercise the topics with Mosquitto:
```bash
mosquitto_sub -t 'GGS/FWT/ExampleTower/#' -v # watch everything for the tower
mosquitto_pub -t 'GGS/FWT/ExampleTower/ControlCode' -m '0'
mosquitto_pub -t 'GGS/FWT/ExampleTower/target_HDG' -m '180'
mosquitto_sub -t 'GGS/FWT/Staeffelsberg/#' -v # watch everything for the tower
mosquitto_pub -t 'GGS/FWT/Staeffelsberg/ControlCode' -m '0'
mosquitto_pub -t 'GGS/FWT/Staeffelsberg/target_HDG' -m '180'
```

View File

@ -93,7 +93,6 @@ struct AppConfig {
UiConfig ui; // [UI] terminal dashboard toggle
Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch)
YawHomeZero yaw_home_zero = YawHomeZero::Off; // [Motor] re-anchor yaw zero on homing
long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off)
ScanConfig scan; // [Scan] grid source
ImuConfig imu; // [IMU] Xsens MTi serial device

View File

@ -21,10 +21,6 @@ struct DumpAxis {
long soft_margin = 0; // counts kept clear of each hard limit
long hold_target = 0;
long speed = 0;
// Encoder-tracking health accumulated by the firmware hold corrector.
long hold_corrections = 0; // # of hold resyncs since firmware boot/RESET
long hold_peak_dev = 0; // largest deviation seen while holding (counts)
long hold_cumulative_dev = 0; // sum of corrected deviations (counts)
int hsub = 0;
// Raw register hex strings as received, keyed by name (GCONF, DRV_STATUS...).

View File

@ -58,8 +58,7 @@ public:
d << "DUMP " << L << " state=" << st
<< " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
<< " speed=200000 eeprom_restored=1 soft_margin=5000"
<< " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n";
<< " speed=200000 eeprom_restored=1 soft_margin=5000 has_encoder=1\n";
d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000"
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"
<< " XACTUAL=0x" << std::hex << (a.xactual & 0xffffffff)

View File

@ -47,9 +47,6 @@ private:
// When set, the 100ms auto-redraw is suspended so the screen holds still and
// the operator can select/copy text (terminals drop a selection on repaint).
std::atomic<bool> paused_{false};
// True while the gimbal overlay is open, so the refresh loop can periodically
// re-pull a firmware dump (keeps the tracking counters ticking without 'd').
std::atomic<bool> gimbal_overlay_open_{false};
// Log ring buffer (newest last), filled by the Logger sink.
std::mutex log_mutex_;

View File

@ -23,7 +23,6 @@ struct AxisView {
double deg = 0.0; // heading/elevation, from xenc via Geometry
long xactual = 0;
long xenc = 0;
long enc_err = 0; // live tracking error = xactual - xenc (counts)
double target_deg = 0.0; // current scheduler target
int sg = 0; // SG_RESULT (live, from ST line)
int cs = 0; // CS_ACTUAL
@ -42,12 +41,6 @@ struct AxisView {
long lim_pos = 0;
double lim_neg_deg = 0.0;
double lim_pos_deg = 0.0;
// Encoder-tracking health (from the last firmware dump): disturbances the
// firmware hold corrector counted/erased. has_track gates rendering.
bool has_track = false;
long hold_corrections = 0;
long hold_peak_dev = 0;
long hold_cumulative_dev = 0;
};
struct GimbalView {
@ -204,23 +197,6 @@ struct CalibResultView {
CalibAxisView yaw, pitch;
};
// Worst-case encoder tracking error from the last DIAG run, per axis, for the
// gimbal expanded view. err_* are the largest values across that axis's tests
// (-1 when the axis has no encoder).
struct DiagAxisView {
char axis = '?';
bool has = false;
bool pass = false;
long err_peak = 0; // worst following error during the moves (counts)
long err_rms = 0; // worst RMS following error (counts)
long err_still = 0; // worst standstill error (counts)
};
struct DiagResultView {
bool has = false;
long long ts_ms = 0;
std::vector<DiagAxisView> axes; // Y and (if present) P
};
struct UiSnapshot {
HeaderView header;
GimbalView gimbal;
@ -232,7 +208,6 @@ struct UiSnapshot {
ImuView imu;
ActivityView activity;
CalibResultView calib;
DiagResultView diag;
};
// ---- Pure formatting helpers (unit-tested in tests/test_uisnapshot.cpp) ----

View File

@ -78,10 +78,6 @@ void ImagePipeline::process(const Frame& frame) {
const fs::path dir = fs::path(params_.output_dir) / label;
std::error_code ec;
fs::create_directories(dir, ec);
if (ec) {
LOG_ERROR << "Cannot create capture dir " << dir.string() << ": " << ec.message();
return;
}
const fs::path file = dir / (std::to_string(frame.timestamp_ms) + ".jxl");
if (params_.demo) {

View File

@ -24,15 +24,12 @@
#include "fgc/ui/IUserInterface.h"
#include "fgc/ui/UiSnapshot.h"
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <memory>
#include <mutex>
#include <queue>
@ -60,22 +57,6 @@ long long nowEpochMs() {
.count();
}
// True if `dir` can be created and a probe file written there. A successful
// create_directories alone isn't enough (the dir may exist read-only), so we
// actually write+remove a probe file.
bool outputDirWritable(const std::string& dir) {
namespace fs = std::filesystem;
std::error_code ec;
fs::create_directories(dir, ec);
if (ec) return false;
const fs::path probe = fs::path(dir) / ".fgc_write_test";
std::ofstream f(probe);
if (!f.good()) return false;
f.close();
fs::remove(probe, ec);
return true;
}
// Human-readable list of the enabled wire-trace categories, for echoing back.
std::string traceNames(unsigned mask) {
if (mask == 0) return "none";
@ -183,10 +164,6 @@ struct Application::Impl {
std::vector<std::string> last_diag_summary;
long long last_diag_ts = 0; // epoch ms
bool last_diag_pass = false;
DiagResultView last_diag_view; // per-axis worst tracking error
// Rising-edge latches for the live encoder-error WARN (avoid 100Hz spam).
bool yaw_track_over_ = false;
bool pitch_track_over_ = false;
CalibResultView last_calib_view;
std::vector<std::string> last_calib_summary;
long long last_calib_ts = 0; // epoch ms
@ -288,7 +265,6 @@ struct Application::Impl {
v.deg = map.toDeg(a.xenc);
v.xactual = a.xactual;
v.xenc = a.xenc;
v.enc_err = a.xactual - a.xenc; // live tracking error (counts)
v.target_deg = map.toDeg(target_counts);
v.sg = a.sg;
v.cs = a.cs;
@ -316,10 +292,6 @@ struct Application::Impl {
v.lim_pos = a.lim_pos;
v.lim_neg_deg = map.toDeg(a.lim_neg);
v.lim_pos_deg = map.toDeg(a.lim_pos);
v.has_track = true;
v.hold_corrections = a.hold_corrections;
v.hold_peak_dev = a.hold_peak_dev;
v.hold_cumulative_dev = a.hold_cumulative_dev;
};
for (const auto& a : dd.axes) {
if (a.axis == 'Y') fillLimits(s.gimbal.yaw, a, cfg.geometry.yaw);
@ -417,7 +389,6 @@ struct Application::Impl {
// --- Activity strip + last calibration ---
s.calib = last_calib_view;
s.calib.pitch_present = s.gimbal.pitch_present || s.calib.pitch_present;
s.diag = last_diag_view;
fillActivity(s);
return s;
}
@ -492,28 +463,6 @@ struct Application::Impl {
latest_snapshot = std::move(s);
}
// Warn (once per excursion) when an axis's live encoder error |xactual-xenc|
// crosses the configured limit, so positional slip is visible in the log
// without watching the gimbal view. Hysteresis re-arms at 75% of the limit.
void checkTrackingError() {
const long limit = cfg.enc_error_warn_counts;
if (limit <= 0) return; // disabled
MotorTelemetry t = motor->telemetry();
auto check = [&](const char* axis, const AxisTelemetry& a, bool present, bool& over) {
if (!present) return;
const long err = std::labs(a.xactual - a.xenc);
if (!over && err > limit) {
over = true;
LOG_WARN << "encoder tracking error on " << axis << ": " << (a.xactual - a.xenc)
<< " counts (limit " << limit << ")";
} else if (over && err < limit * 3 / 4) {
over = false;
}
};
check("yaw", t.yaw, true, yaw_track_over_);
check("pitch", t.pitch, t.pitch_present, pitch_track_over_);
}
void runInitSequence() {
using namespace std::chrono_literals;
LOG_INFO << "Running gimbal init sequence (enable + home)";
@ -867,35 +816,14 @@ struct Application::Impl {
DiagResult dr = parseDiag(motor->lastDiag());
std::ostringstream summary;
for (const auto& l : formatDiag(dr)) { LOG_INFO << l; summary << l << "\n"; }
// Concise per-axis lines for the activity strip + the per-axis worst
// tracking error for the gimbal view. Full detail is logged above.
// Concise per-axis lines for the activity strip (full detail is logged).
last_diag_summary.clear();
last_diag_view = DiagResultView{};
last_diag_view.has = dr.valid;
for (const auto& ax : dr.axes) {
DiagAxisView dv;
dv.axis = ax.axis;
dv.has = !ax.tests.empty();
dv.pass = ax.pass;
dv.err_peak = dv.err_rms = dv.err_still = -1; // -1 stays if no encoder
for (const auto& tst : ax.tests) { // worst (max) across the tests
if (tst.err_peak < 0) continue; // no-encoder test: skip
dv.err_peak = std::max(dv.err_peak, tst.err_peak);
dv.err_rms = std::max(dv.err_rms, tst.err_rms);
dv.err_still = std::max(dv.err_still, tst.err_still);
}
last_diag_view.axes.push_back(dv);
std::string line = std::string(1, ax.axis) + ": " +
(ax.pass ? "PASS" : "FAIL");
if (dv.has && dv.err_peak >= 0) // -1 == no encoder
line += " peak " + std::to_string(dv.err_peak) +
" still " + std::to_string(dv.err_still);
line += " (" + std::to_string(ax.tests.size()) + " tests)";
last_diag_summary.push_back(line);
}
for (const auto& ax : dr.axes)
last_diag_summary.push_back(std::string(1, ax.axis) + ": " +
(ax.pass ? "PASS" : "FAIL") + " (" +
std::to_string(ax.tests.size()) + " tests)");
last_diag_pass = dr.allPass();
last_diag_ts = nowEpochMs();
last_diag_view.ts_ms = last_diag_ts;
std::string path = paths::writeLogFile(
paths::timestampedLogName("diag"),
motor->lastDiag() + "\n--- parsed ---\n" + summary.str());
@ -1010,21 +938,6 @@ struct Application::Impl {
if (!channel->connect())
LOG_WARN << "Control channel not connected; continuing in degraded mode";
// Verify the capture output dir is writable up front; if not (e.g. a
// misconfigured absolute path), fall back to the per-user default so
// captures land on disk instead of failing once per frame. Resolved once
// here, then used everywhere via cfg.paths.output_dir.
if (!outputDirWritable(cfg.paths.output_dir)) {
const std::string fb = paths::defaultOutputDir();
LOG_ERROR << "capture output_dir '" << cfg.paths.output_dir
<< "' is not writable; falling back to '" << fb << "'";
cfg.paths.output_dir = fb;
if (!outputDirWritable(cfg.paths.output_dir))
LOG_ERROR << "fallback output_dir '" << fb
<< "' is also not writable; captures will fail to save";
}
LOG_INFO << "capture output dir: " << cfg.paths.output_dir;
ImagePipeline::Params pp;
pp.output_dir = cfg.paths.output_dir;
pp.labels = cfg.camera.labels;
@ -1093,7 +1006,6 @@ struct Application::Impl {
drainCommands();
pollBackgroundResults(); // apply calibration result / emit DIAG summary+log
scheduler->tick();
checkTrackingError(); // warn on live encoder-error excursions
publishSnapshot();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

View File

@ -140,8 +140,6 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
: hz == "low" ? YawHomeZero::Low
: YawHomeZero::Off;
}
cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts",
cfg.enc_error_warn_counts);
// [Scan]: scan-grid source (explicit CSV file, else generated).
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);

View File

@ -162,9 +162,6 @@ DumpData parseDump(const std::string& block) {
ax.soft_margin = asInt(get(m, "soft_margin", "0"));
ax.hold_target = asInt(get(m, "hold_target", "0"));
ax.speed = asInt(get(m, "speed", "0"));
ax.hold_corrections = asInt(get(m, "hold_corr", "0"));
ax.hold_peak_dev = asInt(get(m, "hold_peak", "0"));
ax.hold_cumulative_dev = asInt(get(m, "hold_cumul", "0"));
}
}

View File

@ -321,7 +321,6 @@ Element axisLiveDetail(const AxisView& a) {
text(formatDegrees(a.deg) + " -> " + formatDegrees(a.target_deg)) | bold}),
kvRow("xactual", std::to_string(a.xactual)),
kvRow("xenc", std::to_string(a.xenc)),
kvRow("enc err", std::to_string(a.enc_err)), // live tracking error xactual-xenc
kvRow("SG_RESULT", std::to_string(a.sg)),
kvRow("CS_ACTUAL", std::to_string(a.cs)),
kvRow("PWM", std::to_string(a.pwm)),
@ -385,8 +384,7 @@ Element axisDumpDetail(const DumpAxis& ax) {
// registers. Rendered per axis so the two axes sit side by side and every row
// (incl. RAMP_STAT, the last one) is visible without scrolling.
Element axisColumn(const std::string& title, const AxisView& live, const DumpData& d,
char letter, bool calib_has, const CalibAxisView& cal,
const DiagAxisView* diag) {
char letter, bool calib_has, const CalibAxisView& cal) {
auto fmt = [](const char* f, double v) {
char b[32];
std::snprintf(b, sizeof(b), f, v);
@ -406,31 +404,6 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
col.push_back(text(d.valid ? "(axis absent from dump)"
: "(awaiting dump - press 'd')") | dim);
// Encoder-tracking health: disturbances the firmware hold corrector counted
// and erased (from the firmware dump), plus the last DIAG tracking errors.
col.push_back(separator());
col.push_back(text("TRACKING") | bold | color(Color::Yellow));
if (live.has_track) {
col.push_back(kvRow("corrections", std::to_string(live.hold_corrections)));
col.push_back(kvRow("peak dev", std::to_string(live.hold_peak_dev)));
col.push_back(kvRow("cumul slip", std::to_string(live.hold_cumulative_dev)));
} else {
col.push_back(text("(awaiting dump - press 'd')") | dim);
}
if (diag && diag->has) {
if (diag->err_peak < 0) {
col.push_back(kvRow("diag", "no encoder"));
} else {
col.push_back(kvRow("diag peak", std::to_string(diag->err_peak)));
col.push_back(kvRow("diag rms", std::to_string(diag->err_rms)));
col.push_back(kvRow("diag still", std::to_string(diag->err_still)));
col.push_back(kvRow("diag", diag->pass ? "PASS" : "FAIL",
diag->pass ? Color::Green : Color::Red));
}
} else {
col.push_back(kvRow("diag", "run 'diag'", Color::GrayDark));
}
// Last calibration result for this axis.
col.push_back(separator());
col.push_back(text("CALIBRATION") | bold | color(Color::Magenta));
@ -448,14 +421,8 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
// Full-screen gimbal view (toggled with 'g'): one column per axis, each with
// live telemetry above its decoded firmware register dump + last calibration.
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib,
const DiagResultView& diag) {
Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const CalibResultView& calib) {
DumpData d = parseDump(dump.text);
auto diagFor = [&](char axis) -> const DiagAxisView* {
for (const auto& a : diag.axes)
if (a.axis == axis) return &a;
return nullptr;
};
std::string reset;
for (size_t i = 0; i < d.reset_flags.size(); ++i)
@ -477,9 +444,8 @@ Element gimbalDetailPanel(const GimbalView& g, const DumpView& dump, const Calib
});
std::vector<Element> cols;
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw, diagFor('Y')));
if (g.pitch_present)
cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch, diagFor('P')));
cols.push_back(axisColumn("YAW", g.yaw, d, 'Y', calib.has, calib.yaw));
if (g.pitch_present) cols.push_back(axisColumn("PITCH", g.pitch, d, 'P', calib.has, calib.pitch));
return window(text(" GIMBAL (g/Esc:close d:refresh dump) ") | bold | color(Color::Cyan),
vbox({header, separator(), hbox(std::move(cols)) | flex}));
@ -618,18 +584,10 @@ void TuiUi::pushLog(LogLevel level, const std::string& line) {
void TuiUi::refreshLoop() {
using namespace std::chrono_literals;
auto last_dump = std::chrono::steady_clock::now();
while (running_) {
std::this_thread::sleep_for(100ms);
if (!running_) break;
if (paused_) continue; // frozen for text selection; don't repaint
// While the gimbal overlay is open, re-pull a firmware dump every ~3s so
// the encoder-tracking counters stay current without pressing 'd'.
if (gimbal_overlay_open_.load() && sink_ &&
std::chrono::steady_clock::now() - last_dump > 3s) {
sink_("gimbal dump");
last_dump = std::chrono::steady_clock::now();
}
if (auto* s = screen_.load()) s->PostEvent(Event::Custom); // force a redraw
}
}
@ -652,7 +610,6 @@ void TuiUi::uiLoop() {
s.log.assign(log_.begin(), log_.end());
}
calib_prompt = !s.activity.prompt.empty();
gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open
std::string mode = s.header.live ? "LIVE" : "MOCK";
Element header = hbox({
@ -690,7 +647,7 @@ void TuiUi::uiLoop() {
return vbox({header, separator(), helpPanel(help_sel, s.dump) | flex, bottom});
case Overlay::Gimbal:
return vbox({header, separator(),
gimbalDetailPanel(s.gimbal, s.dump, s.calib, s.diag) | flex, bottom});
gimbalDetailPanel(s.gimbal, s.dump, s.calib) | flex, bottom});
case Overlay::Sensors:
return vbox({header, separator(), imuDetailPanel(s.imu) | flex, bottom});
case Overlay::Cameras:

View File

@ -15,8 +15,7 @@ namespace {
const char* kDump =
"DUMP BEGIN build=11dd3ce-dirty uptime=11740067 mcusr=0x01 free_ram=1852\n"
"DUMP Y state=3 hsub=10 enabled=1 lim_neg=-82919 lim_pos=98687 hold_target=-90 "
"speed=50000 eeprom_restored=0 soft_margin=5000 hold_corr=7 hold_peak=430 hold_cumul=2810 "
"has_encoder=1\n"
"speed=50000 eeprom_restored=0 has_encoder=1\n"
"DUMP Y TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF "
"RAMPMODE=0x00000000 XACTUAL=0xFFFFFFA6 VACTUAL=0x00000000 XTARGET=0xFFFFFFA6 "
"SW_MODE=0x000008A0 RAMP_STAT=0x00001680 X_ENC=0xFFFFFF7F ENC_STATUS=0x00000002 "
@ -65,10 +64,6 @@ TEST_CASE("parseDump decodes the per-axis state line") {
CHECK(y->hold_target == -90);
CHECK(y->speed == 50000);
CHECK(y->hsub == 10);
CHECK(y->soft_margin == 5000);
CHECK(y->hold_corrections == 7);
CHECK(y->hold_peak_dev == 430);
CHECK(y->hold_cumulative_dev == 2810);
const DumpAxis* p = axis(d, 'P');
REQUIRE(p != nullptr);