Compare commits

...

2 Commits

20 changed files with 603 additions and 41 deletions

View File

@ -61,6 +61,8 @@ add_library(fgc_core STATIC
src/core/CalibrationRoutine.cpp src/core/CalibrationRoutine.cpp
src/core/MtiProtocol.cpp src/core/MtiProtocol.cpp
src/core/CameraMode.cpp src/core/CameraMode.cpp
src/core/ChannelHistory.cpp
src/core/ChannelTopics.cpp
src/sensors/Sht41Protocol.cpp src/sensors/Sht41Protocol.cpp
src/ui/UiSnapshot.cpp src/ui/UiSnapshot.cpp
src/ui/Scroll.cpp src/ui/Scroll.cpp

View File

@ -0,0 +1,53 @@
#pragma once
#include "fgc/IControlChannel.h"
#include <cstddef>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
namespace fgc {
// A bounded, thread-safe record of recent control-channel traffic, plus the
// cumulative counters behind the connectivity view.
//
// It lives here rather than inside MqttControlChannel for two reasons: the null
// channel keeps the same history (so the view works with --no-mqtt), and holding
// no MQTT types means the counter logic is unit-testable with WITH_MQTT=OFF.
//
// Its mutex is its own, deliberately not MqttControlChannel's: that one guards
// sub_data_ and is taken by poll() on the control thread, while record() is
// called from the Paho callback thread and from publish() on both the control
// and ImagePipeline worker threads. Separate locks also keep the history lock
// off the 3-second publish wait.
class ChannelHistory {
public:
// Newest-first eviction bound. Enough to cover a scan's worth of waypoints
// without making the per-tick snapshot copy expensive.
static constexpr std::size_t kCapacity = 40;
// Anything but Delivery::Ok on an outbound message means it never reached the
// broker. Those are the entries worth looking for.
void record(bool outbound, Delivery delivery, std::string topic, std::string payload);
void noteConnect();
void noteLoss();
// What actually went out on the StatusCode topic. The connectivity view used
// to infer this from the live control code, which assumes the echo rather
// than reporting the send.
void noteStatus(std::string payload);
// Oldest first, so the view can print it top-to-bottom as it happened.
std::vector<ChannelMessage> recent() const;
ChannelStats stats() const;
private:
mutable std::mutex mutex_;
std::deque<ChannelMessage> ring_;
ChannelStats stats_;
};
} // namespace fgc

View File

@ -0,0 +1,29 @@
#pragma once
#include "fgc/IControlChannel.h"
#include <string>
namespace fgc {
// Topic names and payload encoding for the control channel, kept here rather
// than inside MqttControlChannel so the null channel produces byte-identical
// output (its history has to show what WOULD have been sent, not an
// approximation) and so the JSON is unit-testable without a broker.
//
// Scheme: GGS/FWT/<tower>/<leaf> — see docs/mqtt-api.md.
// Leaf names, so the two channels and the UI agree on spelling.
inline constexpr const char* kTopicTargetHdg = "target_HDG";
inline constexpr const char* kTopicControlCode = "ControlCode";
inline constexpr const char* kTopicStatusCode = "StatusCode";
inline constexpr const char* kTopicCamEvent = "CamEvent";
inline constexpr const char* kTopicEnv = "Env";
std::string towerTopic(const std::string& tower, const std::string& leaf);
// Payload builders. One definition each, used by both channels.
std::string camEventJson(const CamEvent& e);
std::string envJson(const EnvEvent& e);
} // namespace fgc

View File

@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string> #include <string>
#include <vector>
namespace fgc { namespace fgc {
@ -30,6 +31,32 @@ struct EnvEvent {
long long timestamp_ms = 0; long long timestamp_ms = 0;
}; };
// What became of an outbound message. Dropped and Failed are separated because
// they mean different things operationally: Dropped is "the link was down and we
// discarded it silently", Failed is "the broker or library rejected it".
// Inbound messages are always Ok.
enum class Delivery { Ok, Dropped, Failed };
// One message that crossed the control channel, for the connectivity view.
struct ChannelMessage {
long long timestamp_ms = 0;
bool outbound = true; // PUB (ours) vs RX (broker's)
Delivery delivery = Delivery::Ok;
std::string topic;
std::string payload;
};
// Cumulative channel counters since start-up.
struct ChannelStats {
long long published_ok = 0;
long long published_dropped = 0; // attempted while disconnected: silently discarded
long long published_failed = 0; // the broker/library rejected it
long long received = 0;
long long connects = 0; // successful (re)connections
long long last_connect_ms = 0, last_loss_ms = 0;
std::string last_status_payload; // what actually went out on StatusCode
};
// Abstraction over the remote control/telemetry channel. Implemented by // Abstraction over the remote control/telemetry channel. Implemented by
// MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used // MqttControlChannel (Eclipse Paho) and NullControlChannel (no broker; used
// for development - publishes are dropped and poll() yields a default). // for development - publishes are dropped and poll() yields a default).
@ -49,6 +76,11 @@ public:
// Latest control input; clears the *_available flags so each update is // Latest control input; clears the *_available flags so each update is
// acted on once. // acted on once.
virtual ControlCommand poll() = 0; virtual ControlCommand poll() = 0;
// Recent traffic and counters for the connectivity ('m') view. Default: none,
// so an implementation that keeps no history needs no changes.
virtual std::vector<ChannelMessage> recentMessages() const { return {}; }
virtual ChannelStats channelStats() const { return {}; }
}; };
} // namespace fgc } // namespace fgc

View File

