348 lines
13 KiB
C++
348 lines
13 KiB
C++
/**
|
||
* @file App.h
|
||
* @brief Top-level application state and frame dispatch.
|
||
*/
|
||
|
||
#pragma once
|
||
|
||
#include "WSClient.h"
|
||
#include "Protocol.h"
|
||
#include "SignalBuffer.h"
|
||
|
||
#include <string>
|
||
#include <vector>
|
||
#include <memory>
|
||
#include <mutex>
|
||
#include <cstdint>
|
||
|
||
#include "imgui.h"
|
||
|
||
namespace StreamHubClient {
|
||
|
||
/*---------------------------------------------------------------------------*/
|
||
/* Domain types */
|
||
/*---------------------------------------------------------------------------*/
|
||
|
||
/** One signal as known to the client. */
|
||
struct Signal {
|
||
SignalMeta meta;
|
||
SignalBuffer buf{1000000};
|
||
ImVec4 color{0.4f, 0.8f, 1.0f, 1.0f};
|
||
float lineWidth = 1.5f;
|
||
int marker = -1; /* ImPlotMarker (-1 = none) */
|
||
bool visible = true;
|
||
};
|
||
|
||
/** One connected UDPStreamer source. */
|
||
struct Source {
|
||
std::string id;
|
||
std::string label;
|
||
std::string addr;
|
||
std::string state = "disconnected";
|
||
uint32_t port = 0;
|
||
bool configured = false;
|
||
int publishMode = 0;
|
||
std::vector<Signal> signals;
|
||
SourceStats stats;
|
||
};
|
||
|
||
/** Trigger configuration and live state (hub-side trigger semantics). */
|
||
struct TriggerState {
|
||
/* Config (sent to the hub via setTrigger) */
|
||
std::string signalKey; /* full key "src:sig" or "src:sig[i]" */
|
||
int edge = 0; /* 0=rising 1=falling 2=both */
|
||
double threshold = 0.0;
|
||
double windowSec = 0.1; /* 100 µs .. 10 s */
|
||
double prePercent = 20.0;
|
||
bool single = false; /* false=normal true=single */
|
||
|
||
/* Live (driven by hub triggerState broadcasts) */
|
||
std::string status = "idle"; /* idle|armed|collecting|triggered */
|
||
bool stopped = false;
|
||
bool hasTrigTime = false;
|
||
double trigTime = 0.0;
|
||
};
|
||
|
||
/** Per-signal vertical scale state (oscilloscope style). */
|
||
struct VScale {
|
||
int mode = 0; /* 0=auto 1=range 2=manual */
|
||
double divValue = 1.0; /* units per division (manual mode) */
|
||
double offset = 0.0; /* raw value at center (manual mode) */
|
||
double screenPos = 0.0; /* position offset in divisions from center */
|
||
bool digitalInMixed = false; /* quantize this trace in mixed plot mode */
|
||
/* Resolved each frame (read-only): */
|
||
double resolvedDiv = 1.0;
|
||
double resolvedOffset = 0.0;
|
||
};
|
||
|
||
/** Assignment of one signal to a plot panel. */
|
||
struct PlotAssignment {
|
||
int sourceIdx = -1;
|
||
int signalIdx = -1;
|
||
VScale vs;
|
||
};
|
||
|
||
/*---------------------------------------------------------------------------*/
|
||
/* Plot layouts */
|
||
/*---------------------------------------------------------------------------*/
|
||
|
||
/* Layout set mirrors the web UI: cols×rows */
|
||
enum class PlotLayout {
|
||
kLayout1x1 = 0,
|
||
kLayout2x1, /* 2 side by side */
|
||
kLayout1x2, /* 2 stacked */
|
||
kLayout3x1,
|
||
kLayout1x3,
|
||
kLayout2x2,
|
||
kLayout4x1,
|
||
kLayout1x4,
|
||
kCount
|
||
};
|
||
|
||
static inline const char* const PlotLayoutNames[] = {
|
||
"1×1", "2×1", "1×2", "3×1", "1×3", "2×2", "4×1", "1×4"
|
||
};
|
||
|
||
static inline void PlotLayoutDims(PlotLayout l, int& cols, int& rows) {
|
||
switch (l) {
|
||
case PlotLayout::kLayout1x1: cols=1; rows=1; break;
|
||
case PlotLayout::kLayout2x1: cols=2; rows=1; break;
|
||
case PlotLayout::kLayout1x2: cols=1; rows=2; break;
|
||
case PlotLayout::kLayout3x1: cols=3; rows=1; break;
|
||
case PlotLayout::kLayout1x3: cols=1; rows=3; break;
|
||
case PlotLayout::kLayout2x2: cols=2; rows=2; break;
|
||
case PlotLayout::kLayout4x1: cols=4; rows=1; break;
|
||
case PlotLayout::kLayout1x4: cols=1; rows=4; break;
|
||
default: cols=1; rows=1; break;
|
||
}
|
||
}
|
||
|
||
/*---------------------------------------------------------------------------*/
|
||
/* App */
|
||
/*---------------------------------------------------------------------------*/
|
||
|
||
class App {
|
||
public:
|
||
App();
|
||
~App();
|
||
|
||
/** @brief Initialise and start the WebSocket connection. */
|
||
void init(const std::string& host, uint16_t port);
|
||
|
||
/**
|
||
* @brief Call once per ImGui frame.
|
||
* Drains the WS receive queue, dispatches messages, renders all panels.
|
||
*/
|
||
void update();
|
||
|
||
/** @brief Disconnect and clean up. */
|
||
void shutdown();
|
||
|
||
/* ---- Accessors used by sub-panels ----------------------------------- */
|
||
|
||
std::vector<Source>& sources() { return sources_; }
|
||
const std::vector<Source>& sources() const { return sources_; }
|
||
TriggerState& trigger() { return trigger_; }
|
||
WSClient& ws() { return ws_; }
|
||
uint32_t maxPoints() const { return maxPoints_; }
|
||
|
||
/** @brief Last trigger capture received from the hub (nullptr if none). */
|
||
const CaptureFrame* capture() const { return hasCapture_ ? &capture_ : nullptr; }
|
||
void clearCapture() { hasCapture_ = false; }
|
||
|
||
/** @brief Get the plot assignment vector for plot index i. */
|
||
std::vector<PlotAssignment>& plotSlot(int i) { return plotSlots_[i]; }
|
||
int numPlotSlots() const;
|
||
|
||
PlotLayout plotLayout() const { return layout_; }
|
||
void setLayout(PlotLayout l);
|
||
|
||
/** @brief True if all plots are paused (global pause). */
|
||
bool globalPaused() const { return globalPaused_; }
|
||
void setGlobalPaused(bool p) { globalPaused_ = p; }
|
||
|
||
/** @brief Live-follow state per plot (true = auto-scroll with newest data). */
|
||
bool& liveFollow(int i) { return liveFollow_[i]; }
|
||
|
||
/** @brief Which slot index is "active" (selected) in the given plot (-1 = none). */
|
||
int& activeSlot(int i) { return activeSlot_[i]; }
|
||
|
||
/** @brief Time window width in seconds used by live scroll. */
|
||
double windowSec() const { return windowSec_; }
|
||
void setWindowSec(double s){ if (s >= 1e-4 && s <= 3600.0) windowSec_ = s; }
|
||
|
||
/** @brief Stored X range for non-live (zoom/pan) mode per plot. */
|
||
double plotXMin(int i) const { return plotXMin_[i]; }
|
||
double plotXMax(int i) const { return plotXMax_[i]; }
|
||
void setPlotX(int i, double mn, double mx) {
|
||
if (mx > mn + 1e-9) { plotXMin_[i] = mn; plotXMax_[i] = mx; }
|
||
}
|
||
/** @brief Call when transitioning live→non-live to seed the stored X range. */
|
||
void initPlotX(int i, double tMax) {
|
||
plotXMin_[i] = tMax - windowSec_;
|
||
plotXMax_[i] = tMax;
|
||
}
|
||
|
||
/** @brief Per-plot vertical normalisation: 0=normal 1=digital 2=mixed. */
|
||
int& plotVMode(int i) { return plotVMode_[i]; }
|
||
|
||
/* ---- Cursors A/B (global: shared & synchronised across all plots) ---- */
|
||
bool& cursorsOn() { return cursorsOn_; }
|
||
double& cursorA() { return cursorA_; }
|
||
double& cursorB() { return cursorB_; }
|
||
|
||
/* ---- Paused view snapshot (per plot) ---------------------------------- *
|
||
* Double-buffer scheme: the per-signal ring buffers stay "active" (always
|
||
* written by the WS thread-drain); a paused plot freezes a "view" copy of
|
||
* its slots' data at pause time and renders from it, so the picture does
|
||
* not drain away while the rings keep rolling. */
|
||
struct PlotSnapshot {
|
||
bool valid = false;
|
||
std::vector<std::string> keys; /* slot key "src:sig" */
|
||
std::vector<std::vector<double>> t, v;
|
||
};
|
||
PlotSnapshot& plotSnap(int i) { return plotSnap_[i]; }
|
||
|
||
/* ---- Zoom history (per plot) ----------------------------------------- */
|
||
std::vector<std::pair<double,double>>& zoomHist(int i) { return zoomHist_[i]; }
|
||
/** @brief Push the current stored X range onto the history (capped). */
|
||
void pushZoomHist(int i) {
|
||
auto& h = zoomHist_[i];
|
||
if (!h.empty() && h.back().first == plotXMin_[i] &&
|
||
h.back().second == plotXMax_[i]) { return; }
|
||
if (h.size() >= 64) { h.erase(h.begin()); }
|
||
h.emplace_back(plotXMin_[i], plotXMax_[i]);
|
||
}
|
||
|
||
/* ---- Hi-res WS zoom (per plot) ---------------------------------------- */
|
||
struct PlotZoomCache {
|
||
bool valid = false; /* signals[] match [t0,t1] */
|
||
bool pending = false; /* request in flight */
|
||
uint32_t reqId = 0;
|
||
double t0 = 0.0, t1 = 0.0; /* range of valid data */
|
||
double reqT0 = 0.0, reqT1 = 0.0; /* range of pending request */
|
||
std::vector<ZoomSignal> signals;
|
||
};
|
||
PlotZoomCache& zoomCache(int i) { return zoomCache_[i]; }
|
||
|
||
/** @brief Send a hub zoom request for plot i over [t0,t1] (2400 pts). */
|
||
void requestZoom(int plotIdx, double t0, double t1,
|
||
const std::string& signalsCsv);
|
||
|
||
/** @brief Full key "src:sig" for an assignment (empty if invalid). */
|
||
std::string slotKey(const PlotAssignment& a) const;
|
||
|
||
/* ---- History ------------------------------------------------------------ */
|
||
|
||
/** @brief History info received from the hub. */
|
||
const HistoryInfoMsg& historyInfo() const { return historyInfo_; }
|
||
|
||
/** @brief Send a historyZoom request. */
|
||
void requestHistoryZoom(int plotIdx, double t0, double t1,
|
||
const std::string& signalsCsv);
|
||
|
||
/** @brief Hi-res history zoom cache (per plot, parallel to zoomCache_). */
|
||
PlotZoomCache& histZoomCache(int i) { return histZoomCache_[i]; }
|
||
|
||
/* ---- Trigger zoom (per plot, used in trigger capture view) ----------- */
|
||
bool& trigZoomed(int i) { return trigZoomed_[i]; }
|
||
void resetTrigZoom() { for (int i = 0; i < kMaxPlotSlots; i++) trigZoomed_[i] = false; }
|
||
|
||
/** @brief True if the stats panel is open. */
|
||
bool& showStats() { return showStats_; }
|
||
bool& showAddSrc() { return showAddSrc_; }
|
||
bool& showTrigBar() { return showTrigBar_; }
|
||
bool& showHistBar() { return showHistBar_; }
|
||
|
||
/** @brief Find source index by id (-1 if not found). */
|
||
int findSource(const std::string& id) const;
|
||
|
||
/** @brief Find signal index in source by name (-1 if not found). */
|
||
int findSignal(const Source& src, const std::string& name) const;
|
||
|
||
private:
|
||
/* ---- WS message dispatch ------------------------------------------- */
|
||
void handleJSON(const std::string& json);
|
||
void handleBinary(const uint8_t* data, size_t len);
|
||
|
||
void onSources(const std::string& json);
|
||
void onConfig(const std::string& json);
|
||
void onStats(const std::string& json);
|
||
void onTriggerState(const std::string& json);
|
||
void onZoom(const std::string& json);
|
||
void onHistoryZoom(const std::string& json);
|
||
void onHistoryInfo(const std::string& json);
|
||
void onMaxPointsUpdated(const std::string& json);
|
||
|
||
/* ---- Rendering ------------------------------------------------------ */
|
||
void renderToolbar();
|
||
void renderHistoryBar();
|
||
void renderTriggerBar();
|
||
void renderSidebar();
|
||
void renderPlotGrid();
|
||
void renderStatsModal();
|
||
void renderAddSourceModal();
|
||
|
||
/* ---- State ---------------------------------------------------------- */
|
||
WSClient ws_;
|
||
std::vector<Source> sources_;
|
||
mutable std::mutex srcMutex_; /* protects sources_ */
|
||
|
||
TriggerState trigger_;
|
||
PlotLayout layout_ = PlotLayout::kLayout1x1;
|
||
uint32_t maxPoints_ = 1000000u;
|
||
bool globalPaused_ = false;
|
||
|
||
/* Plot slots: one vector of assignments per plot panel */
|
||
static const int kMaxPlotSlots = 8;
|
||
std::vector<PlotAssignment> plotSlots_[kMaxPlotSlots];
|
||
bool plotPaused_[kMaxPlotSlots] = {};
|
||
bool liveFollow_[kMaxPlotSlots] = {}; /* true = live-scroll X axis */
|
||
int activeSlot_[kMaxPlotSlots]; /* which slot is "active" (-1 = none) */
|
||
double windowSec_ = 10.0; /* live scroll window width */
|
||
double plotXMin_[kMaxPlotSlots] = {}; /* stored X min for non-live mode */
|
||
double plotXMax_[kMaxPlotSlots] = {}; /* stored X max for non-live mode */
|
||
int plotVMode_[kMaxPlotSlots] = {}; /* 0=normal 1=digital 2=mixed */
|
||
|
||
/* Cursors (global) */
|
||
bool cursorsOn_ = false;
|
||
double cursorA_ = 0.0;
|
||
double cursorB_ = 0.0;
|
||
|
||
/* Trigger zoom per plot (capture view zoom state) */
|
||
bool trigZoomed_[kMaxPlotSlots] = {};
|
||
|
||
/* Frozen view per plot (valid while the plot is paused) */
|
||
PlotSnapshot plotSnap_[kMaxPlotSlots];
|
||
|
||
/* Zoom history + hi-res zoom cache */
|
||
std::vector<std::pair<double,double>> zoomHist_[kMaxPlotSlots];
|
||
PlotZoomCache zoomCache_[kMaxPlotSlots];
|
||
PlotZoomCache histZoomCache_[kMaxPlotSlots];
|
||
uint32_t nextZoomReqId_ = 1;
|
||
|
||
/* History info from the hub */
|
||
HistoryInfoMsg historyInfo_;
|
||
double lastHistInfoReq_ = 0.0;
|
||
|
||
/* UI state */
|
||
bool showStats_ = false;
|
||
bool showAddSrc_ = false;
|
||
bool showTrigBar_ = false;
|
||
bool showHistBar_ = false;
|
||
bool sidebarOpen_ = true;
|
||
|
||
/* Connection fields (populated by init(), editable in menu bar) */
|
||
char hostBuf_[64] = "127.0.0.1";
|
||
char portBuf_[8] = "8090";
|
||
|
||
/* Track connect transition to send initial queries */
|
||
bool prevConnected_ = false;
|
||
|
||
/* Last trigger capture (version=2 binary frame; render thread only) */
|
||
CaptureFrame capture_;
|
||
bool hasCapture_ = false;
|
||
};
|
||
|
||
} /* namespace StreamHubClient */
|