// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include "condition.hpp" namespace udpscope { /// How an armed trigger re-arms after it fired. enum class TriggerMode { Normal, Single }; /// The trigger panel's configuration (spec item 2.2). /// /// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a /// channel: the panel lists every signal the stream describes, whether or not it /// is being acquired, and picking one is what arms the trigger on it. The /// acquisition resolves the signal to the channel carrying it. struct TriggerConfig { bool enabled = false; ///< trigger mode on/off (toolbar). bool armed = false; ///< arm/disarm button. int channel = -1; ///< index into ScopeModel::channels(), -1 = none. /// What counts as an event on that channel: the level and edge, and for the qualified /// shapes the width, band or timeout as well. The engine does not interpret it -- it /// hands it to an `EventDetector`. EventCondition condition{}; double pretrigger_pct = 50.0; ///< share of the buffer shown before the event. double holdoff_s = 0.010; ///< default from the spec. TriggerMode mode = TriggerMode::Normal; /// Seconds of post-trigger data to collect before latching the event, given /// the buffer's span. double post_span_s(double buffer_span_s) const; }; /// The producer-side trigger state machine. Fed one sample at a time in stream /// order; it says when the event buffer should be latched. class TriggerEngine { public: /// Configures the channel and the post-trigger window. Resets the state when the /// trigger moves to a different channel. /// /// `channel_name` is that channel's name, and it -- not `cfg.channel` -- is what /// identifies it here. The index is not stable: every rebuild of the channel list /// renumbers it (enabling a signal that sorts before the trigger's own is enough), /// and the model re-resolves the trigger by name afterwards. Keying the reset on the /// index therefore made any such rebuild abandon an open capture *and set no /// hold-off*, so the next edge inside the post window became t0 -- which is the /// "re-triggers mid-capture and ignores the hold-off" defect. Choosing a genuinely /// different source still resets, because the name changes with it. /// /// `post_seconds` is the post-trigger window in **stream seconds**, the same clock /// `process()` is fed and the same clock the x axis reads, so the capture covers /// exactly that much axis by construction. /// /// It used to be a count of samples, converted from seconds by a measured estimate /// of the channel's cadence. That estimate is what made the window wrong: when it /// collapsed (any stream whose time signal advances in large steps drove it to the /// 1 S/s floor) the count came out as 1, the engine latched on the edge sample /// itself, and the event had no post-trigger data at all. Seconds need no estimate. /// /// `pre_seconds` is the pre-trigger window, and `oldest_s` the stream time of the /// oldest sample the buffer still holds. Together they gate the edge: a capture is /// only allowed once the history reaches back a full `pre_seconds` before it, so an /// event cannot latch onto a half-filled pre window. `oldest_s` has to be the real /// buffer's, not a timer since arming -- the ring keeps filling with the trigger /// off, so the history is usually already there and waiting would be wrong. void configure(const TriggerConfig& cfg, const std::string& channel_name, double post_seconds, double pre_seconds, double oldest_s); /// Disarms without forgetting the configuration. void disarm() noexcept { armed_ = false; pending_ = false; } void arm(double now_s) noexcept; /// Feeds one sample of the trigger channel. `now_s` is the sample's timestamp /// in stream seconds. Returns true when the event has just been latched. bool process(double value, double now_s) noexcept; bool pending() const noexcept { return pending_; } bool armed() const noexcept { return armed_; } /// Diagnostics for the capture state machine: how many samples have landed in /// the post-trigger window, the window it is filling (in stream seconds), how /// much of that has elapsed, and the last value fed. std::uint64_t post_accum() const noexcept { return post_accum_count_; } double post_seconds() const noexcept { return post_seconds_; } double post_elapsed() const noexcept { return pending_ ? last_t_s_ - pending_start_s_ : 0.0; } /// How much history the buffer holds behind the newest sample, against the /// pre-trigger window an edge needs before it may open a capture. double history() const noexcept { return last_t_s_ - oldest_s_; } double pre_seconds() const noexcept { return pre_seconds_; } double last_value() const noexcept { return detector_.last_value(); } bool have_last() const noexcept { return detector_.have_last(); } double last_t() const noexcept { return last_t_s_; } double holdoff_until() const noexcept { return holdoff_until_s_; } /// Stream time of the edge that armed the event now on screen. Valid once /// `process()` has fired; the view is centred on this instant (pre-trigger share /// before it, post-trigger share after), so it is the x a trigger marker belongs /// at. Unchanged while a later event has not fired yet. double last_trigger_time() const noexcept { return last_trigger_s_; } private: TriggerConfig cfg_{}; std::string channel_name_; ///< the trigger channel's stable identity (see `configure`). bool armed_ = false; /// What an event *is*. The engine owns when one may open a capture; this owns whether /// the sample is one at all. EventDetector detector_{}; bool pending_ = false; ///< an edge was seen; collecting the post window double pending_start_s_ = 0.0; double holdoff_until_s_ = 0.0; std::uint64_t post_accum_count_ = 0; double post_seconds_ = 0.0; ///< post-trigger window, in stream seconds. double pre_seconds_ = 0.0; ///< pre-trigger window an edge needs behind it. double oldest_s_ = 0.0; ///< stream time of the oldest sample still buffered. double last_t_s_ = 0.0; double last_trigger_s_ = 0.0; }; } // namespace udpscope