Compare commits
2 Commits
d3514f7d2b
...
bc4d2ec002
| Author | SHA1 | Date |
|---|---|---|
|
|
bc4d2ec002 | |
|
|
6c1941cc80 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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_;
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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."}},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
115
src/ui/TuiUi.cpp
115
src/ui/TuiUi.cpp
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue