diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 465299c..4fea332 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1890,7 +1890,8 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign did not run. - `stream: signals=4 expect=4 rate=1000.000 ok=1`: the enabled channels plus the plotted digital lines plus the counter, at the scan rate. `signals` below `expect` is a signal the source did not declare; `rate` off by a - factor is the pacer computed from the wrong aggregate. + factor is the pacer computed from the wrong aggregate. `ok` also requires `names=` to be the enabled analog + inputs, then the plotted digital lines, then `Counter`, in that order: swapped channels read `ok=0`. - `calibration: ai0_mean=0.50xx ok=1`: the mock's AI0 is 0.5 V under a 5 Hz sine of 0.1 V, read through non-trivial factory coefficients; the mean over two periods lands on 0.5 V only when `decode()` applies them. About `0.2043` is the **uncalibrated** reading (the calibration read failed, or `calibrate=0`); `nan` is a @@ -1902,7 +1903,9 @@ the one `[e1608] handshake with ...: Connection refused` line, `stream: ... sign clamp at both ends). It needs no board; `ok=0` is a change to the calibration arithmetic. - `reconnect: connects=2 connected=1`, `resumed: ok=1`: the mock dropped both links at about 3 s; the source reported it, retried after 2 s and the frame count moved on. `connects=1` is a source that never noticed the - drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart the scan. + drop (neither the scan read nor the status check raised it), `resumed: ok=0` is a reconnect that did not restart + the scan (it also reads 0 when `connects` is still 1, since a frame count that grows on a link that never dropped + proves nothing). ## `[prefs-probe]` diff --git a/src/layout_store.cpp b/src/layout_store.cpp index 0b42d37..d6454b8 100644 --- a/src/layout_store.cpp +++ b/src/layout_store.cpp @@ -55,12 +55,23 @@ namespace { std::string toml_escape(const std::string& s) { std::string out = "\""; for (char c : s) { - if (c == '"' || c == '\\') out.push_back('\\'); - out.push_back(c); + // The file is read line by line, so a raw newline in a value would end the line early. + if (c == '\n') { + out += "\\n"; + } else if (c == '\r') { + out += "\\r"; + } else { + if (c == '"' || c == '\\') out.push_back('\\'); + out.push_back(c); + } } return out + "\""; } +/// The character an escape `\x` stands for: `\n` and `\r` are the two line breaks, anything else +/// (the quote, the backslash) stands for itself. +char unescaped(char c) { return c == 'n' ? '\n' : c == 'r' ? '\r' : c; } + bool unescape(std::string& s) { std::string out; for (std::size_t i = 0; i < s.size(); ++i) { @@ -69,8 +80,8 @@ bool unescape(std::string& s) { continue; } if (++i >= s.size()) return false; - if (s[i] == '"' || s[i] == '\\') { - out.push_back(s[i]); + if (s[i] == '"' || s[i] == '\\' || s[i] == 'n' || s[i] == 'r') { + out.push_back(unescaped(s[i])); } else { return false; } @@ -472,8 +483,11 @@ bool load_layout(const std::string& path, LayoutState& out) { std::string s = v.substr(1, v.size() - 2); std::string out; for (std::size_t i = 0; i < s.size(); ++i) { - if (s[i] == '\\' && i + 1 < s.size()) ++i; - out.push_back(s[i]); + if (s[i] == '\\' && i + 1 < s.size()) { + out.push_back(unescaped(s[++i])); + } else { + out.push_back(s[i]); + } } return out; } diff --git a/src/source/e1608/e1608_kind.cpp b/src/source/e1608/e1608_kind.cpp index 8d0c7f0..2f89931 100644 --- a/src/source/e1608/e1608_kind.cpp +++ b/src/source/e1608/e1608_kind.cpp @@ -81,6 +81,7 @@ struct Form { GtkWidget *dir[8], *do_init, *plot_counter, *ao_init[2]; ChangeCb changed = std::make_shared>(); bool updating = false; + bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing bool diff() const { return sel_of(mode) == 1; } int enabled_ai() const { @@ -170,17 +171,27 @@ using FormPtr = std::shared_ptr
; void on_notify(GObject*, GParamSpec*, gpointer user) { Form& f = **static_cast(user); + if (!f.alive) return; f.refresh(); if (!f.updating && f.changed && *f.changed) (*f.changed)(); } void on_edited(GtkEditable*, gpointer user) { Form& f = **static_cast(user); + if (!f.alive) return; if (!f.updating && f.changed && *f.changed) (*f.changed)(); } void free_form(gpointer d, GClosure*) { delete static_cast(d); } +/// "destroy" on a row group: the siblings the handlers reach by raw pointer may already be gone. +void on_form_destroy(GtkWidget*, gpointer user) { (**static_cast(user)).alive = false; } + +void hook_destroy(GtkWidget* w, const FormPtr& f) { + g_signal_connect_data(w, "destroy", G_CALLBACK(on_form_destroy), new FormPtr(f), free_form, + static_cast(0)); +} + void hook_notify(GtkWidget* w, const char* signal, const FormPtr& f) { g_signal_connect_data(w, signal, G_CALLBACK(on_notify), new FormPtr(f), free_form, static_cast(0)); @@ -254,6 +265,7 @@ class E1608Kind final : public SourceKind { f->ai_group = ai; adw_expander_row_set_expanded(ADW_EXPANDER_ROW(conn), TRUE); + for (GtkWidget* w : {conn, acq, ai, dio}) hook_destroy(w, f); for (GtkWidget* w : {f->address, f->mac, f->subnet}) hook_entry(w, f); for (GtkWidget* w : {f->port, f->code, f->timeout, f->rate, f->poll_rate, f->do_init, f->ao_init[0], f->ao_init[1]}) diff --git a/src/source/e1608/e1608_link.cpp b/src/source/e1608/e1608_link.cpp index 3055ff3..566d0a0 100644 --- a/src/source/e1608/e1608_link.cpp +++ b/src/source/e1608/e1608_link.cpp @@ -199,9 +199,16 @@ std::vector discover(const std::vector& targets, int po } catch (const LinkError&) { continue; } - // A sweep may hit unreachable hosts; those sends fail and are simply skipped. - (void)::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&a), - sizeof a); + // A sweep may hit unreachable hosts; those sends fail and are simply skipped. A full send + // buffer is not that: wait for room once and retry, so a large sweep does not lose probes. + const auto send_probe = [&] { + return ::sendto(u.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast(&a), + sizeof a); + }; + if (send_probe() < 0 && (errno == EAGAIN || errno == ENOBUFS)) { + wait_fd(u.get(), POLLOUT, 20); + (void)send_probe(); + } } const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms); for (;;) { @@ -288,7 +295,8 @@ std::vector Link::command(std::uint8_t cmd, const std::vector 0) { } } - const std::uint8_t id = ++next_id_; + if (++next_id_ == 0) next_id_ = 1; // id 0 is never used, in case a board treats it specially + const std::uint8_t id = next_id_; std::vector f; f.reserve(7 + payload.size()); f.push_back(0xDB); @@ -349,6 +357,7 @@ void Link::send_filler() { std::size_t Link::read_scan(std::uint8_t* buf, std::size_t cap, int timeout_ms) { if (!scan_.valid()) throw LinkError(Kind::Io, "the scan socket is not open"); + if (cap == 0) return 0; // recv would return 0 and read as a close if (!wait_fd(scan_.get(), POLLIN, timeout_ms)) return 0; const ssize_t n = ::recv(scan_.get(), buf, cap, MSG_DONTWAIT); if (n > 0) return static_cast(n); diff --git a/src/source/e1608/e1608_source.cpp b/src/source/e1608/e1608_source.cpp index 81f5565..d7d8d7f 100644 --- a/src/source/e1608/e1608_source.cpp +++ b/src/source/e1608/e1608_source.cpp @@ -430,10 +430,12 @@ void E1608Source::check_status() { sink_->on_event(SourceEvent::Error, "scan overrun; restarting"); restart_scan(); pending_lost_ = 1; + last_logged_.clear(); // a later overrun on this connection is news again, not a retry echo } else if (!(st & 2) && cfg_.auto_restart && cfg_.trigger == Trigger::None) { // With a hardware trigger the board may report "not running" while it waits for the edge; // restarting then would reset the wait forever, so only the overrun bit restarts the scan. restart_scan(); + pending_lost_ = 1; // the samples between the stop and the restart are gone either way } } diff --git a/src/source/source_config.cpp b/src/source/source_config.cpp index 99f810f..f0fe4b9 100644 --- a/src/source/source_config.cpp +++ b/src/source/source_config.cpp @@ -5,6 +5,8 @@ #include #include +#include +#include #include namespace udpscope { @@ -35,16 +37,18 @@ long SourceConfig::get_int(const std::string& key, long def) const { if (!has(key)) return def; const std::string v = get(key); char* end = nullptr; + errno = 0; const long n = std::strtol(v.c_str(), &end, 10); - return (end == v.c_str() || *end != '\0') ? def : n; + return (end == v.c_str() || *end != '\0' || errno == ERANGE) ? def : n; } double SourceConfig::get_double(const std::string& key, double def) const { if (!has(key)) return def; const std::string v = get(key); char* end = nullptr; + errno = 0; const double d = std::strtod(v.c_str(), &end); - return (end == v.c_str() || *end != '\0') ? def : d; + return (end == v.c_str() || *end != '\0' || std::isinf(d)) ? def : d; } bool SourceConfig::get_bool(const std::string& key, bool def) const { diff --git a/src/source/udps_kind.cpp b/src/source/udps_kind.cpp index ffe53c7..22431a0 100644 --- a/src/source/udps_kind.cpp +++ b/src/source/udps_kind.cpp @@ -25,6 +25,17 @@ void on_edited(GtkEditable*, gpointer user) { if (cb && *cb) (*cb)(); } +void on_value(GObject*, GParamSpec*, gpointer user) { + const auto& cb = *static_cast(user); + if (cb && *cb) (*cb)(); +} + +void hook_value(GtkWidget* spin, const ChangeCb& cb) { + g_signal_connect_data(spin, "notify::value", G_CALLBACK(on_value), new ChangeCb(cb), + +[](gpointer d, GClosure*) { delete static_cast(d); }, + static_cast(0)); +} + void hook(GtkWidget* entry, const ChangeCb& cb) { g_signal_connect_data(entry, "changed", G_CALLBACK(on_edited), new ChangeCb(cb), +[](gpointer d, GClosure*) { delete static_cast(d); }, @@ -58,6 +69,7 @@ class UdpsKind final : public SourceKind { ChangeCb cb = std::make_shared>(); hook(server, cb); + hook_value(port, cb); hook(group, cb); hook(iface, cb); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 510608b..5ab9cd5 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -2058,8 +2058,19 @@ gboolean e1608_probe_report(gpointer user) { for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name; const double rate = sigs.empty() ? 0.0 : sigs.front().sampling_rate; const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed); - const bool stream_ok = up && sigs.size() == expect && std::fabs(rate - s.scan_rate) < 1e-6 && - frames > 0; + // The signals' order is part of the contract: the enabled analog inputs, then the plotted DI + // lines, then the counter. Swapped channels must not read ok. + std::string expect_names; + const auto add_name = [&](const std::string& n) { expect_names += (expect_names.empty() ? "" : ",") + n; }; + for (int i = 0; i < 8; ++i) { + if (s.ai_on[i]) add_name("AI" + std::to_string(i)); + } + for (int i = 0; i < 8; ++i) { + if (s.plot[i]) add_name("DI" + std::to_string(i)); + } + if (s.plot_counter) add_name("Counter"); + const bool stream_ok = up && sigs.size() == expect && names == expect_names && + std::fabs(rate - s.scan_rate) < 1e-6 && frames > 0; std::fprintf(stderr, "[e1608] stream: connected=%d signals=%zu expect=%zu names=%s frames=%" G_GUINT64_FORMAT " rate=%.3f ok=%d\n", @@ -2146,13 +2157,17 @@ gboolean e1608_probe_resumed(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed); + // Frames alone also grow on a link that never dropped: the reconnect must have happened too. + e1608::E1608Source* src = e1608_source(a); + const bool reconnected = src != nullptr && src->connects() >= 2; std::fprintf(stderr, "[e1608] resumed: frames_before=%" G_GUINT64_FORMAT " frames_after=%" G_GUINT64_FORMAT " ok=%d\n", - g_e1608_frames_before, after, after > g_e1608_frames_before ? 1 : 0); + g_e1608_frames_before, after, reconnected && after > g_e1608_frames_before ? 1 : 0); std::fflush(stderr); return G_SOURCE_REMOVE; } +/// After the mock dropped the link: the source's connect count, then (500 ms later) whether the stream moves. gboolean e1608_probe_reconnect(gpointer user) { App* a = static_cast(user); if (a->window == nullptr) return G_SOURCE_REMOVE; diff --git a/src/ui_source.cpp b/src/ui_source.cpp index 90927af..c7c7c80 100644 --- a/src/ui_source.cpp +++ b/src/ui_source.cpp @@ -6,6 +6,8 @@ #include +#include + namespace udpscope { namespace ui_impl { @@ -14,7 +16,11 @@ void build_source_panels(App& app) { SourceKind* kind = find_kind(app.source_kind); if (kind == nullptr) return; for (const SourcePanel& p : kind->make_panels(*app.acq->source())) { - if (p.widget == nullptr || p.id.empty()) continue; + if (p.widget == nullptr || p.id.empty()) { + std::fprintf(stderr, "[source] a '%s' panel was skipped: it has %s\n", app.source_kind.c_str(), + p.widget == nullptr ? "no widget" : "an empty id"); + continue; + } // Each page scrolls on its own, like Measure and Signals: panels differ in height. GtkWidget* scroller = gtk_scrolled_window_new(); gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,