Files
udpscope/docs/superpowers/plans/2026-11-02-flat-udp-source.md
T

2204 lines
95 KiB
Markdown

# Flat UDP Source Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `flatudp` source kind: a receiver for the MARTe2 `UDPSender` datagram (signals concatenated in declaration order, no header), configured by a hand-entered signal list.
**Architecture:** Three new units under `src/source/flatudp/`: a GTK-free signal-list codec, a `DataSource` that owns a non-blocking UDP socket and does all its work inside `poll()` on the producer thread, and a `SourceKind` with a row-editor connect form. The existing pipeline stamps the frames from the declared `rate`; the source never reports loss.
**Tech Stack:** C++20, GTK4/libadwaita (>= 1.8), POSIX sockets, Python 3 for the mock sender. No unit-test suite: the contract is the application's own headless self-check (`xvfb-run`, env-scheduled probes in `src/ui_probe.cpp`).
Spec: `docs/superpowers/specs/2026-11-02-flat-udp-source-design.md`. Branch: `feature/source-flatudp`, cut from `feature/source-plugins` (do not push or merge).
## Global Constraints
- Kind id `flatudp`; new files `src/source/flatudp/flatudp_{signals,source,kind}.{hpp,cpp}`, `tools/mock_flatudp.py`.
- Every source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`; `#` form in Python).
- Config keys: `port` (default 44488), `bind` (empty), `multicast` (empty), `iface` (empty), `rate` (none, required, Hz), `endian` (`little` default or `big`), `signals` (`name:type[count],...`).
- Types: `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`. Count 1 is a scalar signal, N a vector signal.
- Validity (form and `--source-opt` alike): at least one signal; names non-empty and unique; count 1-4096; total at most 65507 bytes; `rate` finite and positive.
- One datagram is one cycle. A datagram of any other size than the expected one is dropped, never decoded.
- Batching: all rows scalar means `block = clamp(round(rate / 100), 1, 1024)` datagrams per `Frame`, a partial block flushed after 100 ms. Any vector row means one datagram per `Frame` (`num_samples = 1`).
- `Frame`: `counter` a running frame count, `lost = 0`, `late = false`, `recv_time` the wall-clock epoch seconds at delivery, `publish_mode = 1`.
- `on_config` is sent from the first `poll()`: one `SignalInfo` per row (`rows = count`, `cols = 1`, `time_mode = kTimePacket`, `sampling_rate = rate`).
- `Connected` on the first valid datagram, `Disconnected` after 2 s of silence, `Connected` again on the next valid datagram. Each distinct wrong size: one `Error` ("datagram of N bytes, expected M, check the signal list"), one stderr line, `stale` incremented per wrong datagram.
- `has_datagrams()` is true and `set_datagram_tap` is honoured for every datagram, valid or not.
- Threading unchanged: the source lives on the producer thread; the probe reads only atomics.
- GTK traps: notify handlers are `(GObject*, GParamSpec*, gpointer)`, `clicked` is `(GtkButton*, gpointer)`, `changed` is `(GtkEditable*, gpointer)`, spin `value-changed` is `(GtkSpinButton*, gpointer)`; contexts are freed by the destroy notify of `g_signal_connect_data`; rows never parented are `g_object_ref_sink` + `g_object_unref` in probes; no theme icons (text buttons only).
- Baselines of every run: a clean build has exactly 3 warnings (an incremental one shows the 2 pre-existing `ui_probe.cpp` `MathNode::source` ones; none from the new files), the exit dump reads `gtk warnings: 0`, no `Gtk-CRITICAL`.
- Keep verification runs short and serial; kill every mock afterwards.
- Never push, never merge. Commit per task.
## File Structure
| File | Responsibility |
| --- | --- |
| `src/source/flatudp/flatudp_signals.{hpp,cpp}` (new) | `Type`/`Row`, the `signals` string codec (`parse`, `format`), `problem`, size arithmetic, `decode_datagram`. No GTK, no sockets. |
| `src/source/flatudp/flatudp_source.{hpp,cpp}` (new) | `FlatUdpSettings` (config parsing and validity, `valid_ipv4`, `valid_multicast`) and `FlatUdpSource` (socket, batching, events). |
| `src/source/flatudp/flatudp_kind.cpp` (new) | `FlatUdpKind`, the connect form, `make_flatudp_kind()`. |
| `src/source/registry.{hpp,cpp}` | Declare and register `make_flatudp_kind()` after e1608. |
| `CMakeLists.txt` | The five new `.cpp` files. |
| `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp` | The `UDPSCOPE_PROBE_FLATUDP` probe family and the `[source-kinds]` sample. |
| `tools/mock_flatudp.py` (new) | A UDPSender-shaped packet source for a signal spec. |
| `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md` | The kind, its limits, the run recipes and the probe readings. |
## Task ordering
1. **Codec** (pure, checked by a hand-computed probe line).
2. **Settings, source, kind with an interim form, mock, stream probes, docs** (end to end against the mock).
3. **Row-editor form**, its probe lines, README and architecture docs.
---
### Task 1: The signal-list codec
**Files:**
- Create: `src/source/flatudp/flatudp_signals.hpp`, `src/source/flatudp/flatudp_signals.cpp`
- Modify: `CMakeLists.txt` (source list, after `src/source/e1608/e1608_panels.cpp`)
- Modify: `src/ui_probe.cpp` (include, `flatudp_codec_check()` and `flatudp_probe()` after `e1608_probe_reconnect`)
- Modify: `src/ui_app.hpp` (declaration after `e1608_probe_resumed`), `src/ui.cpp` (env hook after `UDPSCOPE_PROBE_E1608_RECONNECT`)
**Interfaces:**
- Consumes: nothing.
- Produces (namespace `udpscope::flatudp`, header `source/flatudp/flatudp_signals.hpp`):
- `enum class Type : std::uint8_t { U8 = 0, I8, U16, I16, U32, I32, U64, I64, F32, F64 }` -- the numeric value **is** the `SignalInfo::type_code` (`UDPS_T_*` order).
- `constexpr std::uint32_t kMaxCount = 4096; constexpr std::size_t kMaxDatagram = 65507;`
- `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)`, `const char* type_name(Type)`, `bool parse_type(const std::string&, Type&)`
- `std::size_t datagram_size(const std::vector<Row>&)`, `bool all_scalar(const std::vector<Row>&)`
- `std::string format(const std::vector<Row>&)`; `std::string parse(const std::string& text, std::vector<Row>& rows)` (returns an error text, empty on success; `rows` is cleared on failure)
- `std::string problem(const std::vector<Row>&)` (first validity failure, empty when valid)
- `void decode_datagram(const std::vector<Row>&, const std::uint8_t* data, bool big_endian, std::vector<std::vector<double>>& cols)` -- appends row `s`'s `count` values to `cols[s]` (growing `cols` to `rows.size()`); the caller guarantees `datagram_size(rows)` bytes at `data`.
- In `ui_app.hpp`: `gboolean flatudp_probe(gpointer user);`
- [ ] **Step 1: Create the branch**
```bash
git checkout feature/source-plugins && git checkout -b feature/source-flatudp
```
- [ ] **Step 2: Write the header**
Create `src/source/flatudp/flatudp_signals.hpp`:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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<Row>& 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<Row>& rows);
/// `name:type[,name:type[count]...]`; a count of 1 is written without brackets.
std::string format(const std::vector<Row>& 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<Row>& rows);
/// The first reason this list cannot describe a datagram, in words for the user; empty when valid.
std::string problem(const std::vector<Row>& 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<Row>& rows, const std::uint8_t* data, bool big_endian,
std::vector<std::vector<double>>& cols);
} // namespace udpscope::flatudp
```
- [ ] **Step 3: Write the implementation**
Create `src/source/flatudp/flatudp_signals.cpp`:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_signals.hpp"
#include <algorithm>
#include <bit>
#include <cctype>
#include <cstring>
#include <set>
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<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(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<unsigned char>(n.front())) ||
std::isspace(static_cast<unsigned char>(n.back())))
return false;
for (char c : n) {
if (c == ',' || c == ':' || c == '[' || c == ']' || static_cast<unsigned char>(c) < 0x20)
return false;
}
return true;
}
template <class T>
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<double>(v);
}
double load_typed(Type t, const std::uint8_t* p, bool swap) {
switch (t) {
case Type::U8: return load<std::uint8_t>(p, swap);
case Type::I8: return load<std::int8_t>(p, swap);
case Type::U16: return load<std::uint16_t>(p, swap);
case Type::I16: return load<std::int16_t>(p, swap);
case Type::U32: return load<std::uint32_t>(p, swap);
case Type::I32: return load<std::int32_t>(p, swap);
case Type::U64: return load<std::uint64_t>(p, swap);
case Type::I64: return load<std::int64_t>(p, swap);
case Type::F32: return load<float>(p, swap);
case Type::F64: return load<double>(p, swap);
}
return 0.0;
}
} // namespace
std::size_t type_size(Type t) { return kSizes[static_cast<std::size_t>(t)]; }
const char* type_name(Type t) { return kNames[static_cast<std::size_t>(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<Type>(i);
return true;
}
}
return false;
}
std::size_t datagram_size(const std::vector<Row>& rows) {
std::size_t n = 0;
for (const Row& r : rows) n += type_size(r.type) * static_cast<std::size_t>(r.count);
return n;
}
bool all_scalar(const std::vector<Row>& rows) {
return std::all_of(rows.begin(), rows.end(), [](const Row& r) { return r.count == 1; });
}
std::string format(const std::vector<Row>& 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<Row>& 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::uint32_t>(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<Row>& rows) {
if (rows.empty()) return "add at least one signal";
std::set<std::string> 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<Row>& rows, const std::uint8_t* data, bool big_endian,
std::vector<std::vector<double>>& 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
```
- [ ] **Step 4: Register in CMake**
In `CMakeLists.txt`, after the line ` src/source/e1608/e1608_panels.cpp` add:
```cmake
src/source/flatudp/flatudp_signals.cpp
```
- [ ] **Step 5: Write the failing probe (codec check)**
In `src/ui_probe.cpp` add the include after `#include "source/e1608/e1608_source.hpp"`:
```cpp
#include "source/flatudp/flatudp_signals.hpp"
```
In the anonymous namespace, directly after `e1608_decode_check()`'s closing brace and before the namespace's closing `} // namespace`, add:
```cpp
/// 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<Row> rows;
if (!parse(" A:uint8, B:int16,C : float32 ,D:uint64[2],E:int8", rows).empty()) return false;
const std::vector<Row> 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<int>(Type::F64) != 9 || static_cast<int>(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<std::vector<double>> 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<Row> 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<Row> r) { return problem(r); };
if (prob({{"A", Type::U8, 1}, {"A", Type::U8, 1}}).find("twice") == std::string::npos) return false;
if (prob({{"", Type::U8, 1}}).empty() || prob({{"a,b", Type::U8, 1}}).empty()) return false;
if (prob({{"A", Type::U8, 0}}).empty() || prob({{"A", Type::U8, 4097}}).empty()) return false;
if (!prob({{"A", Type::U8, 4096}}).empty()) 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 (!prob({{"A", Type::F64, 4096}, {"B", Type::F32, 4096}}).empty()) return false;
return true;
}
```
Then, after the function `e1608_probe_reconnect` (outside the anonymous namespace, directly before `gboolean pan_probe`), add:
```cpp
/// 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<App*>(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;
}
```
In `src/ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add:
```cpp
/// 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);
```
In `src/ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add:
```cpp
if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), flatudp_probe, &app);
}
```
- [ ] **Step 6: Build and run the check**
This is the first run of the check (the implementation is written in step 3). Reconfigure (new source file) and build:
```bash
cmake -S . -B build >/dev/null && cmake --build build -j8 2>&1 | grep -E "warning|error|flatudp" | head -20
```
Expected: no `error`; no warning mentioning `flatudp`.
```bash
UDPSCOPE_PROBE_SOURCE_KINDS=60000 UDPSCOPE_PROBE_FLATUDP=1500 UDPSCOPE_EXIT_AFTER_MS=3000 \
timeout 30 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings"
```
Expected: `[flatudp] codec: ok=1` and `gtk warnings: 0`, no `Gtk-CRITICAL`. If `ok=0`, bisect the check by returning early at each stage; every expected value above is hand-computed, so a mismatch is a codec defect, not a test defect (re-derive the byte arrays before touching the codec).
- [ ] **Step 7: Commit**
```bash
git add CMakeLists.txt src/source/flatudp/flatudp_signals.hpp src/source/flatudp/flatudp_signals.cpp \
src/ui_probe.cpp src/ui_app.hpp src/ui.cpp
git commit -m "flatudp: the signal-list codec, with a hand-computed decode check"
```
---
### Task 2: The settings, the source, the kind, the mock and the stream probes
**Files:**
- Create: `src/source/flatudp/flatudp_source.hpp`, `src/source/flatudp/flatudp_source.cpp`, `src/source/flatudp/flatudp_kind.cpp`, `tools/mock_flatudp.py`
- Modify: `CMakeLists.txt`, `src/source/registry.hpp`, `src/source/registry.cpp`
- Modify: `src/ui_probe.cpp` (include, anonymous-namespace helpers, `flatudp_probe` rewritten, three new phase functions, the `[source-kinds]` sample), `src/ui_app.hpp`, `src/ui.cpp`
- Modify: `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`
**Interfaces:**
- Consumes (Task 1): everything in `flatudp_signals.hpp`.
- Produces (namespace `udpscope::flatudp`, header `source/flatudp/flatudp_source.hpp`):
- `constexpr int kDefaultPort = 44488;`
- `bool valid_ipv4(const std::string&)`, `bool valid_multicast(const std::string&)` (224.0.0.0/4)
- `struct FlatUdpSettings { int port; std::string bind, multicast, iface; double rate; bool big_endian; bool endian_ok; std::vector<Row> rows; std::string signals_error; static FlatUdpSettings from(const SourceConfig&); std::string problem() const; std::string identity() const; std::size_t block() const; }`
- `class FlatUdpSource final : public DataSource` with `explicit FlatUdpSource(const SourceConfig&)`, `const FlatUdpSettings& settings() const`, `std::uint64_t valid_datagrams() const`, `std::uint64_t wrong_size_datagrams() const`, `std::string last_message() const`.
- `std::unique_ptr<SourceKind> make_flatudp_kind();` (namespace `udpscope`, declared in `registry.hpp`).
- In `ui_app.hpp`: `flatudp_probe`, `flatudp_probe_report`, `flatudp_probe_gap`, `flatudp_probe_resumed`.
- [ ] **Step 1: Write the settings and source header**
Create `src/source/flatudp/flatudp_source.hpp`:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include "source/data_source.hpp"
#include "source/flatudp/flatudp_signals.hpp"
#include "source/source_config.hpp"
#include <atomic>
#include <chrono>
#include <cstdint>
#include <mutex>
#include <set>
#include <string>
#include <vector>
namespace udpscope::flatudp {
constexpr int kDefaultPort = 44488; ///< `UDPSender`'s own default
bool valid_ipv4(const std::string& s);
/// An IPv4 address in 224.0.0.0/4.
bool valid_multicast(const std::string& s);
/// Everything a configured flat UDP source needs, parsed once from the flat `SourceConfig`.
struct FlatUdpSettings {
int port = kDefaultPort;
std::string bind; ///< local address; empty is any
std::string multicast; ///< group to join; empty is plain unicast
std::string iface; ///< local interface address for the join; empty is the system's choice
double rate = 0.0; ///< the sender's cycle rate in Hz; 0 means not given
bool big_endian = false;
bool endian_ok = true; ///< false when `endian` is neither "little" nor "big"
std::vector<Row> rows;
std::string signals_error; ///< why the `signals` text did not parse; `rows` is empty then
static FlatUdpSettings from(const SourceConfig& cfg);
/// The first reason this cannot connect, in words for the user; empty when it can.
std::string problem() const;
/// `[multicast:]port`; names the session file.
std::string identity() const;
/// Datagrams per frame: 1 for a list with a vector, else `clamp(round(rate / 100), 1, 1024)`.
std::size_t block() const;
};
class FlatUdpSource final : public DataSource {
public:
explicit FlatUdpSource(const SourceConfig& cfg);
~FlatUdpSource() override;
void open(SourceSink& sink) override;
void poll(int timeout_ms) override;
void close() override;
SourceStats stats() const override;
bool connected() const override { return connected_.load(std::memory_order_relaxed); }
void set_datagram_tap(DatagramTap tap) override { tap_ = std::move(tap); }
const FlatUdpSettings& settings() const { return cfg_; }
/// Datagrams of the expected size / of any other size; for the probe, readable from any thread.
std::uint64_t valid_datagrams() const { return valid_.load(std::memory_order_relaxed); }
std::uint64_t wrong_size_datagrams() const { return wrong_.load(std::memory_order_relaxed); }
std::string last_message() const;
private:
using Clock = std::chrono::steady_clock;
void close_fd();
void drain_socket();
void accept(const std::uint8_t* data);
void reject(std::size_t len);
void flush();
void note(const std::string& msg);
FlatUdpSettings cfg_;
SourceSink* sink_ = nullptr;
DatagramTap tap_;
int fd_ = -1;
bool configured_ = false;
bool ever_connected_ = false;
std::size_t expected_ = 0; ///< bytes of a valid datagram
std::size_t block_ = 1;
std::vector<SignalInfo> signals_;
std::vector<std::uint8_t> rx_;
std::vector<std::vector<double>> pending_; ///< decoded, not yet framed: [signal][value]
std::vector<SignalValues> views_;
std::size_t pending_n_ = 0; ///< datagrams in `pending_`
Clock::time_point batch_start_;
Clock::time_point last_valid_;
std::set<std::size_t> reported_sizes_;
std::atomic<bool> connected_{false};
std::atomic<std::uint64_t> valid_{0};
std::atomic<std::uint64_t> wrong_{0};
mutable std::mutex msg_mu_;
std::string last_message_;
std::uint32_t frame_counter_ = 0;
std::uint64_t frames_ = 0;
std::uint64_t reconnects_ = 0;
};
} // namespace udpscope::flatudp
```
- [ ] **Step 2: Write the settings and source implementation**
Create `src/source/flatudp/flatudp_source.cpp`:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_source.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <stdexcept>
#include <thread>
namespace udpscope::flatudp {
namespace {
using namespace std::chrono_literals;
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
double epoch_seconds() {
return std::chrono::duration<double>(std::chrono::system_clock::now().time_since_epoch()).count();
}
constexpr std::size_t kMaxReportedSizes = 8; ///< a sender of random sizes must not flood stderr
} // namespace
bool valid_ipv4(const std::string& s) {
in_addr a{};
return inet_pton(AF_INET, s.c_str(), &a) == 1;
}
bool valid_multicast(const std::string& s) {
in_addr a{};
if (inet_pton(AF_INET, s.c_str(), &a) != 1) return false;
return (ntohl(a.s_addr) >> 28) == 0xE;
}
// --- FlatUdpSettings ------------------------------------------------------------------------
FlatUdpSettings FlatUdpSettings::from(const SourceConfig& c) {
FlatUdpSettings s;
s.port = static_cast<int>(c.get_int("port", kDefaultPort));
s.bind = trim(c.get("bind"));
s.multicast = trim(c.get("multicast"));
s.iface = trim(c.get("iface"));
s.rate = c.get_double("rate", 0.0);
const std::string e = lower(trim(c.get("endian", "little")));
s.endian_ok = e == "little" || e == "big";
s.big_endian = e == "big";
s.signals_error = parse(c.get("signals"), s.rows);
return s;
}
std::string FlatUdpSettings::problem() const {
if (port < 1 || port > 65535) return "the port must be 1 to 65535";
if (!signals_error.empty()) return signals_error;
const std::string p = flatudp::problem(rows);
if (!p.empty()) return p;
if (!(rate > 0.0) || !std::isfinite(rate)) return "give the sender's cycle rate in Hz (rate)";
if (!endian_ok) return "endian must be little or big";
if (!bind.empty() && !valid_ipv4(bind)) return "the bind address '" + bind + "' is not an IPv4 address";
if (!multicast.empty() && !valid_multicast(multicast))
return "'" + multicast + "' is not a multicast group (224.0.0.0 to 239.255.255.255)";
if (!iface.empty() && !valid_ipv4(iface))
return "the interface address '" + iface + "' is not an IPv4 address";
return {};
}
std::string FlatUdpSettings::identity() const {
return (multicast.empty() ? std::string() : multicast + ":") + std::to_string(port);
}
std::size_t FlatUdpSettings::block() const {
if (!all_scalar(rows) || !(rate > 0.0) || !std::isfinite(rate)) return 1;
const long b = std::lround(rate / 100.0);
return static_cast<std::size_t>(std::min(1024L, std::max(1L, b)));
}
// --- FlatUdpSource --------------------------------------------------------------------------
FlatUdpSource::FlatUdpSource(const SourceConfig& cfg) : cfg_(FlatUdpSettings::from(cfg)) {}
FlatUdpSource::~FlatUdpSource() { close_fd(); }
std::string FlatUdpSource::last_message() const {
std::lock_guard<std::mutex> lk(msg_mu_);
return last_message_;
}
void FlatUdpSource::note(const std::string& msg) {
{
std::lock_guard<std::mutex> lk(msg_mu_);
last_message_ = msg;
}
std::fprintf(stderr, "[flatudp] %s\n", msg.c_str());
std::fflush(stderr);
}
void FlatUdpSource::close_fd() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
void FlatUdpSource::open(SourceSink& sink) {
const std::string p = cfg_.problem();
if (!p.empty()) throw std::runtime_error(p);
sink_ = &sink;
signals_.clear();
for (const Row& r : cfg_.rows) {
SignalInfo s;
s.name = r.name;
s.type_code = static_cast<std::uint8_t>(r.type);
s.rows = r.count;
s.cols = 1;
s.time_mode = kTimePacket;
s.sampling_rate = cfg_.rate;
signals_.push_back(s);
}
expected_ = datagram_size(cfg_.rows);
block_ = cfg_.block();
pending_.assign(cfg_.rows.size(), {});
pending_n_ = 0;
rx_.assign(65536, 0);
const auto fail = [this](const std::string& what) {
const std::string msg = what + ": " + std::strerror(errno);
close_fd();
throw std::runtime_error(msg);
};
fd_ = ::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
if (fd_ < 0) fail("cannot create a UDP socket");
// Receivers of one multicast group share its port; a unicast port is the one app's.
if (!cfg_.multicast.empty()) {
const int one = 1;
::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
}
const int rcvbuf = 4 << 20; // best effort: a 1 kHz sender at 64 KiB would not fit a default one
::setsockopt(fd_, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof rcvbuf);
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<std::uint16_t>(cfg_.port));
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (!cfg_.bind.empty()) inet_pton(AF_INET, cfg_.bind.c_str(), &addr.sin_addr);
if (::bind(fd_, reinterpret_cast<const sockaddr*>(&addr), sizeof addr) < 0)
fail("cannot bind UDP port " + std::to_string(cfg_.port));
if (!cfg_.multicast.empty()) {
ip_mreq mreq{};
inet_pton(AF_INET, cfg_.multicast.c_str(), &mreq.imr_multiaddr);
if (cfg_.iface.empty()) {
mreq.imr_interface.s_addr = htonl(INADDR_ANY);
} else {
inet_pton(AF_INET, cfg_.iface.c_str(), &mreq.imr_interface);
}
if (::setsockopt(fd_, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) < 0)
fail("cannot join multicast group " + cfg_.multicast);
}
}
void FlatUdpSource::close() {
close_fd();
connected_.store(false, std::memory_order_relaxed);
}
SourceStats FlatUdpSource::stats() const {
SourceStats s;
s.packets = valid_.load(std::memory_order_relaxed) + wrong_.load(std::memory_order_relaxed);
s.frames = frames_;
s.stale = wrong_.load(std::memory_order_relaxed);
s.reconnects = reconnects_;
s.connected = connected_.load(std::memory_order_relaxed);
return s;
}
void FlatUdpSource::poll(int timeout_ms) {
drain_commands();
// Never wait long: the partial-block flush and the silence timeout are checked on this cadence.
const int wait = std::clamp(timeout_ms, 0, 20);
if (fd_ < 0 || sink_ == nullptr) {
std::this_thread::sleep_for(std::chrono::milliseconds(wait));
return;
}
if (!configured_) {
configured_ = true;
sink_->on_config(signals_, 1);
}
pollfd p{};
p.fd = fd_;
p.events = POLLIN;
if (::poll(&p, 1, wait) > 0 && (p.revents & POLLIN)) drain_socket();
const auto now = Clock::now();
if (pending_n_ > 0 && now - batch_start_ >= 100ms) flush(); // a stopped sender holds nothing back
if (connected_.load(std::memory_order_relaxed) && now - last_valid_ >= 2s) {
connected_.store(false, std::memory_order_relaxed);
sink_->on_event(SourceEvent::Disconnected, "no datagram for 2 s");
}
}
void FlatUdpSource::drain_socket() {
for (int i = 0; i < 8192; ++i) { // bounded, so a flood cannot starve the timers
const ssize_t n = ::recv(fd_, rx_.data(), rx_.size(), 0);
if (n < 0) {
if (errno == EINTR) continue;
break; // EAGAIN: drained
}
const double t = epoch_seconds();
const std::size_t len = static_cast<std::size_t>(n);
if (tap_) tap_(rx_.data(), len, t);
if (len == expected_) {
accept(rx_.data());
} else {
reject(len);
}
}
}
void FlatUdpSource::accept(const std::uint8_t* data) {
valid_.fetch_add(1, std::memory_order_relaxed);
last_valid_ = Clock::now();
if (!connected_.load(std::memory_order_relaxed)) {
if (ever_connected_) ++reconnects_;
ever_connected_ = true;
connected_.store(true, std::memory_order_relaxed);
sink_->on_event(SourceEvent::Connected, "");
}
decode_datagram(cfg_.rows, data, cfg_.big_endian, pending_);
if (pending_n_ == 0) batch_start_ = last_valid_;
if (++pending_n_ >= block_) flush();
}
void FlatUdpSource::reject(std::size_t len) {
wrong_.fetch_add(1, std::memory_order_relaxed);
if (reported_sizes_.size() >= kMaxReportedSizes || !reported_sizes_.insert(len).second) return;
const std::string msg = "datagram of " + std::to_string(len) + " bytes, expected " +
std::to_string(expected_) + ", check the signal list";
note(msg);
sink_->on_event(SourceEvent::Error, msg.c_str());
}
void FlatUdpSource::flush() {
const std::size_t n = signals_.size();
views_.resize(n);
for (std::size_t i = 0; i < n; ++i)
views_[i] = SignalValues{pending_[i].data(), static_cast<std::uint32_t>(pending_[i].size())};
Frame f;
f.counter = frame_counter_++;
f.lost = 0;
f.late = false;
f.recv_time = epoch_seconds();
f.publish_mode = 1;
f.num_samples = static_cast<std::uint32_t>(pending_n_);
f.num_signals = static_cast<std::uint32_t>(n);
f.signals = signals_.data();
f.values = views_.data();
++frames_;
sink_->on_data(f);
for (auto& c : pending_) c.clear();
pending_n_ = 0;
}
} // namespace udpscope::flatudp
```
- [ ] **Step 3: Write the kind with an interim form**
The `signals` row is a plain text entry here; Task 3 replaces it with the row editor. Create `src/source/flatudp/flatudp_kind.cpp`:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_source.hpp"
#include "source/registry.hpp"
#include <adwaita.h>
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
namespace udpscope {
namespace {
using namespace flatudp;
using ChangeCb = std::shared_ptr<std::function<void()>>;
std::string num(double v) {
char b[48];
std::snprintf(b, sizeof b, "%.10g", v);
return b;
}
GtkWidget* entry_row(const char* title) {
GtkWidget* r = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
return r;
}
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* combo_row(const char* title, const char* const* items) {
GtkWidget* r = adw_combo_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
return r;
}
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
GtkWidget* e = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
return e;
}
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
void mark(GtkWidget* w, bool bad) {
if (bad) {
gtk_widget_add_css_class(w, "error");
} else {
gtk_widget_remove_css_class(w, "error");
}
}
/// The form's widgets, shared by every signal closure and every `ConfigForm` closure.
struct Form {
GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *signals;
ChangeCb changed = std::make_shared<std::function<void()>>();
bool updating = false;
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
/// A bad address is marked with the `error` style; the rest of the problem is `valid()`'s.
void refresh() {
mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind)));
mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast)));
mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface)));
}
SourceConfig read() const {
SourceConfig c;
c.set("port", num(value_of(port)));
if (!text_of(bind).empty()) c.set("bind", text_of(bind));
if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast));
if (!text_of(iface).empty()) c.set("iface", text_of(iface));
c.set("rate", num(value_of(rate)));
c.set("endian", sel_of(endian) == 1 ? "big" : "little");
c.set("signals", text_of(signals));
return c;
}
void write(const SourceConfig& cfg) {
const FlatUdpSettings s = FlatUdpSettings::from(cfg);
updating = true;
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str());
gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str());
gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str());
if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate);
adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0);
gtk_editable_set_text(GTK_EDITABLE(signals), cfg.get("signals").c_str());
updating = false;
refresh();
}
bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); }
};
using FormPtr = std::shared_ptr<Form>;
void on_notify(GObject*, GParamSpec*, gpointer user) {
Form& f = **static_cast<FormPtr*>(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<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
if (!f.updating && f.changed && *f.changed) (*f.changed)();
}
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(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<FormPtr*>(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<GConnectFlags>(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<GConnectFlags>(0));
}
void hook_entry(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
class FlatUdpKind final : public SourceKind {
public:
std::string id() const override { return "flatudp"; }
std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; }
bool has_datagrams() const override { return true; }
std::string identity(const SourceConfig& cfg) const override {
return FlatUdpSettings::from(cfg).identity();
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<FlatUdpSource>(cfg);
}
ConfigForm make_config_form() const override {
auto f = std::make_shared<Form>();
static const char* const kEndian[] = {"Little endian", "Big endian", nullptr};
f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0);
f->bind = entry_row("_Bind address");
f->multicast = entry_row("_Multicast group");
f->iface = entry_row("Multicast _interface");
f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3);
f->endian = combo_row("_Byte order", kEndian);
f->signals = entry_row("_Signals");
GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface});
GtkWidget* timing = expander("Timing and format", {f->rate, f->endian});
GtkWidget* sig = expander("Signals", {f->signals});
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE);
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(sig), TRUE);
for (GtkWidget* w : {net, timing, sig}) hook_destroy(w, f);
for (GtkWidget* w : {f->bind, f->multicast, f->iface, f->signals}) hook_entry(w, f);
for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f);
hook_notify(f->endian, "notify::selected", f);
f->refresh();
ConfigForm out;
out.rows = {net, timing, sig};
out.description =
"The signal list must match the sender's exactly, in order: the datagrams carry no "
"description. Write name:type[count], comma separated, e.g. Trigger:uint8,Ia:float32,"
"Wave:float32[16]";
out.focus = f->signals;
out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); };
out.valid = [f]() { return f->valid(); };
ChangeCb cb = f->changed;
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return out;
}
};
} // namespace
std::unique_ptr<SourceKind> make_flatudp_kind() { return std::make_unique<FlatUdpKind>(); }
} // namespace udpscope
```
- [ ] **Step 4: Register the kind**
`CMakeLists.txt`, after the `flatudp_signals.cpp` line add:
```cmake
src/source/flatudp/flatudp_source.cpp
src/source/flatudp/flatudp_kind.cpp
```
`src/source/registry.hpp`: change the comment above the `make_*_kind` declarations to name `flatudp/flatudp_kind.cpp` too, and add after `make_e1608_kind`:
```cpp
std::unique_ptr<SourceKind> make_flatudp_kind();
```
`src/source/registry.cpp`, in `register_builtin_sources()`, after `register_kind(make_e1608_kind());`:
```cpp
register_kind(make_flatudp_kind());
```
- [ ] **Step 5: Write the mock sender**
Create `tools/mock_flatudp.py` (then `chmod +x`):
```python
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
"""A stand-in for the MARTe2 UDPSender datasource, for the flatudp source's self-check.
One datagram is one cycle: the signals concatenated in declaration order, no padding, in the
chosen byte order. Packet i carries, for the signal of index k (from 0) and its element e,
the value (i * (k + 1) + e) % 100, so consecutive datagrams are predictable.
--signals name:type[count],... default Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]
--rate cycles per second
--big-endian pack big endian (default little)
--gap S,D send nothing from S to S+D seconds after the start (a sender that stops)
--bad-every S one datagram one byte too long every S seconds (the wrong-size path)
--multicast G --mcast-if A send to group G out of interface A instead of --host
--log FILE one line per event
"""
import argparse
import socket
import struct
import sys
import time
CODES = {"uint8": "B", "int8": "b", "uint16": "H", "int16": "h", "uint32": "I", "int32": "i",
"uint64": "Q", "int64": "q", "float32": "f", "float64": "d"}
def parse_signals(text):
out = []
for tok in text.split(","):
name, _, ty = tok.strip().partition(":")
count = 1
if "[" in ty:
ty, _, n = ty.partition("[")
count = int(n.rstrip("]"))
out.append((name.strip(), CODES[ty.strip()], count))
return out
def make_packer(sigs, big):
st = struct.Struct((">" if big else "<") + "".join(code * count for _, code, count in sigs))
def pack(i):
vals = []
for k, (_, code, count) in enumerate(sigs):
for e in range(count):
v = (i * (k + 1) + e) % 100
vals.append(float(v) if code in "fd" else v)
return st.pack(*vals)
return pack, st.size
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=44488)
ap.add_argument("--rate", type=float, default=1000.0)
ap.add_argument("--signals", default="Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]")
ap.add_argument("--big-endian", action="store_true")
ap.add_argument("--gap", default="")
ap.add_argument("--bad-every", type=float, default=0.0)
ap.add_argument("--multicast", default="")
ap.add_argument("--mcast-if", default="127.0.0.1")
ap.add_argument("--log", default="")
args = ap.parse_args()
logf = open(args.log, "w") if args.log else None
def log(msg):
if logf:
logf.write("%.3f %s\n" % (time.monotonic() - t0, msg))
logf.flush()
sigs = parse_signals(args.signals)
pack, size = make_packer(sigs, args.big_endian)
gap = tuple(float(x) for x in args.gap.split(",")) if args.gap else None
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
dest = (args.host, args.port)
if args.multicast:
dest = (args.multicast, args.port)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(args.mcast_if))
period = 1.0 / args.rate
t0 = time.monotonic()
log("start %s rate=%g size=%d %s" % (args.signals, args.rate, size, "big" if args.big_endian else "little"))
next_t = t0
next_bad = t0 + args.bad_every
in_gap = False
i = 0
try:
while True:
now = time.monotonic()
if now < next_t:
time.sleep(min(next_t - now, 0.001))
continue
elapsed = now - t0
if gap and gap[0] <= elapsed < gap[0] + gap[1]:
if not in_gap:
in_gap = True
log("gap begins")
next_t = now + period
time.sleep(0.01)
continue
if in_gap:
in_gap = False
log("gap ends")
if args.bad_every > 0 and now >= next_bad:
next_bad = now + args.bad_every
sock.sendto(pack(i) + b"\x00", dest)
log("bad datagram of %d bytes" % (size + 1))
burst = 0
while next_t <= now and burst < 64: # catch up a late timer, but bounded
sock.sendto(pack(i), dest)
i += 1
next_t += period
burst += 1
if next_t < now - 0.5:
next_t = now
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 6: Replace the probe**
In `src/ui_probe.cpp`:
1. Add the include `#include "source/flatudp/flatudp_source.hpp"` after the signals include.
2. In the anonymous namespace, directly after `flatudp_codec_check()` and before its closing `} // namespace`, add:
```cpp
flatudp::FlatUdpSource* flatudp_source(App* a) {
return a->acq != nullptr ? dynamic_cast<flatudp::FlatUdpSource*>(a->acq->source()) : nullptr;
}
std::uint64_t g_flatudp_frames_before = 0;
/// The mock's signal k carries (i * (k + 1) + element) % 100 in packet i, so two consecutive
/// samples of a scalar signal differ by k + 1 modulo 100. Returns the number of steps that do not,
/// over a window clear of the head the producer is writing; -1 when it cannot be read.
long flatudp_breaks(App* a, std::size_t channel, long step) {
if (channel >= a->model->channels().size()) return -1;
const ChannelBuffer& ch = a->model->live().channel(channel);
const u64 n = ch.size();
const u64 want = 400;
if (n <= want + 50) return -1;
std::vector<f32> v(static_cast<std::size_t>(want));
ch.raw(n - want - 50, n - 50, v.data());
long breaks = 0;
for (std::size_t i = 1; i < v.size(); ++i) {
const long d = std::lround(double(v[i])) - std::lround(double(v[i - 1]));
if (((d % 100) + 100) % 100 != step) ++breaks;
}
return breaks;
}
```
3. Replace the whole `flatudp_probe` function (the T1 one) with:
```cpp
/// Phase 2, 1 s after the signals were enabled: the stream, its channels, the value continuity, the
/// wrong-size accounting and the capture button.
gboolean flatudp_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
flatudp::FlatUdpSource* src = flatudp_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const flatudp::FlatUdpSettings& s = src->settings();
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
const std::vector<SignalInfo>& sigs = a->model->signals();
std::string names, expect_names;
std::size_t expect_channels = 0;
for (const SignalInfo& si : sigs) names += (names.empty() ? "" : ",") + si.name;
for (const flatudp::Row& r : s.rows) {
expect_names += (expect_names.empty() ? "" : ",") + r.name;
expect_channels += r.count;
}
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 && names == expect_names && std::fabs(rate - s.rate) < 1e-6 && frames > 0;
std::fprintf(stderr,
"[flatudp] stream: connected=%d signals=%zu names=%s expect=%s frames=%" G_GUINT64_FORMAT
" rate=%.3f ok=%d\n",
up ? 1 : 0, sigs.size(), names.c_str(), expect_names.c_str(), frames, rate,
stream_ok ? 1 : 0);
const std::size_t channels = a->model->channels().size();
std::fprintf(stderr, "[flatudp] channels: count=%zu expect=%zu ok=%d\n", channels, expect_channels,
channels == expect_channels ? 1 : 0);
// Only the first two signals are checked, and only when they are scalars (the mock's default).
long b0 = -1, b1 = -1;
if (up && s.rows.size() >= 2 && s.rows[0].count == 1 && s.rows[1].count == 1) {
b0 = flatudp_breaks(a, 0, 1);
b1 = flatudp_breaks(a, 1, 2);
}
std::fprintf(stderr, "[flatudp] continuity: ch0_breaks=%ld ch1_breaks=%ld ok=%d\n", b0, b1,
b0 == 0 && b1 == 0 ? 1 : 0);
const std::uint64_t wrong = src->wrong_size_datagrams();
const std::string msg = src->last_message();
std::fprintf(stderr, "[flatudp] wrong size: count=%" G_GUINT64_FORMAT " message='%s' ok=%d\n", wrong,
msg.c_str(), wrong >= 1 && msg.find("expected") != std::string::npos ? 1 : 0);
std::fprintf(stderr, "[flatudp] capture button: present=%d sensitive=%d\n",
a->capture_button != nullptr ? 1 : 0,
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
std::fprintf(stderr, "[flatudp] state: valid=%" G_GUINT64_FORMAT " wrong=%" G_GUINT64_FORMAT "\n",
src->valid_datagrams(), wrong);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase 1: the codec check (it needs no flatudp source), then every signal enabled in one structural
/// change, and the report a second later.
gboolean flatudp_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::fprintf(stderr, "[flatudp] codec: ok=%d\n", flatudp_codec_check() ? 1 : 0);
if (flatudp_source(a) == nullptr) {
std::fprintf(stderr, "[flatudp] no flatudp source (run with --source flatudp)\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
std::vector<std::size_t> all(a->model->signals().size());
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
if (!all.empty()) a->model->set_signals_enabled(all, true);
std::fflush(stderr);
g_timeout_add(1000, flatudp_probe_report, a);
return G_SOURCE_REMOVE;
}
/// After the mock's gap: the stream's frame count moved on and the source is connected again.
gboolean flatudp_probe_resumed(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
flatudp::FlatUdpSource* src = flatudp_source(a);
const std::uint64_t after = a->model->stats().frames.load(std::memory_order_relaxed);
const bool up = src != nullptr && src->connected() &&
a->model->stats().connected.load(std::memory_order_relaxed);
std::fprintf(stderr, "[flatudp] resumed: connected=%d frames_before=%" G_GUINT64_FORMAT
" frames_after=%" G_GUINT64_FORMAT " ok=%d\n",
up ? 1 : 0, g_flatudp_frames_before, after,
up && after > g_flatudp_frames_before ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Inside the mock's gap: 2 s of silence must have raised `Disconnected`.
gboolean flatudp_probe_gap(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
flatudp::FlatUdpSource* src = flatudp_source(a);
if (src == nullptr) return G_SOURCE_REMOVE;
const bool src_up = src->connected();
const bool model_up = a->model->stats().connected.load(std::memory_order_relaxed);
g_flatudp_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
std::fprintf(stderr, "[flatudp] silence: connected=%d model_connected=%d ok=%d\n", src_up ? 1 : 0,
model_up ? 1 : 0, !src_up && !model_up ? 1 : 0);
std::fflush(stderr);
g_timeout_add(4000, flatudp_probe_resumed, a);
return G_SOURCE_REMOVE;
}
```
The phase functions reference each other, so define them in this order (resumed, gap, report, probe) or forward-declare: they are all declared in `ui_app.hpp` (next step), so any order compiles.
4. In `source_kinds_probe`'s `sample` lambda, after the `e1608` branch, add:
```cpp
} else if (id == "flatudp") {
c.set("port", "44600");
c.set("bind", "10.0.0.8");
c.set("multicast", "239.1.2.3");
c.set("iface", "10.0.0.9");
c.set("rate", "2500");
c.set("endian", "big");
c.set("signals", "Trigger:uint8,Ia:float32,Wave:int16[4]");
}
```
(the existing `e1608` branch's closing `}` moves after this one: the chain is `if (udps) {...} else if (probe) {...} else if (e1608) {...} else if (flatudp) {...}`).
5. `src/ui_app.hpp`: replace the T1 `flatudp_probe` declaration with:
```cpp
/// The flat UDP (MARTe2 UDPSender) source: the signal-list codec and a hand-computed decode, then,
/// against `tools/mock_flatudp.py`, the stream, the value continuity, the wrong-size accounting and
/// the capture button (`_report`, a second later). `_gap` reads the source inside the mock's silence;
/// `_resumed` shows the stream came back.
gboolean flatudp_probe(gpointer user);
gboolean flatudp_probe_report(gpointer user);
gboolean flatudp_probe_gap(gpointer user);
gboolean flatudp_probe_resumed(gpointer user);
```
6. `src/ui.cpp`, after the `UDPSCOPE_PROBE_FLATUDP` block add:
```cpp
if (const char* env = g_getenv("UDPSCOPE_PROBE_FLATUDP_GAP")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), flatudp_probe_gap, &app);
}
```
- [ ] **Step 7: Build**
```bash
cmake -S . -B build >/dev/null && cmake --build build -j8 2>&1 | grep -E "warning|error" | head -20
```
Expected: no errors; at most the 2 pre-existing `MathNode::source` warnings of `ui_probe.cpp`, nothing from `flatudp`. If `ChannelBuffer::raw` or `model->channels()` differ from what the e1608 probe calls (see `e1608_probe_report`), mirror that code.
- [ ] **Step 8: Run against the mock (scalar and vector lists)**
First the run with a vector signal in the list and the mock's gap and bad datagrams; the probe schedule is: phase 1 at 2.5 s, `silence` at 8 s, `resumed` at 12 s.
```bash
python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] \
--gap 6,5 --bad-every 1 --log /tmp/flatudp-mock.log &
MOCK=$!
sleep 1
UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=8000 UDPSCOPE_EXIT_AFTER_MS=14500 timeout 40 \
xvfb-run -a build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \
--source-opt signals=Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] 2>&1 \
| grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings|buffer allocated"
kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null; head -5 /tmp/flatudp-mock.log
```
Expected, in order:
```
[flatudp] codec: ok=1
[flatudp] datagram of N bytes, expected M, check the signal list <- exactly ONE such line (N = M + 1)
[flatudp] stream: connected=1 signals=4 names=Trigger,Ia,Cnt,Wave expect=Trigger,Ia,Cnt,Wave frames=(any N > 0) rate=1000.000 ok=1
[flatudp] channels: count=7 expect=7 ok=1
[flatudp] continuity: ch0_breaks=0 ch1_breaks=0 ok=1
[flatudp] wrong size: count=(any N >= 1) message='datagram of ... expected ...' ok=1
[flatudp] capture button: present=1 sensitive=1
[flatudp] state: valid=(any N > 0) wrong=(any N >= 1)
[flatudp] silence: connected=0 model_connected=0 ok=1
[flatudp] resumed: connected=1 frames_before=(N) frames_after=(a number larger than N) ok=1
gtk warnings: 0
```
Rough sizes: M = 1+4+8+8 = 21 bytes, N = 22. Notes for reading failures:
- `stream rate` is the declared rate (1000); `names` is the signal order, so swapped signals read `ok=0`.
- `continuity` nonzero: first check the source did not drop or reorder datagrams (`valid` against the mock's count), then whether the model's ingest decimation is 1:1 at 1 kHz (a decimated channel steps by more than `k + 1`). Do not weaken the check before one of those is shown.
- `capture button sensitive=0`: find where the button's sensitivity is derived from the active kind's `has_datagrams()` (grep `capture_button`); do not special-case the kind.
- Any `Gtk-CRITICAL` or a count other than one wrong-size stderr line is a defect.
- [ ] **Step 9: Run the scalar list (batching) and the big-endian run**
```bash
python3 tools/mock_flatudp.py --port 44589 --rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64 --big-endian &
MOCK=$!
sleep 1
UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 \
xvfb-run -a build/udpscope --source flatudp --source-opt port=44589 --source-opt rate=1000 \
--source-opt endian=big --source-opt signals=Trigger:uint8,Ia:float32,Cnt:uint64 2>&1 \
| grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings"
kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null
```
Expected: `stream ... names=Trigger,Ia,Cnt ... ok=1`, `channels: count=3 expect=3 ok=1`, `continuity: ch0_breaks=0 ch1_breaks=0 ok=1` (a byte-order or batching defect shows here), `wrong size: count=0 ... ok=0` (no `--bad-every` in this run: a reading, not a failure), `gtk warnings: 0`.
Then the no-sender run (nothing listening on the mock side), 3 s:
```bash
UDPSCOPE_PROBE_FLATUDP=1500 UDPSCOPE_EXIT_AFTER_MS=3000 timeout 20 \
xvfb-run -a build/udpscope --source flatudp --source-opt port=44590 --source-opt rate=1000 \
--source-opt signals=Trigger:uint8,Ia:float32 2>&1 | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings"
```
Expected: `stream: connected=0 ... frames=0 ... ok=0`, `continuity: ... -1 ... ok=0`, no crash, `gtk warnings: 0`.
A `--source flatudp` run with no `rate` must fail to start with the message "give the sender's cycle rate in Hz (rate)" rather than silently using 1 kHz:
```bash
UDPSCOPE_EXIT_AFTER_MS=2000 timeout 20 xvfb-run -a build/udpscope --source flatudp \
--source-opt signals=A:uint8 2>&1 | grep -iE "rate|error" | head -3
```
If the app starts a window with no error, look at how `--source` failures are reported for the e1608 (`--source e1608` with no address) and match that behaviour rather than inventing one.
- [ ] **Step 10: The `[source-kinds]` run**
```bash
UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 \
xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "\[source-kinds\]|gtk warnings|Gtk-CRITICAL"
```
Expected: `kinds=udps,e1608,flatudp,probe`, `form flatudp: round_trip=1 valid=1`, nine `[source-kinds]` lines in all, `gtk warnings: 0`. (`valid=1` holds because the sample has a rate and a signal list.)
- [ ] **Step 11: Documentation**
1. `docs/claude/build-and-run.md`: after the e1608 `sh` block (before "`tools/mock_udpstreamer.py` has waveform-injection options"), add:
````markdown
`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals
concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the
settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local
interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest
decimation and spaces the samples within a frame), `endian` `little|big`, and `signals`
`name:type[count],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64` (a count above 1
makes a vector signal), e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A datagram of any other size than
the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as
`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for
a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`,
`--log FILE`).
```sh
python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Wave:int16[4] &
./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \
--source-opt signals=Trigger:uint8,Ia:float32,Wave:int16[4]
```
````
2. `docs/claude/verification.md`: in the `UDPSCOPE_PROBE_SOURCE_KINDS` entry change "Eight `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `probe`)" to "Nine `[source-kinds]` lines (one `form` line per registered kind: `udps`, `e1608`, `flatudp`, `probe`)". After the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` entry add:
````markdown
- `UDPSCOPE_PROBE_FLATUDP=2500` with `UDPSCOPE_EXIT_AFTER_MS`, run as `--source flatudp` against
`tools/mock_flatudp.py` -- the flat UDP source end to end: a hand-computed codec and decode check (it
needs no source), the stream's signal names and declared rate, the channel count (vectors count per
element), value continuity across the first two signals, the wrong-size accounting and the capture button.
It enables the signals itself, so do **not** add `UDPSCOPE_ENABLE_ALL_MS`. Runs, each short and serial,
with the mock killed afterwards:
- **mixed list, gap and bad datagrams:** mock `--rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4]
--gap 6,5 --bad-every 1 --log /tmp/flatudp-mock.log`, the same `signals` as a `--source-opt`, with
`UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_PROBE_FLATUDP_GAP=8000 UDPSCOPE_EXIT_AFTER_MS=14500`; stderr carries
exactly one `datagram of 22 bytes, expected 21` line;
- **scalar list, big endian (batching):** mock `--signals Trigger:uint8,Ia:float32,Cnt:uint64 --big-endian`,
app `--source-opt endian=big`, `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`;
- **no sender:** `connected=0` throughout, a reading and not a failure.
- `UDPSCOPE_PROBE_FLATUDP_GAP=8000` -- reads the source inside the mock's silence (2 s after its last datagram
the source is disconnected) and 4 s later checks it reconnected and the stream moved on.
````
3. `docs/claude/probe-readings.md`: in the `[source-kinds]` block change `kinds=udps,e1608,probe` to `kinds=udps,e1608,flatudp,probe` and insert `[source-kinds] form flatudp: round_trip=1 valid=1` after the e1608 form line; in the bullet about the e1608 sample add "The flatudp sample sets port, bind, multicast, iface, rate, endian and signals." Add a new section after `## [e1608]`'s last bullet (before the next `##`):
````markdown
## `[flatudp]`
```
[flatudp] codec: ok=1
[flatudp] datagram of 22 bytes, expected 21, check the signal list
[flatudp] stream: connected=1 signals=4 names=Trigger,Ia,Cnt,Wave expect=Trigger,Ia,Cnt,Wave frames=2410 rate=1000.000 ok=1
[flatudp] channels: count=7 expect=7 ok=1
[flatudp] continuity: ch0_breaks=0 ch1_breaks=0 ok=1
[flatudp] wrong size: count=3 message='datagram of 22 bytes, expected 21, check the signal list' ok=1
[flatudp] capture button: present=1 sensitive=1
[flatudp] state: valid=3511 wrong=3
[flatudp] silence: connected=0 model_connected=0 ok=1
[flatudp] resumed: connected=1 frames_before=3522 frames_after=4310 ok=1
```
`UDPSCOPE_PROBE_FLATUDP=2500` (the first lines; counts vary) and `UDPSCOPE_PROBE_FLATUDP_GAP=8000` (the last two)
run as `--source flatudp` against `tools/mock_flatudp.py` with the options in `verification.md`. The second line is
the source's own stderr message, printed once per distinct wrong size, not a probe line. With no sender the
stream lines read `connected=0 ... frames=0 ... ok=0` and `continuity` reads `-1`: a reading, not a failure. A
run without `--bad-every` reads `wrong size: count=0 ... ok=0` for the same reason.
- `codec: ok=1`: the `signals` text round-trips, a hand-built 24-byte datagram decodes to the hand-computed
values in both byte orders (`uint8` 254, `int16` -2, `float32` 1.5, `uint64[2]` 1 and 4294967298, `int8` -1)
and the validity rules refuse what they should. It needs no source, so `--source probe` shows it too.
`ok=0` is a codec defect: a swapped byte order, a wrong width or a rule that does not fire.
- `stream: ... ok=1`: the model's signals are the list's rows in order, at the declared rate. `names` out of
order is a decode that mixed rows; `rate` of 1000 against a different declared rate is a `sampling_rate`
that did not reach the model (the ring and the decimation are then sized for the wrong cadence).
- `channels: count=7`: the sum of the rows' counts -- a vector signal is one channel per element.
- `continuity: ch0_breaks=0 ch1_breaks=0`: consecutive samples of the mock's first two signals step by 1 and
by 2 modulo 100. A break is a lost or reordered datagram, a batch flushed with the wrong number of samples,
or ingest decimation that is not 1:1.
- `wrong size: ok=1`: wrong-size datagrams were counted and the message names the received and expected size.
The message must also be the only stderr line of its kind: one per distinct size, however many arrive.
- `capture button: present=1 sensitive=1`: the kind reports datagrams, so the capture UI is live (contrast
`[source-kinds]`, where the probe kind leaves it disabled).
- `silence: connected=0 model_connected=0`: 2 s without a valid datagram raised `Disconnected`. `connected=1`
is the timeout not firing, or wrong-size datagrams refreshing the silence clock (they must not).
- `resumed: ok=1`: `Connected` was raised again and frames resumed. `connected=0` here means the source never
recovered after the sender came back.
````
- [ ] **Step 12: Commit**
```bash
git add CMakeLists.txt src/source/flatudp src/source/registry.hpp src/source/registry.cpp \
src/ui_probe.cpp src/ui_app.hpp src/ui.cpp tools/mock_flatudp.py \
docs/claude/build-and-run.md docs/claude/verification.md docs/claude/probe-readings.md
git commit -m "flatudp: the source, kind, mock sender and stream probes"
```
---
### Task 3: The row-editor form, its probe lines, README and architecture
**Files:**
- Modify (full replacement): `src/source/flatudp/flatudp_kind.cpp`
- Modify: `src/ui_probe.cpp` (a form check, called from `flatudp_probe`)
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`
**Interfaces:**
- Consumes: everything from Tasks 1 and 2 (`flatudp::Row`, `Type`, `type_name`, `problem`, `format`, `datagram_size`, `FlatUdpSettings::from/problem`, `valid_ipv4`, `valid_multicast`, `kDefaultPort`).
- Produces: the same `make_flatudp_kind()`; the widget names `flatudp-add` (the "Add signal" button), `flatudp-remove` (each row's button), which the probe finds with `find_named`.
- [ ] **Step 1: Replace the kind with the row-editor form**
Overwrite `src/source/flatudp/flatudp_kind.cpp` with:
```cpp
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "source/flatudp/flatudp_source.hpp"
#include "source/registry.hpp"
#include <adwaita.h>
#include <cctype>
#include <cstdio>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace udpscope {
namespace {
using namespace flatudp;
using ChangeCb = std::shared_ptr<std::function<void()>>;
std::string num(double v) {
char b[48];
std::snprintf(b, sizeof b, "%.10g", v);
return b;
}
std::string trim(const std::string& s) {
std::size_t a = 0, b = s.size();
while (a < b && std::isspace(static_cast<unsigned char>(s[a]))) ++a;
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1]))) --b;
return s.substr(a, b - a);
}
GtkWidget* entry_row(const char* title) {
GtkWidget* r = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
return r;
}
GtkWidget* spin_row(const char* title, double lo, double hi, double step, double def, int digits) {
GtkWidget* r = GTK_WIDGET(adw_spin_row_new_with_range(lo, hi, step));
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_spin_row_set_digits(ADW_SPIN_ROW(r), static_cast<guint>(digits));
adw_spin_row_set_value(ADW_SPIN_ROW(r), def);
return r;
}
GtkWidget* combo_row(const char* title, const char* const* items) {
GtkWidget* r = adw_combo_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(r), title);
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(r), TRUE);
adw_combo_row_set_model(ADW_COMBO_ROW(r), G_LIST_MODEL(gtk_string_list_new(items)));
return r;
}
GtkWidget* expander(const char* title, std::initializer_list<GtkWidget*> rows) {
GtkWidget* e = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(e), title);
for (GtkWidget* r : rows) adw_expander_row_add_row(ADW_EXPANDER_ROW(e), r);
return e;
}
std::string text_of(GtkWidget* w) { return gtk_editable_get_text(GTK_EDITABLE(w)); }
double value_of(GtkWidget* w) { return adw_spin_row_get_value(ADW_SPIN_ROW(w)); }
guint sel_of(GtkWidget* w) { return adw_combo_row_get_selected(ADW_COMBO_ROW(w)); }
void mark(GtkWidget* w, bool bad) {
if (bad) {
gtk_widget_add_css_class(w, "error");
} else {
gtk_widget_remove_css_class(w, "error");
}
}
/// One signal's three editing widgets; `row` is the `AdwEntryRow` that holds the other two.
struct SigRow {
GtkWidget *row, *type, *count;
};
/// The form's widgets, shared by every signal closure and every `ConfigForm` closure. The raw
/// widget pointers are valid while the chooser keeps the rows.
struct Form : std::enable_shared_from_this<Form> {
GtkWidget *port, *bind, *multicast, *iface, *rate, *endian, *group;
std::vector<SigRow> sigs;
ChangeCb changed = std::make_shared<std::function<void()>>();
bool updating = false;
bool alive = true; ///< cleared when the chooser destroys the rows; handlers then touch nothing
std::vector<Row> rows_now() const {
std::vector<Row> rows;
for (const SigRow& s : sigs) {
Row r;
r.name = trim(text_of(s.row));
r.type = static_cast<Type>(std::min<guint>(gtk_drop_down_get_selected(GTK_DROP_DOWN(s.type)), 9));
r.count = static_cast<std::uint32_t>(gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(s.count)));
rows.push_back(r);
}
return rows;
}
/// The first `S<n>` that no row uses.
std::string next_name() const {
const std::vector<Row> rows = rows_now();
for (int n = 1;; ++n) {
const std::string name = "S" + std::to_string(n);
bool used = false;
for (const Row& r : rows) used = used || r.name == name;
if (!used) return name;
}
}
/// Bad addresses are marked with the `error` style; the group's subtitle carries the signal list's
/// own state, the first rule it breaks or the size it implies.
void refresh() {
mark(bind, !text_of(bind).empty() && !valid_ipv4(text_of(bind)));
mark(multicast, !text_of(multicast).empty() && !valid_multicast(text_of(multicast)));
mark(iface, !text_of(iface).empty() && !valid_ipv4(text_of(iface)));
const std::vector<Row> rows = rows_now();
const std::string p = flatudp::problem(rows);
std::string sub = p;
if (p.empty()) {
sub = std::to_string(datagram_size(rows)) + " bytes per datagram";
}
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(group), sub.c_str());
}
void fire() {
if (!updating && changed && *changed) (*changed)();
}
SourceConfig read() const {
SourceConfig c;
c.set("port", num(value_of(port)));
if (!text_of(bind).empty()) c.set("bind", text_of(bind));
if (!text_of(multicast).empty()) c.set("multicast", text_of(multicast));
if (!text_of(iface).empty()) c.set("iface", text_of(iface));
c.set("rate", num(value_of(rate)));
c.set("endian", sel_of(endian) == 1 ? "big" : "little");
c.set("signals", format(rows_now()));
return c;
}
void add_row(const Row& r);
void remove_row(GtkWidget* row);
void write(const SourceConfig& cfg) {
const FlatUdpSettings s = FlatUdpSettings::from(cfg);
updating = true;
adw_spin_row_set_value(ADW_SPIN_ROW(port), s.port);
gtk_editable_set_text(GTK_EDITABLE(bind), s.bind.c_str());
gtk_editable_set_text(GTK_EDITABLE(multicast), s.multicast.c_str());
gtk_editable_set_text(GTK_EDITABLE(iface), s.iface.c_str());
if (s.rate > 0.0) adw_spin_row_set_value(ADW_SPIN_ROW(rate), s.rate);
adw_combo_row_set_selected(ADW_COMBO_ROW(endian), s.big_endian ? 1 : 0);
while (!sigs.empty()) {
GtkWidget* row = sigs.back().row;
sigs.pop_back();
adw_expander_row_remove(ADW_EXPANDER_ROW(group), row);
}
for (const Row& r : s.rows) add_row(r);
updating = false;
refresh();
}
bool valid() const { return FlatUdpSettings::from(read()).problem().empty(); }
};
using FormPtr = std::shared_ptr<Form>;
/// A remove button's context: which form, which row.
struct RowCtx {
FormPtr form;
GtkWidget* row;
};
void on_notify(GObject*, GParamSpec*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
f.fire();
}
void on_edited(GtkEditable*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
f.fire();
}
void on_spin(GtkSpinButton*, gpointer user) {
Form& f = **static_cast<FormPtr*>(user);
if (!f.alive) return;
f.refresh();
f.fire();
}
void on_add(GtkButton*, gpointer user) {
FormPtr f = *static_cast<FormPtr*>(user);
if (!f->alive) return;
f->add_row(Row{f->next_name(), Type::F32, 1});
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE);
f->refresh();
f->fire();
}
void on_remove(GtkButton*, gpointer user) {
// Copy first: removing the row finalizes this button, and with it this context.
RowCtx* c = static_cast<RowCtx*>(user);
FormPtr f = c->form;
GtkWidget* row = c->row;
if (!f->alive) return;
f->remove_row(row);
}
void free_form(gpointer d, GClosure*) { delete static_cast<FormPtr*>(d); }
void free_ctx(gpointer d, GClosure*) { delete static_cast<RowCtx*>(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<FormPtr*>(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<GConnectFlags>(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<GConnectFlags>(0));
}
void hook_entry(GtkWidget* w, const FormPtr& f) {
g_signal_connect_data(w, "changed", G_CALLBACK(on_edited), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
}
void Form::add_row(const Row& r) {
FormPtr self = shared_from_this();
GtkWidget* row = adw_entry_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), "Name");
gtk_editable_set_text(GTK_EDITABLE(row), r.name.c_str());
const char* names[11];
for (std::size_t i = 0; i < 10; ++i) names[i] = type_name(static_cast<Type>(i));
names[10] = nullptr;
GtkWidget* type = gtk_drop_down_new_from_strings(names);
gtk_drop_down_set_selected(GTK_DROP_DOWN(type), static_cast<guint>(r.type));
gtk_widget_set_valign(type, GTK_ALIGN_CENTER);
gtk_widget_set_name(type, "flatudp-type");
// The spin clamps to 1..4096, so a hand-edited session with a count outside it is repaired here.
GtkWidget* count = gtk_spin_button_new_with_range(1, kMaxCount, 1);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(count), r.count);
gtk_widget_set_valign(count, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(count, "Elements: 1 is a scalar signal, more make a vector signal");
gtk_widget_set_name(count, "flatudp-count");
GtkWidget* rm = gtk_button_new_with_label("Remove");
gtk_widget_set_valign(rm, GTK_ALIGN_CENTER);
gtk_widget_set_name(rm, "flatudp-remove");
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), type);
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), count);
adw_entry_row_add_suffix(ADW_ENTRY_ROW(row), rm);
hook_entry(row, self);
hook_notify(type, "notify::selected", self);
g_signal_connect_data(count, "value-changed", G_CALLBACK(on_spin), new FormPtr(self), free_form,
static_cast<GConnectFlags>(0));
g_signal_connect_data(rm, "clicked", G_CALLBACK(on_remove), new RowCtx{self, row}, free_ctx,
static_cast<GConnectFlags>(0));
adw_expander_row_add_row(ADW_EXPANDER_ROW(group), row);
sigs.push_back(SigRow{row, type, count});
}
void Form::remove_row(GtkWidget* row) {
for (auto it = sigs.begin(); it != sigs.end(); ++it) {
if (it->row == row) {
sigs.erase(it);
adw_expander_row_remove(ADW_EXPANDER_ROW(group), row);
break;
}
}
refresh();
fire();
}
class FlatUdpKind final : public SourceKind {
public:
std::string id() const override { return "flatudp"; }
std::string display_name() const override { return "Flat UDP (MARTe2 UDPSender)"; }
bool has_datagrams() const override { return true; }
std::string identity(const SourceConfig& cfg) const override {
return FlatUdpSettings::from(cfg).identity();
}
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
return std::make_unique<FlatUdpSource>(cfg);
}
ConfigForm make_config_form() const override {
auto f = std::make_shared<Form>();
static const char* const kEndian[] = {"Little endian", "Big endian", nullptr};
f->port = spin_row("_Port", 1, 65535, 1, kDefaultPort, 0);
f->bind = entry_row("_Bind address");
f->multicast = entry_row("_Multicast group");
f->iface = entry_row("Multicast _interface");
f->rate = spin_row("Cycle _rate (Hz)", 0.001, 1000000, 1, 1000, 3);
f->endian = combo_row("_Byte order", kEndian);
GtkWidget* net = expander("Network", {f->port, f->bind, f->multicast, f->iface});
GtkWidget* timing = expander("Timing and format", {f->rate, f->endian});
f->group = adw_expander_row_new();
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->group), "Signals");
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(net), TRUE);
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->group), TRUE);
GtkWidget* add = gtk_button_new_with_label("Add signal");
gtk_widget_set_valign(add, GTK_ALIGN_CENTER);
gtk_widget_set_name(add, "flatudp-add");
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->group), add);
g_signal_connect_data(add, "clicked", G_CALLBACK(on_add), new FormPtr(f), free_form,
static_cast<GConnectFlags>(0));
for (GtkWidget* w : {net, timing, f->group}) hook_destroy(w, f);
for (GtkWidget* w : {f->bind, f->multicast, f->iface}) hook_entry(w, f);
for (GtkWidget* w : {f->port, f->rate}) hook_notify(w, "notify::value", f);
hook_notify(f->endian, "notify::selected", f);
f->refresh();
ConfigForm out;
out.rows = {net, timing, f->group};
out.description =
"The signal list must match the sender's exactly, in order: the datagrams carry no "
"description, and a datagram of any other size is dropped";
out.focus = f->rate;
out.write = [f](const SourceConfig& c) { f->write(c); };
out.read = [f]() { return f->read(); };
out.valid = [f]() { return f->valid(); };
ChangeCb cb = f->changed;
out.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
return out;
}
};
} // namespace
std::unique_ptr<SourceKind> make_flatudp_kind() { return std::make_unique<FlatUdpKind>(); }
} // namespace udpscope
```
Notes for the implementer: `adw_expander_row_remove` and `adw_entry_row_add_suffix` are in libadwaita 1.8. `std::min` needs `<algorithm>`; add it to the includes if the compiler asks. The remove handler removes the row from inside its own button's `clicked` emission: GTK keeps the emitting instance alive until the emission ends, which is why `on_remove` copies the form and the row out of its context first. If the probe in step 3 shows a `Gtk-CRITICAL` or a crash on remove, defer the removal with `g_idle_add` (carrying the same two values) instead of changing the ownership scheme.
- [ ] **Step 2: Add the form check to the probe**
In `src/ui_probe.cpp`, in the anonymous namespace after `flatudp_breaks`, add:
```cpp
/// The connect form's validity rules and its signal-row editor, driven through the widgets: the
/// empty form is not connectable; a good list is, and its subtitle names the implied size; a
/// duplicate name and an over-size list are not; "Add signal" appends `S1`, `S2`; a row's
/// "Remove" button takes that row out.
void flatudp_form_probe() {
SourceKind* k = find_kind("flatudp");
if (k == nullptr) {
std::fprintf(stderr, "[flatudp] form: no flatudp kind\n");
return;
}
ConfigForm f = k->make_config_form();
const bool initial = f.valid();
const auto with = [&](const char* signals) {
SourceConfig c;
c.set("rate", "1000");
c.set("signals", signals);
f.write(c);
return f.valid();
};
const auto subtitle = [&]() {
const char* t = adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(f.rows[2]));
return std::string(t != nullptr ? t : "");
};
const bool good = with("A:uint8,B:float32,W:int16[4]");
const bool size_ok = subtitle().find("13 bytes") != std::string::npos;
const bool dup = with("A:uint8,A:float32");
const bool dup_ok = subtitle().find("twice") != std::string::npos;
const bool big = with("A:float64[4096],B:float64[4096]");
with("A:uint8,B:float32");
GtkWidget* add = find_named(f.rows[2], "flatudp-add");
if (add != nullptr) {
g_signal_emit_by_name(add, "clicked");
g_signal_emit_by_name(add, "clicked");
}
const bool added = f.read().get("signals") == "A:uint8,B:float32,S1:float32,S2:float32";
GtkWidget* rm = find_named(f.rows[2], "flatudp-remove"); // the first row's
if (rm != nullptr) g_signal_emit_by_name(rm, "clicked");
const bool removed = f.read().get("signals") == "B:float32,S1:float32,S2:float32";
const bool ok = !initial && good && size_ok && !dup && dup_ok && !big && add != nullptr && added &&
rm != nullptr && removed && f.valid();
std::fprintf(stderr,
"[flatudp] form: initial_valid=%d good_valid=%d size_ok=%d dup_valid=%d dup_msg=%d "
"oversize_valid=%d add=%d remove=%d ok=%d\n",
initial ? 1 : 0, good ? 1 : 0, size_ok ? 1 : 0, dup ? 1 : 0, dup_ok ? 1 : 0,
big ? 1 : 0, added ? 1 : 0, removed ? 1 : 0, ok ? 1 : 0);
// The rows were never parented; sink and release them so the form leaves nothing behind.
for (GtkWidget* row : f.rows) {
g_object_ref_sink(row);
g_object_unref(row);
}
}
```
In `flatudp_probe`, directly after the `[flatudp] codec:` `fprintf`, add the call:
```cpp
flatudp_form_probe();
```
The anonymous-namespace helper `find_named` is defined earlier in the file (before `source_panel_content`), so it is in scope.
- [ ] **Step 3: Build and run the form check (no sender needed)**
```bash
cmake --build build -j8 2>&1 | grep -E "warning|error" | head
UDPSCOPE_PROBE_SOURCE_KINDS=60000 UDPSCOPE_PROBE_FLATUDP=1500 UDPSCOPE_EXIT_AFTER_MS=3000 \
timeout 30 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 \
| grep -E "\[flatudp\]|Gtk-CRITICAL|Gtk-WARNING|gtk warnings"
```
Expected: no build warning from `flatudp`;
```
[flatudp] codec: ok=1
[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 ok=1
[flatudp] no flatudp source (run with --source flatudp)
gtk warnings: 0
```
and no `Gtk-CRITICAL`. A `Gtk-CRITICAL` here is a widget used after removal or a row removed from the wrong container: fix it, do not filter it out.
Then the `[source-kinds]` round trip once more (the row editor changed how `signals` is written) and one full mock run (the vector list of Task 2, step 8, shortened is enough: `UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000`, no gap) to confirm the stream lines are unchanged:
```bash
UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 \
xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep -E "form flatudp|kinds=|gtk warnings|Gtk-CRITICAL"
python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] --bad-every 1 &
MOCK=$!
sleep 1
UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 xvfb-run -a build/udpscope --source flatudp \
--source-opt port=44588 --source-opt rate=1000 --source-opt signals=Trigger:uint8,Ia:float32,Cnt:uint64,Wave:int16[4] 2>&1 \
| grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings"
kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null
```
Expected: `form flatudp: round_trip=1 valid=1`, `kinds=udps,e1608,flatudp,probe`; in the mock run every line `ok=1` (the `form:` line included) and `gtk warnings: 0`.
- [ ] **Step 4: Multicast loopback (if the sandbox allows it)**
```bash
python3 tools/mock_flatudp.py --port 44591 --rate 1000 --signals Trigger:uint8,Ia:float32 \
--multicast 239.255.42.99 --mcast-if 127.0.0.1 &
MOCK=$!
sleep 1
UDPSCOPE_PROBE_FLATUDP=2500 UDPSCOPE_EXIT_AFTER_MS=5000 timeout 30 xvfb-run -a build/udpscope --source flatudp \
--source-opt port=44591 --source-opt rate=1000 --source-opt multicast=239.255.42.99 --source-opt iface=127.0.0.1 \
--source-opt signals=Trigger:uint8,Ia:float32 2>&1 | grep -E "\[flatudp\]|Gtk-CRITICAL|gtk warnings"
kill $MOCK 2>/dev/null; wait $MOCK 2>/dev/null
```
Expected: `stream: connected=1 ... ok=1` and `continuity ... ok=1`. If the sandbox or the `lo` interface refuses multicast (`ip link show lo` lacks `MULTICAST`, the join fails with "cannot join multicast group ..." or `stream` stays `connected=0`), record **"multicast not verified here"** in the commit message and in the README paragraph (step 5); do not claim it works.
- [ ] **Step 5: README**
In `README.md`'s "Data sources" section, change the sentence "`--help` lists the registered kinds." paragraph's neighbours as follows. After the E-1608 paragraph that ends "...not why." (before "A session file written before kinds existed"), insert:
````markdown
The third built-in kind is **Flat UDP**, a receiver for the MARTe2 `UDPSender` datasource: one datagram per
cycle, the signals concatenated in declaration order with no header, no padding and no timestamp, in the
sender's byte order. The datagram describes nothing, so **the signal list in the connection form is the
whole contract and is your responsibility**: one row per signal (a name, a type from `uint8` to `float64`,
and an element count; a count above 1 is a vector signal), in the sender's order, including the `Trigger`
byte an IndependentThread `UDPSender` puts first. The form shows the size the list implies ("41 bytes per
datagram"); a datagram of any other size is dropped, counted as stale, and reported once per distinct size
on stderr as `[flatudp] ...`. The form also takes the port (44488, the sender's own default), an optional bind
address, an optional multicast group with the interface to join it on, the byte order, and the sender's
**cycle rate in Hz, which is required**: it sizes the ring and the ingest decimation and spaces the samples
within a frame. Time is stamped by the existing pipeline from arrival and that declared rate (and
`--relative-time` gives a counted clock); there is no timestamp in the stream to prefer. **Loss is never
reported** -- with no counter in the datagram, a missed cycle is indistinguishable from a slow one -- and
neither is a changed sender: a list that does not match reads as "everything dropped". Source-address
filtering, detecting the list from the traffic and importing a MARTe2 `.cfg` are not provided. A list of
scalars is delivered in frames of about 10 ms (`round(rate / 100)` cycles, at most 1024; a partial frame is
flushed after 100 ms); a list with any vector signal delivers one frame per datagram. The source is
connected on the first valid datagram and disconnected after 2 s without one. It is verified against
`tools/mock_flatudp.py`, not against a MARTe2 application. *(Multicast: state here whether the loopback run of
the plan's step 4 passed or was not verifiable.)*
````
Replace the italic parenthetical in the inserted text with the real outcome of step 4 (either "Multicast was verified on a loopback group." or "Multicast joins were not verified here: the sandbox's loopback has no multicast route."). Then change the paragraph "Planned, each as its own sub-project: a flat UDP kind and ABScope. ABScope has more than one timebase, ..." to start "Planned, as its own sub-project: ABScope. It has more than one timebase, ...", keeping the rest of the sentence.
- [ ] **Step 6: Architecture notes**
In `docs/claude/architecture.md`, after the E-1608 bullet about `E1608Readouts` / stderr (the last E-1608 bullet in the source-plugins list), add:
````markdown
- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec,
the validity rules and `decode_datagram`, with no GTK and no sockets (the probe round-trips it);
`flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form. The datagram has
no description, so a list that is wrong is invisible except as dropped datagrams: the size check is the
only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per size.
- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the
sample count varies): `round(rate / 100)` datagrams for an all-scalar list, a partial block flushed after
100 ms. A vector signal carries one sample per `Frame` (`Frame::value` indexes by sample only when the
value count equals `num_samples` and the signal has one element), so **one vector row forces
one datagram per frame**. `rate` is required because `sampling_rate = 0` makes the model size the ring and
the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping
path that ignores the declared cadence.
- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets
`Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the
history survives (`CONFIG` is sent once, from the first `poll()`).
````
- [ ] **Step 7: Verification and probe-reading docs**
`docs/claude/verification.md`: in the `UDPSCOPE_PROBE_FLATUDP` entry added in Task 2, change "a hand-computed codec and decode check (it needs no source)" to "a hand-computed codec and decode check and the connect form's validity rules and signal-row editor (both need no source)".
`docs/claude/probe-readings.md`: in the `## [flatudp]` block add, after the `codec` line:
```
[flatudp] form: initial_valid=0 good_valid=1 size_ok=1 dup_valid=0 dup_msg=1 oversize_valid=0 add=1 remove=1 ok=1
```
and, after the `codec: ok=1` bullet, this bullet:
````markdown
- `form: ... ok=1`: driven through the widgets, not the settings. `initial_valid=0` -- an empty list is not
connectable (`1` is a form that lets a source with no signals through). `good_valid=1 size_ok=1` -- a
13-byte list is valid and its subtitle says `13 bytes per datagram`. `dup_valid=0 dup_msg=1` -- a duplicate
name is refused *and the subtitle says why* ("used twice"). `oversize_valid=0` -- two `float64[4096]` rows
(65536 bytes) exceed a datagram. `add=1` -- two clicks on "Add signal" append `S1` and `S2`; `remove=1` --
the first row's "Remove" button takes that row out. A `0` on `add`/`remove` with a `Gtk-CRITICAL` is a row
handled after it was removed. Like `codec`, it needs no flatudp source.
````
- [ ] **Step 8: Final baselines and commit**
A clean build must show exactly the 3 baseline warnings. Build from scratch in a scratch directory so the main build tree is untouched:
```bash
cmake -S . -B /tmp/flatudp-clean >/dev/null && cmake --build /tmp/flatudp-clean -j8 2>&1 | grep -c "warning:"
rm -rf /tmp/flatudp-clean
```
Expected: `3`. Anything else, read the new warning: it comes from a flatudp file.
```bash
git add src/source/flatudp/flatudp_kind.cpp src/ui_probe.cpp README.md \
docs/claude/architecture.md docs/claude/verification.md docs/claude/probe-readings.md
git commit -m "flatudp: the signal-row editor form, its probe lines, README and architecture notes"
```
Then ask the user whether to merge `feature/source-flatudp` into `feature/source-plugins`; do not merge or push on your own.