diff --git a/CMakeLists.txt b/CMakeLists.txt index 01e1bf4..fb64fdd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,6 +247,7 @@ add_executable(udpscope src/source/e1608/e1608_source.cpp src/source/e1608/e1608_kind.cpp src/source/e1608/e1608_panels.cpp + src/source/flatudp/flatudp_signals.cpp src/plot_host.cpp src/export.cpp src/search.cpp diff --git a/src/source/flatudp/flatudp_signals.cpp b/src/source/flatudp/flatudp_signals.cpp new file mode 100644 index 0000000..fb67e90 --- /dev/null +++ b/src/source/flatudp/flatudp_signals.cpp @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "source/flatudp/flatudp_signals.hpp" + +#include +#include +#include +#include +#include + +namespace udpscope::flatudp { +namespace { + +constexpr const char* kNames[] = {"uint8", "int8", "uint16", "int16", "uint32", + "int32", "uint64", "int64", "float32", "float64"}; +constexpr std::size_t kSizes[] = {1, 1, 2, 2, 4, 4, 8, 8, 4, 8}; + +std::string trim(const std::string& s) { + std::size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +/// A name must survive `format` and `parse`: none of the separators, no edge whitespace. +bool valid_name(const std::string& n) { + if (n.empty()) return false; + if (std::isspace(static_cast(n.front())) || + std::isspace(static_cast(n.back()))) + return false; + for (char c : n) { + if (c == ',' || c == ':' || c == '[' || c == ']' || static_cast(c) < 0x20) + return false; + } + return true; +} + +template +double load(const std::uint8_t* p, bool swap) { + unsigned char b[sizeof(T)]; + std::memcpy(b, p, sizeof(T)); + if (swap) std::reverse(b, b + sizeof(T)); + T v; + std::memcpy(&v, b, sizeof(T)); + return static_cast(v); +} + +double load_typed(Type t, const std::uint8_t* p, bool swap) { + switch (t) { + case Type::U8: return load(p, swap); + case Type::I8: return load(p, swap); + case Type::U16: return load(p, swap); + case Type::I16: return load(p, swap); + case Type::U32: return load(p, swap); + case Type::I32: return load(p, swap); + case Type::U64: return load(p, swap); + case Type::I64: return load(p, swap); + case Type::F32: return load(p, swap); + case Type::F64: return load(p, swap); + } + return 0.0; +} + +} // namespace + +std::size_t type_size(Type t) { return kSizes[static_cast(t)]; } +const char* type_name(Type t) { return kNames[static_cast(t)]; } + +bool parse_type(const std::string& s, Type& out) { + for (std::size_t i = 0; i < 10; ++i) { + if (s == kNames[i]) { + out = static_cast(i); + return true; + } + } + return false; +} + +std::size_t datagram_size(const std::vector& rows) { + std::size_t n = 0; + for (const Row& r : rows) n += type_size(r.type) * static_cast(r.count); + return n; +} + +bool all_scalar(const std::vector& rows) { + return std::all_of(rows.begin(), rows.end(), [](const Row& r) { return r.count == 1; }); +} + +std::string format(const std::vector& rows) { + std::string out; + for (const Row& r : rows) { + if (!out.empty()) out += ','; + out += r.name + ':' + type_name(r.type); + if (r.count != 1) out += '[' + std::to_string(r.count) + ']'; + } + return out; +} + +std::string parse(const std::string& text, std::vector& rows) { + rows.clear(); + if (trim(text).empty()) return {}; + std::size_t pos = 0; + int index = 0; + for (;;) { + std::size_t end = text.find(',', pos); + if (end == std::string::npos) end = text.size(); + ++index; + const std::string where = "signal " + std::to_string(index) + ": "; + const std::string tok = trim(text.substr(pos, end - pos)); + const std::size_t colon = tok.find(':'); + if (colon == std::string::npos) { + rows.clear(); + return where + "expected name:type"; + } + Row r; + r.name = trim(tok.substr(0, colon)); + std::string ty = trim(tok.substr(colon + 1)); + const std::size_t br = ty.find('['); + if (br != std::string::npos) { + if (ty.back() != ']') { + rows.clear(); + return where + "missing ']' after the count"; + } + const std::string n = trim(ty.substr(br + 1, ty.size() - br - 2)); + const bool digits = !n.empty() && n.size() <= 6 && + std::all_of(n.begin(), n.end(), [](char c) { return c >= '0' && c <= '9'; }); + if (!digits) { + rows.clear(); + return where + "the count must be a whole number"; + } + r.count = static_cast(std::stoul(n)); + ty = trim(ty.substr(0, br)); + } + if (!parse_type(ty, r.type)) { + rows.clear(); + return where + "unknown type '" + ty + "'"; + } + rows.push_back(r); + if (end >= text.size()) break; + pos = end + 1; + } + return {}; +} + +std::string problem(const std::vector& rows) { + if (rows.empty()) return "add at least one signal"; + std::set seen; + for (std::size_t i = 0; i < rows.size(); ++i) { + const Row& r = rows[i]; + if (r.name.empty()) return "signal " + std::to_string(i + 1) + " has no name"; + if (!valid_name(r.name)) + return "'" + r.name + "': a name cannot hold , : [ ] or edge spaces"; + if (!seen.insert(r.name).second) return "the name '" + r.name + "' is used twice"; + if (r.count < 1 || r.count > kMaxCount) + return "'" + r.name + "': the count must be 1 to " + std::to_string(kMaxCount); + } + const std::size_t n = datagram_size(rows); + if (n > kMaxDatagram) + return std::to_string(n) + " bytes per datagram exceed the " + std::to_string(kMaxDatagram) + + " a UDP datagram can carry"; + return {}; +} + +void decode_datagram(const std::vector& rows, const std::uint8_t* data, bool big_endian, + std::vector>& cols) { + if (cols.size() < rows.size()) cols.resize(rows.size()); + const bool swap = big_endian != (std::endian::native == std::endian::big); + std::size_t off = 0; + for (std::size_t s = 0; s < rows.size(); ++s) { + const std::size_t w = type_size(rows[s].type); + for (std::uint32_t k = 0; k < rows[s].count; ++k) { + cols[s].push_back(load_typed(rows[s].type, data + off, swap)); + off += w; + } + } +} + +} // namespace udpscope::flatudp diff --git a/src/source/flatudp/flatudp_signals.hpp b/src/source/flatudp/flatudp_signals.hpp new file mode 100644 index 0000000..a27ccb5 --- /dev/null +++ b/src/source/flatudp/flatudp_signals.hpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#pragma once + +#include +#include +#include +#include + +namespace udpscope::flatudp { + +/// The numeric value is the `SignalInfo::type_code` (the order of the client library's `UDPS_T_*`). +enum class Type : std::uint8_t { U8 = 0, I8, U16, I16, U32, I32, U64, I64, F32, F64 }; + +constexpr std::uint32_t kMaxCount = 4096; +constexpr std::size_t kMaxDatagram = 65507; ///< the largest UDP payload over IPv4 + +/// One signal of the sender's datagram: `count` consecutive values of `type`. +struct Row { + std::string name; + Type type = Type::F32; + std::uint32_t count = 1; + bool operator==(const Row&) const = default; +}; + +std::size_t type_size(Type t); +const char* type_name(Type t); ///< "uint8" ... "float64" +bool parse_type(const std::string& s, Type& out); + +/// The bytes one datagram must have: the sum of `type_size * count`. +std::size_t datagram_size(const std::vector& rows); +/// True when no row is a vector. Such a list can be batched, several datagrams to a frame. +bool all_scalar(const std::vector& rows); + +/// `name:type[,name:type[count]...]`; a count of 1 is written without brackets. +std::string format(const std::vector& rows); +/// The inverse of `format`. Whitespace around names, types and counts is ignored. An empty text +/// is an empty list (and not an error: `problem` reports it). Returns an error in words, empty +/// on success; `rows` is empty after a failure. +std::string parse(const std::string& text, std::vector& rows); + +/// The first reason this list cannot describe a datagram, in words for the user; empty when valid. +std::string problem(const std::vector& rows); + +/// Decodes one datagram of `datagram_size(rows)` bytes to doubles, row by row, byte-swapping when +/// `big_endian` differs from the host. Appends to `cols[s]`; the caller clears them between uses. +void decode_datagram(const std::vector& rows, const std::uint8_t* data, bool big_endian, + std::vector>& cols); + +} // namespace udpscope::flatudp diff --git a/src/ui.cpp b/src/ui.cpp index bb04d6a..e8321d7 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -6118,6 +6118,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_RECONNECT")) { g_timeout_add(static_cast(std::atoi(env)), e1608_probe_reconnect, &app); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) { + g_timeout_add(static_cast(std::atoi(env)), flatudp_probe, &app); + } // Our own options were consumed by parse_args(); GTK would reject them, so only // the program name is handed to g_application_run(). diff --git a/src/ui_app.hpp b/src/ui_app.hpp index a92a403..e68bf74 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -1883,6 +1883,9 @@ gboolean e1608_probe(gpointer user); gboolean e1608_probe_report(gpointer user); gboolean e1608_probe_reconnect(gpointer user); gboolean e1608_probe_resumed(gpointer user); +/// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then, +/// against `tools/mock_flatudp.py`, the stream, the wrong-size accounting and the silence timeout. +gboolean flatudp_probe(gpointer user); gboolean pan_probe(gpointer user); gboolean arm_probe(gpointer user); gboolean trigrenum_probe_step3(gpointer user); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 5ab9cd5..4f147f7 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -8,6 +8,7 @@ #include "source/probe_source.hpp" #include "source/e1608/e1608_source.hpp" +#include "source/flatudp/flatudp_signals.hpp" #include "source/registry.hpp" #include "ui_app.hpp" @@ -2037,6 +2038,70 @@ bool e1608_decode_check() { return true; } +/// The `signals` codec and the datagram decode against values worked out by hand. The row set +/// mixes every width and a vector: A:uint8, B:int16, C:float32, D:uint64[2], E:int8, 24 bytes. +/// A = 254, B = -2, C = 1.5, D = {1, 4294967298}, E = -1. +bool flatudp_codec_check() { + using namespace flatudp; + std::vector rows; + if (!parse(" A:uint8, B:int16,C : float32 ,D:uint64[2],E:int8", rows).empty()) return false; + const std::vector want = {{"A", Type::U8, 1}, {"B", Type::I16, 1}, {"C", Type::F32, 1}, + {"D", Type::U64, 2}, {"E", Type::I8, 1}}; + if (rows != want) return false; + if (format(rows) != "A:uint8,B:int16,C:float32,D:uint64[2],E:int8") return false; + if (datagram_size(rows) != 24 || all_scalar(rows) || !problem(rows).empty()) return false; + // The enum value is the type code the model keeps. + if (static_cast(Type::F64) != 9 || static_cast(Type::U64) != 6) return false; + + const std::uint8_t le[24] = {0xFE, // A + 0xFE, 0xFF, // B + 0x00, 0x00, 0xC0, 0x3F, // C + 0x01, 0, 0, 0, 0, 0, 0, 0, // D[0] + 0x02, 0, 0, 0, 0x01, 0, 0, 0, // D[1] + 0xFF}; // E + const std::uint8_t be[24] = {0xFE, + 0xFF, 0xFE, + 0x3F, 0xC0, 0x00, 0x00, + 0, 0, 0, 0, 0, 0, 0, 0x01, + 0, 0, 0, 0x01, 0, 0, 0, 0x02, + 0xFF}; + const double expect[5][2] = {{254, 0}, {-2, 0}, {1.5, 0}, {1, 4294967298.0}, {-1, 0}}; + const std::size_t counts[5] = {1, 1, 1, 2, 1}; + for (int pass = 0; pass < 2; ++pass) { + std::vector> cols; + decode_datagram(rows, pass == 0 ? le : be, pass == 1, cols); + if (cols.size() != 5) return false; + for (std::size_t s = 0; s < 5; ++s) { + if (cols[s].size() != counts[s]) return false; + for (std::size_t k = 0; k < counts[s]; ++k) + if (cols[s][k] != expect[s][k]) return false; + } + } + + // Parse failures name the signal; a bad list never half-parses. + std::vector bad; + if (parse("A:uint8,B", bad).find("signal 2") == std::string::npos || !bad.empty()) return false; + if (parse("A:uint7", bad).find("uint7") == std::string::npos) return false; + if (parse("A:uint8[x]", bad).empty() || parse("A:uint8[3", bad).empty()) return false; + if (!parse("", bad).empty() || !bad.empty() || problem(bad).empty()) return false; + + // The validity rules. + const auto prob = [](std::vector r) { return problem(r); }; + // One call per statement: GCC warns (-Wdangling-pointer) on two list temporaries joined by `||`. + const auto valid = [](std::vector r) { return problem(r).empty(); }; + if (prob({{"A", Type::U8, 1}, {"A", Type::U8, 1}}).find("twice") == std::string::npos) return false; + if (valid({{"", Type::U8, 1}})) return false; + if (valid({{"a,b", Type::U8, 1}})) return false; + if (valid({{"A", Type::U8, 0}})) return false; + if (valid({{"A", Type::U8, 4097}})) return false; + if (!valid({{"A", Type::U8, 4096}})) return false; + // 2 x 32768 = 65536 bytes is over the datagram limit; 32768 + 16384 is not. + if (prob({{"A", Type::F64, 4096}, {"B", Type::F64, 4096}}).find("65536") == std::string::npos) + return false; + if (!valid({{"A", Type::F64, 4096}, {"B", Type::F32, 4096}})) return false; + return true; +} + } // namespace /// Phase 2, 1 s after the controls were driven: the stream, the calibration, the capture button, @@ -2182,6 +2247,15 @@ gboolean e1608_probe_reconnect(gpointer user) { return G_SOURCE_REMOVE; } +/// The flat UDP source's probe. For now only the pure codec check; it needs no flatudp source. +gboolean flatudp_probe(gpointer user) { + App* a = static_cast(user); + if (a->window == nullptr) return G_SOURCE_REMOVE; + std::fprintf(stderr, "[flatudp] codec: ok=%d\n", flatudp_codec_check() ? 1 : 0); + std::fflush(stderr); + return G_SOURCE_REMOVE; +} + gboolean pan_probe(gpointer user) { App* a = static_cast(user); if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;