@ -1,5 +1,6 @@
#pragma once #pragma once
#include "fgc/ChannelHistory.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include <atomic> #include <atomic>
@ -37,6 +38,9 @@ public:
void publishEnv(const EnvEvent& event) override; void publishEnv(const EnvEvent& event) override;
ControlCommand poll() override; ControlCommand poll() override;
std::vector<ChannelMessage> recentMessages() const override { return history_.recent(); }
ChannelStats channelStats() const override { return history_.stats(); }
private: private:
// mqtt::callback // mqtt::callback
void connected(const std::string& cause) override; void connected(const std::string& cause) override;
@ -48,6 +52,7 @@ private:
void publish(const std::string& topic, const std::string& payload); void publish(const std::string& topic, const std::string& payload);
ChannelHistory history_;
std::string broker_uri_; std::string broker_uri_;
std::string tower_; std::string tower_;
std::string topic_target_hdg_; std::string topic_target_hdg_;

View File

@ -11,8 +11,18 @@ namespace fgc {
// Simulated ambient sensor for development without hardware: synthesizes a // Simulated ambient sensor for development without hardware: synthesizes a
// slowly varying, plausible temp/humidity reading so the Sensors panel // slowly varying, plausible temp/humidity reading so the Sensors panel
// animates without an SHT41 attached. // animates without an SHT41 attached.
//
// It holds each reading for `period`, exactly as the real driver does (which
// only refreshes latest/timestamp_ms once per [Env] period_ms). Stamping a new
// timestamp on every sample() call would make every ~10 ms control tick look
// like a fresh reading, and Application's "publish once per fresh sample" check
// keys on that timestamp - so the mock would publish Env hundreds of times a
// second where the real sensor publishes once every two seconds.
class MockEnvSensor : public IEnvSensor { class MockEnvSensor : public IEnvSensor {
public: public:
explicit MockEnvSensor(std::chrono::milliseconds period = std::chrono::milliseconds(2000))
: period_(period) {}
void start() override { void start() override {
start_ = clock::now(); start_ = clock::now();
LOG_INFO << "[mock] env sensor started"; LOG_INFO << "[mock] env sensor started";
@ -22,15 +32,18 @@ public:
bool connected() const override { return true; } bool connected() const override { return true; }
std::optional<EnvSample> sample() override { std::optional<EnvSample> sample() override {
const double t = std::chrono::duration<double>(clock::now() - start_).count(); const auto now = clock::now();
EnvSample s; if (latest_.valid && (now - taken_) < period_) return latest_; // same reading as before
s.valid = true;
s.temp_c = static_cast<float>(22.0 + 3.0 * std::sin(t * 0.02)); const double t = std::chrono::duration<double>(now - start_).count();
s.humidity_pct = static_cast<float>(45.0 + 10.0 * std::sin(t * 0.013 + 1.0)); latest_.valid = true;
s.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>( latest_.temp_c = static_cast<float>(22.0 + 3.0 * std::sin(t * 0.02));
latest_.humidity_pct = static_cast<float>(45.0 + 10.0 * std::sin(t * 0.013 + 1.0));
latest_.timestamp_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()) std::chrono::system_clock::now().time_since_epoch())
.count(); .count();
return s; taken_ = now;
return latest_;
} }
std::string name() const override { return "SHT41 (mock)"; } std::string name() const override { return "SHT41 (mock)"; }
@ -38,6 +51,9 @@ public:
private: private:
using clock = std::chrono::steady_clock; using clock = std::chrono::steady_clock;
clock::time_point start_ = clock::now(); clock::time_point start_ = clock::now();
std::chrono::milliseconds period_;
EnvSample latest_;
clock::time_point taken_{};
}; };
} // namespace fgc } // namespace fgc

View File

@ -1,34 +1,63 @@
#pragma once #pragma once
#include "fgc/ChannelHistory.h"
#include "fgc/ChannelTopics.h"
#include "fgc/IControlChannel.h" #include "fgc/IControlChannel.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <string>
#include <utility>
namespace fgc { namespace fgc {
// No-op control channel for running without an MQTT broker. Publishes are // No-op control channel for running without an MQTT broker. Publishes go
// dropped (logged at debug) and poll() never supplies a control command, so // nowhere and poll() never supplies a control command, so the scheduler stays
// the scheduler stays in automatic-sweep mode (ControlCode 0). // in automatic-sweep mode (ControlCode 0).
//
// It still records what it was asked to send, using the same topic and payload
// builders as the real channel, so the connectivity view works under --no-mqtt
// and shows exactly what would have gone out. Every entry is Delivery::Dropped,
// which is the truth: nothing was sent.
class NullControlChannel : public IControlChannel { class NullControlChannel : public IControlChannel {
public: public:
explicit NullControlChannel(std::string tower = "") : tower_(std::move(tower)) {}
bool connect() override { return true; } bool connect() override { return true; }
void disconnect() override {} void disconnect() override {}
bool connected() const override { return true; } bool connected() const override { return true; }
void publishStatus(int code) override { void publishStatus(int code) override {
const std::string payload = std::to_string(code);
history_.noteStatus(payload);
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB StatusCode " << code << " (null channel)"; LOG_TRACE_CAT(LogCat::Mqtt) << "PUB StatusCode " << code << " (null channel)";
drop(kTopicStatusCode, payload);
} }
void publishCamEvent(const CamEvent& e) override { void publishCamEvent(const CamEvent& e) override {
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera LOG_TRACE_CAT(LogCat::Mqtt) << "PUB CamEvent cam=" << e.camera
<< " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms << " hdg=" << e.heading_decideg << " t=" << e.timestamp_ms
<< " (null channel)"; << " (null channel)";
drop(kTopicCamEvent, camEventJson(e));
} }
void publishEnv(const EnvEvent& e) override { void publishEnv(const EnvEvent& e) override {
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB Env temp_c=" << e.temp_c LOG_TRACE_CAT(LogCat::Mqtt) << "PUB Env temp_c=" << e.temp_c
<< " humid_pct=" << e.humidity_pct << " t=" << e.timestamp_ms << " humid_pct=" << e.humidity_pct << " t=" << e.timestamp_ms
<< " (null channel)"; << " (null channel)";
drop(kTopicEnv, envJson(e));
} }
ControlCommand poll() override { return {}; } ControlCommand poll() override { return {}; }
std::vector<ChannelMessage> recentMessages() const override { return history_.recent(); }
ChannelStats channelStats() const override { return history_.stats(); }
private:
void drop(const std::string& leaf, std::string payload) {
history_.record(/*outbound=*/true, Delivery::Dropped, towerTopic(tower_, leaf),
std::move(payload));
}
std::string tower_;
ChannelHistory history_;
}; };
} // namespace fgc } // namespace fgc

View File

@ -28,6 +28,11 @@ public:
bool writeRead(const std::vector<uint8_t>& tx, std::vector<uint8_t>& rx); bool writeRead(const std::vector<uint8_t>& tx, std::vector<uint8_t>& rx);
bool write(const std::vector<uint8_t>& tx); bool write(const std::vector<uint8_t>& tx);
// For diagnostics: what this bus is actually pointed at. A silent sensor is
// almost always the wrong bus or address, so error messages should name both.
const std::string& device() const { return device_; }
uint8_t address() const { return addr_; }
private: private:
std::string device_; std::string device_;
uint8_t addr_; uint8_t addr_;

View File

@ -1,5 +1,7 @@
#pragma once #pragma once
#include "fgc/IControlChannel.h"
#include "fgc/IMotorController.h" // AxisState #include "fgc/IMotorController.h" // AxisState
#include "fgc/Logger.h" // LogLevel #include "fgc/Logger.h" // LogLevel
@ -149,6 +151,10 @@ struct ConnView {
int control_code = 0; // 0 = auto-sweep, 1 = directed int control_code = 0; // 0 = auto-sweep, 1 = directed
std::string target_heading; std::string target_heading;
int last_status_code = 0; int last_status_code = 0;
// Recent traffic + counters for the expanded 'm' view.
std::vector<ChannelMessage> recent;
ChannelStats stats;
}; };
struct HeaderView { struct HeaderView {
@ -309,6 +315,16 @@ std::string formatDegrees(double deg);
// Returns "—" when then_ms is 0 (never). // Returns "—" when then_ms is 0 (never).
std::string formatTimeAgo(long long now_ms, long long then_ms); std::string formatTimeAgo(long long now_ms, long long then_ms);
// Last path segment of an MQTT topic ("GGS/FWT/Tower/CamEvent" -> "CamEvent").
// Every row in the connectivity view shares the same prefix, which only eats
// width. Returns the input unchanged when there is no '/'.
std::string topicLeaf(const std::string& topic);
// "11:56:14.201" (local wall clock) for an absolute ms timestamp; "—" when 0.
// The message history needs the actual time, not "3s ago": operators correlate
// it against the broker's own log.
std::string formatClockMs(long long ms);
// MVP placeholder: the Sensors panel fields shown before the DHT11/MTi drivers // MVP placeholder: the Sensors panel fields shown before the DHT11/MTi drivers
// exist (all `present=false`). Centralised so the panel and tests agree. // exist (all `present=false`). Centralised so the panel and tests agree.
SensorsView pendingSensorsView(); SensorsView pendingSensorsView();

View File

@ -252,7 +252,7 @@ struct Application::Impl {
#else #else
if (want) LOG_WARN << "MQTT requested but binary built without MQTT; using null channel"; if (want) LOG_WARN << "MQTT requested but binary built without MQTT; using null channel";
#endif #endif
return std::make_unique<NullControlChannel>(); return std::make_unique<NullControlChannel>(cfg.general.tower_name);
} }
std::unique_ptr<IMotorController> makeMotor() { std::unique_ptr<IMotorController> makeMotor() {
@ -277,7 +277,8 @@ struct Application::Impl {
const bool force_mock = opts.mock_env.value_or(false); const bool force_mock = opts.mock_env.value_or(false);
if (!cfg.features.enable_env && !force_mock) if (!cfg.features.enable_env && !force_mock)
return nullptr; // sensors panel stays "pending" return nullptr; // sensors panel stays "pending"
if (force_mock || cfg.features.mock_env) return std::make_unique<MockEnvSensor>(); if (force_mock || cfg.features.mock_env)
return std::make_unique<MockEnvSensor>(std::chrono::milliseconds(cfg.env.period_ms));
if (cfg.env.i2c_device.empty()) { if (cfg.env.i2c_device.empty()) {
LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling"; LOG_WARN << "Env sensor enabled but [Env] i2c_device is empty; disabling";
return nullptr; return nullptr;
@ -724,6 +725,10 @@ struct Application::Impl {
s.conn.control_code = scheduler ? scheduler->controlCode() : 0; s.conn.control_code = scheduler ? scheduler->controlCode() : 0;
s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0"; s.conn.target_heading = scheduler ? scheduler->targetHeading() : "0";
s.conn.last_status_code = s.conn.control_code; // echoed back as status s.conn.last_status_code = s.conn.control_code; // echoed back as status
if (channel) {
s.conn.recent = channel->recentMessages();
s.conn.stats = channel->channelStats();
}
// --- Diagnostics (last firmware DUMP) --- // --- Diagnostics (last firmware DUMP) ---
s.dump.text = motor->lastDump(); s.dump.text = motor->lastDump();

View File

@ -0,0 +1,68 @@
#include "fgc/ChannelHistory.h"
#include <chrono>
namespace fgc {
namespace {
long long nowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
}
} // namespace
void ChannelHistory::record(bool outbound, Delivery delivery, std::string topic,
std::string payload) {
std::lock_guard<std::mutex> lock(mutex_);
if (!outbound) {
++stats_.received;
} else {
switch (delivery) {
case Delivery::Ok: ++stats_.published_ok; break;
case Delivery::Dropped: ++stats_.published_dropped; break;
case Delivery::Failed: ++stats_.published_failed; break;
}
}
ChannelMessage m;
m.timestamp_ms = nowMs();
m.outbound = outbound;
m.delivery = delivery;
m.topic = std::move(topic);
m.payload = std::move(payload);
ring_.push_back(std::move(m));
while (ring_.size() > kCapacity) ring_.pop_front();
}
void ChannelHistory::noteConnect() {
std::lock_guard<std::mutex> lock(mutex_);
++stats_.connects;
stats_.last_connect_ms = nowMs();
}
void ChannelHistory::noteStatus(std::string payload) {
std::lock_guard<std::mutex> lock(mutex_);
stats_.last_status_payload = std::move(payload);
}
void ChannelHistory::noteLoss() {
std::lock_guard<std::mutex> lock(mutex_);
stats_.last_loss_ms = nowMs();
}
std::vector<ChannelMessage> ChannelHistory::recent() const {
std::lock_guard<std::mutex> lock(mutex_);
return {ring_.begin(), ring_.end()};
}
ChannelStats ChannelHistory::stats() const {
std::lock_guard<std::mutex> lock(mutex_);
return stats_;
}
} // namespace fgc

View File

@ -0,0 +1,22 @@
#include "fgc/ChannelTopics.h"
namespace fgc {
std::string towerTopic(const std::string& tower, const std::string& leaf) {
return "GGS/FWT/" + tower + "/" + leaf;
}
std::string camEventJson(const CamEvent& e) {
return "{ \"fwt\":\"" + e.tower + "\", \"cam\":\"" + e.camera +
"\", \"hdg\":" + std::to_string(e.heading_decideg) +
", \"pit\":" + std::to_string(e.pitch_decideg) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
}
std::string envJson(const EnvEvent& e) {
return "{ \"fwt\":\"" + e.tower + "\", \"temp_c\":" + std::to_string(e.temp_c) +
", \"humid_pct\":" + std::to_string(e.humidity_pct) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
}
} // namespace fgc

View File

@ -118,10 +118,11 @@ const std::vector<HelpSection>& helpCatalog() {
"Example: trace serial on / trace off"}}, "Example: trace serial on / trace off"}},
}}, }},
{"Display", "Terminal dashboard keys (TUI only).", { {"Display", "Terminal dashboard keys (TUI only).", {
{"? / g / i / c", {"? / g / i / c / m",
"Toggle the Help, Gimbal, Sensors and Camera overlays.", { "Toggle the Help, Gimbal, Sensors, Camera and Connectivity overlays.", {
"An overlay takes the full terminal height. Esc closes it; with none open,", "An overlay takes the full terminal height. Esc closes it; with none open,",
"Esc cancels a running procedure."}}, "Esc cancels a running procedure. 'm' shows broker state, per-topic traffic",
"and the recent MQTT messages, including any dropped while offline."}},
{"Up/Down, j/k", {"Up/Down, j/k",
"Scroll the open overlay one line (Help: change section).", { "Scroll the open overlay one line (Help: change section).", {
"The scrollbar on the right shows how much is off-screen."}}, "The scrollbar on the right shows how much is off-screen."}},

View File

@ -1,5 +1,7 @@
#include "fgc/MqttControlChannel.h" #include "fgc/MqttControlChannel.h"
#include "fgc/ChannelTopics.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
#include <chrono> #include <chrono>
@ -16,11 +18,11 @@ MqttControlChannel::MqttControlChannel(const std::string& broker_uri, const std:
const std::string& user, const std::string& password) const std::string& user, const std::string& password)
: broker_uri_(broker_uri), : broker_uri_(broker_uri),
tower_(tower), tower_(tower),
topic_target_hdg_("GGS/FWT/" + tower + "/target_HDG"), topic_target_hdg_(towerTopic(tower, kTopicTargetHdg)),
topic_control_code_("GGS/FWT/" + tower + "/ControlCode"), topic_control_code_(towerTopic(tower, kTopicControlCode)),
topic_status_("GGS/FWT/" + tower + "/StatusCode"), topic_status_(towerTopic(tower, kTopicStatusCode)),
topic_cam_event_("GGS/FWT/" + tower + "/CamEvent"), topic_cam_event_(towerTopic(tower, kTopicCamEvent)),
topic_env_("GGS/FWT/" + tower + "/Env"), topic_env_(towerTopic(tower, kTopicEnv)),
client_(broker_uri, tower) { client_(broker_uri, tower) {
conn_opts_.set_keep_alive_interval(20); conn_opts_.set_keep_alive_interval(20);
conn_opts_.set_clean_session(true); conn_opts_.set_clean_session(true);
@ -59,6 +61,7 @@ bool MqttControlChannel::connected() const { return connected_; }
void MqttControlChannel::connected(const std::string& /*cause*/) { void MqttControlChannel::connected(const std::string& /*cause*/) {
LOG_INFO << "MQTT (re)connected; subscribing"; LOG_INFO << "MQTT (re)connected; subscribing";
history_.noteConnect();
client_.subscribe(topic_target_hdg_, kQos); client_.subscribe(topic_target_hdg_, kQos);
client_.subscribe(topic_control_code_, kQos); client_.subscribe(topic_control_code_, kQos);
connected_ = true; connected_ = true;
@ -66,6 +69,7 @@ void MqttControlChannel::connected(const std::string& /*cause*/) {
void MqttControlChannel::connection_lost(const std::string& cause) { void MqttControlChannel::connection_lost(const std::string& cause) {
LOG_WARN << "MQTT connection lost" << (cause.empty() ? "" : ": " + cause); LOG_WARN << "MQTT connection lost" << (cause.empty() ? "" : ": " + cause);
history_.noteLoss();
connected_ = false; connected_ = false;
// Paho will auto-reconnect when configured; the broker callback re-subscribes. // Paho will auto-reconnect when configured; the broker callback re-subscribes.
} }
@ -78,6 +82,7 @@ void MqttControlChannel::message_arrived(mqtt::const_message_ptr msg) {
const std::string topic = msg->get_topic(); const std::string topic = msg->get_topic();
const std::string payload = msg->to_string(); const std::string payload = msg->to_string();
LOG_TRACE_CAT(LogCat::Mqtt) << "RX " << topic << ' ' << payload; LOG_TRACE_CAT(LogCat::Mqtt) << "RX " << topic << ' ' << payload;
history_.record(/*outbound=*/false, Delivery::Ok, topic, payload);
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
try { try {
if (topic == topic_target_hdg_) { if (topic == topic_target_hdg_) {
@ -102,36 +107,37 @@ ControlCommand MqttControlChannel::poll() {
} }
void MqttControlChannel::publish(const std::string& topic, const std::string& payload) { void MqttControlChannel::publish(const std::string& topic, const std::string& payload) {
if (!client_.is_connected()) return; if (!client_.is_connected()) {
// Discarded: there is no queue and no retry, so this message is simply
// gone. Recording it is the only trace it ever existed.
history_.record(/*outbound=*/true, Delivery::Dropped, topic, payload);
return;
}
LOG_TRACE_CAT(LogCat::Mqtt) << "PUB " << topic << ' ' << payload; LOG_TRACE_CAT(LogCat::Mqtt) << "PUB " << topic << ' ' << payload;
try { try {
auto msg = mqtt::make_message(topic, payload); auto msg = mqtt::make_message(topic, payload);
msg->set_qos(kQos); msg->set_qos(kQos);
msg->set_retained(true); msg->set_retained(true);
client_.publish(msg)->wait_for(kPublishTimeout); client_.publish(msg)->wait_for(kPublishTimeout);
history_.record(/*outbound=*/true, Delivery::Ok, topic, payload);
} catch (const mqtt::exception& e) { } catch (const mqtt::exception& e) {
LOG_WARN << "MQTT publish failed: " << e.what(); LOG_WARN << "MQTT publish failed: " << e.what();
history_.record(/*outbound=*/true, Delivery::Failed, topic, payload);
} }
} }
void MqttControlChannel::publishStatus(int code) { void MqttControlChannel::publishStatus(int code) {
publish(topic_status_, std::to_string(code)); const std::string payload = std::to_string(code);
history_.noteStatus(payload);
publish(topic_status_, payload);
} }
void MqttControlChannel::publishCamEvent(const CamEvent& e) { void MqttControlChannel::publishCamEvent(const CamEvent& e) {
std::string payload = "{ \"fwt\":\"" + e.tower + "\" ,\"cam\":\"" + e.camera + publish(topic_cam_event_, camEventJson(e));
"\", \"hdg\":" + std::to_string(e.heading_decideg) +
", \"pit\":" + std::to_string(e.pitch_decideg) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
publish(topic_cam_event_, payload);
} }
void MqttControlChannel::publishEnv(const EnvEvent& e) { void MqttControlChannel::publishEnv(const EnvEvent& e) {
std::string payload = "{ \"fwt\":\"" + e.tower + publish(topic_env_, envJson(e));
"\", \"temp_c\":" + std::to_string(e.temp_c) +
", \"humid_pct\":" + std::to_string(e.humidity_pct) +
", \"time\":" + std::to_string(e.timestamp_ms) + " }";
publish(topic_env_, payload);
} }
} // namespace fgc } // namespace fgc

View File

@ -5,6 +5,7 @@
#include "fgc/sensors/Sht41Protocol.h" #include "fgc/sensors/Sht41Protocol.h"
#include <atomic> #include <atomic>
#include <ios>
#include <chrono> #include <chrono>
#include <mutex> #include <mutex>
#include <thread> #include <thread>
@ -36,11 +37,37 @@ struct Sht41EnvSensor::Impl {
EnvSample latest; EnvSample latest;
clock::time_point last_ok{}; clock::time_point last_ok{};
// Consecutive failed reads. The transfers run every `period` forever, so the
// failure is logged on the FIRST one only: a wrong bus would otherwise print
// the same line every couple of seconds for the life of the process. Reset on
// a good read, which also gives us a recovery message.
long long fail_streak = 0;
// A silent sensor is nearly always the wrong bus or address, and neither
// open() nor the I2C_SLAVE ioctl can tell you that - the ioctl only records
// the target address, it never probes. This is the first point where the
// absence of a device is actually observable, so it is where it gets said.
void reportFailure(const char* what) {
if (fail_streak++ > 0) return;
LOG_ERROR << "SHT41: " << what << " at address 0x" << std::hex
<< static_cast<int>(bus.address()) << std::dec << " on " << bus.device()
<< " - check [Env] i2c_device/i2c_addr and the wiring"
<< " (probe with: i2cdetect -l, then i2ctransfer -y <bus> w1@0x"
<< std::hex << static_cast<int>(bus.address()) << std::dec << " 0x89)";
LOG_ERROR << "SHT41: further read failures will not be logged until it responds";
}
void readOnce() { void readOnce() {
if (!bus.write({kSht41CmdMeasureHighPrec})) return; if (!bus.write({kSht41CmdMeasureHighPrec})) {
reportFailure("no response to the measure command");
return;
}
std::this_thread::sleep_for(kConversionDelay); std::this_thread::sleep_for(kConversionDelay);
std::vector<uint8_t> rx(kSht41ResponseBytes); std::vector<uint8_t> rx(kSht41ResponseBytes);
if (!bus.writeRead({}, rx)) return; if (!bus.writeRead({}, rx)) {
reportFailure("command acknowledged but no measurement came back");
return;
}
auto reading = decodeSht41Measurement(rx); auto reading = decodeSht41Measurement(rx);
if (!reading) { if (!reading) {
@ -48,6 +75,11 @@ struct Sht41EnvSensor::Impl {
return; return;
} }
if (fail_streak > 0) {
LOG_INFO << "SHT41: responding again after " << fail_streak << " failed read(s)";
fail_streak = 0;
}
std::lock_guard<std::mutex> lock(mutex); std::lock_guard<std::mutex> lock(mutex);
latest.valid = true; latest.valid = true;
latest.temp_c = reading->temp_c; latest.temp_c = reading->temp_c;
@ -81,7 +113,11 @@ void Sht41EnvSensor::start() {
std::this_thread::sleep_for(kSoftResetSettle); std::this_thread::sleep_for(kSoftResetSettle);
impl_->running = true; impl_->running = true;
impl_->worker = std::thread([this] { impl_->run(); }); impl_->worker = std::thread([this] { impl_->run(); });
LOG_INFO << "SHT41 env sensor started"; // Deliberately not "started OK": opening the node proves nothing about the
// sensor being there. The first read either succeeds quietly or logs why not.
LOG_INFO << "SHT41 env sensor polling 0x" << std::hex << static_cast<int>(impl_->bus.address())
<< std::dec << " on " << impl_->bus.device() << " every "
<< impl_->period.count() << " ms";
} }
void Sht41EnvSensor::stop() { void Sht41EnvSensor::stop() {

View File

@ -1,5 +1,7 @@
#include "fgc/ui/TuiUi.h" #include "fgc/ui/TuiUi.h"
#include "fgc/ChannelTopics.h"
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
#include "fgc/HelpText.h" #include "fgc/HelpText.h"
#include "fgc/Logger.h" #include "fgc/Logger.h"
@ -375,6 +377,103 @@ Element connPanel(const ConnView& v) {
})); }));
} }
// Expanded connectivity view ('m'): broker state, the five topics with their last
// traffic, the counters, and a scrollable history of recent messages.
//
// The history is the point of the view. `dropped` in particular has no other
// route to the operator: MqttControlChannel::publish() discards a message when
// the link is down without queueing, retrying or logging, so a lost CamEvent -
// and with it an image the ground station never fetches - is otherwise invisible.
Element connDetailPanel(const ConnView& v, ScrollState& st) {
auto row = [](const std::string& k, Element val) {
return hbox({text(k) | dim | size(WIDTH, EQUAL, 12), std::move(val)});
};
const long long now = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
std::vector<Element> rows;
// --- Broker ---
rows.push_back(text("BROKER") | bold | color(Color::Green));
Element state = !v.mqtt_enabled
? (text(" disabled - messages not sent ") | color(Color::Yellow) | bold)
: v.mqtt_connected
? (text(" connected ") | color(Color::Green) | bold)
: (text(" disconnected - retrying ") | color(Color::Red) | bold);
const bool really_up = v.mqtt_enabled && v.mqtt_connected && v.stats.last_connect_ms > 0;
rows.push_back(hbox({text("state ") | dim, state, filler(),
text(really_up ? "since " + formatTimeAgo(now, v.stats.last_connect_ms)
: "") | dim}));
rows.push_back(row("uri", text(v.broker.empty() ? "\xE2\x80\x94" : v.broker)));
rows.push_back(row("client id", text(v.tower)));
rows.push_back(row("settings", text("QoS 1 \xC2\xB7 retained \xC2\xB7 keep-alive 20 s "
"\xC2\xB7 clean session") | dim));
rows.push_back(row("link", text(std::to_string(v.stats.connects) + " connect(s), last loss " +
formatTimeAgo(now, v.stats.last_loss_ms)) | dim));
// --- Counters ---
rows.push_back(separator());
rows.push_back(text("COUNTERS") | bold | color(Color::Green));
rows.push_back(row("published", hbox({
text(std::to_string(v.stats.published_ok) + " ok ") | color(Color::Green),
text(std::to_string(v.stats.published_dropped) + " dropped ") |
(v.stats.published_dropped ? color(Color::Red) : color(Color::Default)),
text(std::to_string(v.stats.published_failed) + " failed") |
(v.stats.published_failed ? color(Color::Red) : color(Color::Default)),
})));
rows.push_back(row("received", text(std::to_string(v.stats.received))));
if (v.stats.published_dropped)
rows.push_back(text("dropped = attempted while the link was down; not queued, not "
"retried, gone.") | color(Color::Red) | dim);
// --- Topics: last traffic per topic, newest wins ---
rows.push_back(separator());
rows.push_back(text("TOPICS") | bold | color(Color::Green));
struct TopicRow { const char* dir; const char* leaf; };
static const TopicRow kTopics[] = {
{"sub", kTopicTargetHdg}, {"sub", kTopicControlCode}, {"pub", kTopicStatusCode},
{"pub", kTopicCamEvent}, {"pub", kTopicEnv},
};
for (const auto& t : kTopics) {
const ChannelMessage* last = nullptr;
for (const auto& m : v.recent)
if (topicLeaf(m.topic) == t.leaf) last = &m; // recent is oldest-first
std::string when = last ? formatTimeAgo(now, last->timestamp_ms) : "\xE2\x80\x94";
rows.push_back(hbox({
text(std::string(" ") + t.dir + " ") | dim,
text(t.leaf) | size(WIDTH, EQUAL, 14),
text(when) | dim | size(WIDTH, EQUAL, 10),
text(last ? last->payload : "") | dim,
}));
}
// --- History ---
rows.push_back(separator());
rows.push_back(hbox({text("RECENT MESSAGES") | bold | color(Color::Green),
text(" (oldest first)") | dim}));
if (v.recent.empty()) {
rows.push_back(text("(nothing sent or received yet)") | dim);
} else {
for (const auto& m : v.recent) {
const bool bad = m.delivery != Delivery::Ok;
Element tag = text(m.outbound ? "PUB" : "RX ") |
(m.outbound ? color(Color::Yellow) : color(Color::Cyan));
std::string note = m.delivery == Delivery::Dropped ? " DROPPED"
: m.delivery == Delivery::Failed ? " FAILED"
: "";
rows.push_back(hbox({
text(formatClockMs(m.timestamp_ms) + " ") | dim,
tag,
text(" " + topicLeaf(m.topic)) | size(WIDTH, EQUAL, 14),
text(" " + m.payload + note) | (bad ? color(Color::Red) : color(Color::Default)),
}));
}
}
return window(overlayTitle("CONNECTIVITY", "m/Esc:close", st, Color::Green),
scrollableBody(vbox(std::move(rows)), st));
}
Element logPanel(const std::vector<LogLine>& lines, ScrollState& st) { Element logPanel(const std::vector<LogLine>& lines, ScrollState& st) {
std::vector<Element> rows; std::vector<Element> rows;
for (const auto& l : lines) { for (const auto& l : lines) {
@ -877,7 +976,7 @@ void TuiUi::refreshLoop() {
void TuiUi::uiLoop() { void TuiUi::uiLoop() {
std::string cmd_buffer; std::string cmd_buffer;
bool command_mode = false; bool command_mode = false;
enum class Overlay { None, Help, Gimbal, Sensors, Cameras }; enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn };
Overlay overlay = Overlay::None; // which takeover panel owns the main area Overlay overlay = Overlay::None; // which takeover panel owns the main area
int help_sel = 0; int help_sel = 0;
bool gimbal_dump_requested = false; // auto-pull a dump the first time bool gimbal_dump_requested = false; // auto-pull a dump the first time
@ -886,7 +985,7 @@ void TuiUi::uiLoop() {
// Per-pane scroll positions. UI-thread-only view state (the renderer lambda // Per-pane scroll positions. UI-thread-only view state (the renderer lambda
// and CatchEvent below both run on this thread), so unlike screen_/paused_ // and CatchEvent below both run on this thread), so unlike screen_/paused_
// these need no synchronisation and stay locals rather than TuiUi members. // these need no synchronisation and stay locals rather than TuiUi members.
ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll; ScrollState cam_scroll, sensors_scroll, help_scroll, log_scroll, conn_scroll;
std::array<ScrollState, 2> gimbal_scroll{}; // YAW, PITCH std::array<ScrollState, 2> gimbal_scroll{}; // YAW, PITCH
log_scroll.follow = true; // the log tracks the tail until told otherwise log_scroll.follow = true; // the log tracks the tail until told otherwise
size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered
@ -944,7 +1043,7 @@ void TuiUi::uiLoop() {
bottom = hbox({ bottom = hbox({
keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"), keyHint("s", "Start"), keyHint("x", "Stop"), keyHint("h", "Home"),
keyHint("g", "Gimbal"), keyHint("i", "Sensors"), keyHint("c", "Scan"), keyHint("g", "Gimbal"), keyHint("i", "Sensors"), keyHint("c", "Scan"),
keyHint("p", "Pause"), keyHint("r", "Refresh"), keyHint("m", "Comms"), keyHint("p", "Pause"), keyHint("r", "Refresh"),
keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"), keyHint("\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93", "Nudge"),
keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"), keyHint(":", "Cmd"), keyHint("?", "Help"), filler(), keyHint("q", "Quit"),
}); });
@ -966,6 +1065,8 @@ void TuiUi::uiLoop() {
sensorsDetailPanel(s.imu, s.env, sensors_scroll) | flex, bottom}); sensorsDetailPanel(s.imu, s.env, sensors_scroll) | flex, bottom});
case Overlay::Cameras: case Overlay::Cameras:
return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom}); return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom});
case Overlay::Conn:
return vbox({header, separator(), connDetailPanel(s.conn, conn_scroll) | flex, bottom});
default: { default: {
// Activity strip sits between the log and the key bar; shown only // Activity strip sits between the log and the key bar; shown only
// once a special op has run or is running, else it costs no space. // once a special op has run or is running, else it costs no space.
@ -1027,6 +1128,9 @@ void TuiUi::uiLoop() {
case Overlay::Cameras: case Overlay::Cameras:
if (scrollKeys(cam_scroll, e, arrows)) return true; if (scrollKeys(cam_scroll, e, arrows)) return true;
break; break;
case Overlay::Conn:
if (scrollKeys(conn_scroll, e, arrows)) return true;
break;
case Overlay::Sensors: case Overlay::Sensors:
if (scrollKeys(sensors_scroll, e, arrows)) return true; if (scrollKeys(sensors_scroll, e, arrows)) return true;
break; break;
@ -1116,6 +1220,11 @@ void TuiUi::uiLoop() {
if (overlay == Overlay::Cameras) cam_scroll = {}; if (overlay == Overlay::Cameras) cam_scroll = {};
return true; return true;
} }
if (c == "m") {
overlay = (overlay == Overlay::Conn) ? Overlay::None : Overlay::Conn;
if (overlay == Overlay::Conn) conn_scroll = {};
return true;
}
if (c == "p") { // freeze/unfreeze the live refresh so text can be selected if (c == "p") { // freeze/unfreeze the live refresh so text can be selected
paused_ = !paused_; paused_ = !paused_;
return true; // this keypress redraws once so the indicator updates return true; // this keypress redraws once so the indicator updates

View File

@ -2,6 +2,7 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <ctime>
namespace fgc { namespace fgc {
@ -47,6 +48,23 @@ std::string formatTimeAgo(long long now_ms, long long then_ms) {
return buf; return buf;
} }
std::string topicLeaf(const std::string& topic) {
const auto slash = topic.rfind('/');
if (slash == std::string::npos) return topic;
return topic.substr(slash + 1);
}
std::string formatClockMs(long long ms) {
if (ms <= 0) return "\xE2\x80\x94"; // em dash: never
const std::time_t secs = static_cast<std::time_t>(ms / 1000);
std::tm tm{};
localtime_r(&secs, &tm);
char buf[32];
std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d.%03d", tm.tm_hour, tm.tm_min, tm.tm_sec,
static_cast<int>(ms % 1000));
return buf;
}
SensorsView pendingSensorsView() { SensorsView pendingSensorsView() {
SensorsView v; SensorsView v;
// Xsens MTi-28 AHRS: drift-free Euler orientation. The device's internal // Xsens MTi-28 AHRS: drift-free Euler orientation. The device's internal

View File

@ -23,6 +23,7 @@ add_executable(fgc_tests
test_singleinstance.cpp test_singleinstance.cpp
test_config.cpp test_config.cpp
test_cameramode.cpp test_cameramode.cpp
test_channelhistory.cpp
test_telemetry.cpp test_telemetry.cpp
test_command.cpp test_command.cpp
test_scheduler.cpp test_scheduler.cpp

View File

@ -0,0 +1,92 @@
#include <doctest/doctest.h>
#include "fgc/ChannelHistory.h"
#include "fgc/ChannelTopics.h"
#include <string>
using namespace fgc;
TEST_CASE("ChannelHistory counts each delivery outcome separately") {
ChannelHistory h;
h.record(true, Delivery::Ok, "t/a", "1");
h.record(true, Delivery::Ok, "t/a", "2");
h.record(true, Delivery::Dropped, "t/a", "3");
h.record(true, Delivery::Failed, "t/a", "4");
h.record(false, Delivery::Ok, "t/b", "in");
ChannelStats s = h.stats();
CHECK(s.published_ok == 2);
CHECK(s.published_dropped == 1); // the silent-drop path: the number that matters
CHECK(s.published_failed == 1);
CHECK(s.received == 1); // inbound never touches the publish counters
}
TEST_CASE("ChannelHistory returns messages oldest first and keeps their fields") {
ChannelHistory h;
h.record(true, Delivery::Dropped, "t/first", "a");
h.record(false, Delivery::Ok, "t/second", "b");
auto r = h.recent();
REQUIRE(r.size() == 2);
CHECK(r[0].topic == "t/first");
CHECK(r[0].outbound);
CHECK(r[0].delivery == Delivery::Dropped);
CHECK(r[0].payload == "a");
CHECK(r[1].topic == "t/second");
CHECK_FALSE(r[1].outbound);
CHECK(r[1].timestamp_ms > 0);
}
TEST_CASE("ChannelHistory evicts the oldest beyond its capacity") {
ChannelHistory h;
const int n = static_cast<int>(ChannelHistory::kCapacity) + 10;
for (int i = 0; i < n; ++i) h.record(true, Delivery::Ok, "t", std::to_string(i));
auto r = h.recent();
REQUIRE(r.size() == ChannelHistory::kCapacity);
CHECK(r.front().payload == std::to_string(n - static_cast<int>(ChannelHistory::kCapacity)));
CHECK(r.back().payload == std::to_string(n - 1));
// Counters are cumulative; they must not be bounded by the ring.
CHECK(h.stats().published_ok == n);
}
TEST_CASE("ChannelHistory tracks connects, losses and the last status sent") {
ChannelHistory h;
CHECK(h.stats().connects == 0);
CHECK(h.stats().last_connect_ms == 0);
h.noteConnect();
h.noteLoss();
h.noteConnect();
h.noteStatus("1");
ChannelStats s = h.stats();
CHECK(s.connects == 2);
CHECK(s.last_connect_ms > 0);
CHECK(s.last_loss_ms > 0);
CHECK(s.last_status_payload == "1");
}
TEST_CASE("topic and payload builders match the documented wire format") {
CHECK(towerTopic("Tower", kTopicCamEvent) == "GGS/FWT/Tower/CamEvent");
CamEvent c;
c.tower = "Tower"; c.camera = "RGB";
c.heading_decideg = 1373; c.pitch_decideg = 300; c.timestamp_ms = 1719312345678LL;
const std::string j = camEventJson(c);
CHECK(j.find("\"fwt\":\"Tower\"") != std::string::npos);
CHECK(j.find("\"cam\":\"RGB\"") != std::string::npos);
CHECK(j.find("\"hdg\":1373") != std::string::npos);
CHECK(j.find("\"pit\":300") != std::string::npos);
CHECK(j.find("\"time\":1719312345678") != std::string::npos);
// The quality gate is gone; nothing should still be publishing a verdict.
CHECK(j.find("degraded") == std::string::npos);
EnvEvent e;
e.tower = "Tower"; e.temp_c = 21.5f; e.humidity_pct = 48.0f; e.timestamp_ms = 42;
const std::string ej = envJson(e);
CHECK(ej.find("\"fwt\":\"Tower\"") != std::string::npos);
CHECK(ej.find("\"temp_c\":21.5") != std::string::npos);
CHECK(ej.find("\"time\":42") != std::string::npos);
}

View File

@ -101,3 +101,20 @@ TEST_CASE("EnvView defaults to disabled with no reading") {
CHECK_FALSE(e.mock); CHECK_FALSE(e.mock);
CHECK(e.timestamp_ms == 0); CHECK(e.timestamp_ms == 0);
} }
TEST_CASE("topicLeaf strips the constant GGS/FWT/<tower>/ prefix") {
CHECK(topicLeaf("GGS/FWT/Staeffelsberg/CamEvent") == "CamEvent");
CHECK(topicLeaf("CamEvent") == "CamEvent"); // already a leaf
CHECK(topicLeaf("a/b/") == ""); // trailing slash: empty leaf, not "b"
CHECK(topicLeaf("") == "");
}
TEST_CASE("formatClockMs renders wall-clock time, and never for 0") {
CHECK(formatClockMs(0) == "\xE2\x80\x94");
const std::string t = formatClockMs(1719312345678LL);
REQUIRE(t.size() == 12); // HH:MM:SS.mmm
CHECK(t[2] == ':');
CHECK(t[5] == ':');
CHECK(t[8] == '.');
CHECK(t.substr(9) == "678"); // the ms field comes straight off the input
}