2204 lines
95 KiB
Markdown
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.
|